1
//! Clipboard Manager
2
//!
3
//! Manages clipboard content flow between the system clipboard and
4
//! the application.
5
//!
6
//! ## Architecture
7
//!
8
//! The clipboard manager acts as a bridge between system clipboard
9
//! operations and user callbacks:
10
//!
11
//! 1. **Paste Flow**: System clipboard → `ClipboardManager` → User Callback → `TextInputManager`
12
//!
13
//!    - When Ctrl+V is pressed, `event_v2` reads system clipboard and calls `set_paste_content()`
14
//!    - User's `On::Paste` callback can inspect content via `get_clipboard_content()`
15
//!    - User can modify/block paste by not calling the default paste action
16
//!    - After callback, content is cleared for next operation
17
//!
18
//! 2. **Copy Flow**: Selection → User Callback → `ClipboardManager` → System clipboard
19
//!
20
//!    - When Ctrl+C is pressed, user's `On::Copy` callback fires
21
//!    - Callback can inspect selected content and override via `set_copy_content()`
22
//!    - `event_v2` calls `get_copy_content()` to get final content (override or default)
23
//!    - Content is written to system clipboard via platform sync
24
//!    - After callback, content is cleared for next operation
25
//!
26
//! 3. **Cut Flow**: Same as Copy + delete selection
27

            
28
use crate::managers::selection::ClipboardContent;
29

            
30
/// Manages clipboard content flow between system clipboard and application
31
///
32
/// This manager temporarily holds clipboard content during clipboard operations,
33
/// allowing user callbacks to inspect and modify content before it's committed
34
/// to the system clipboard or pasted into the document.
35
#[derive(Debug, Clone, Default)]
36
pub struct ClipboardManager {
37
    /// Content from system clipboard when paste is triggered
38
    /// Available to user callbacks via `CallbackInfo::get_clipboard_content()`
39
    pending_paste_content: Option<ClipboardContent>,
40

            
41
    /// Content to be written to system clipboard after copy/cut
42
    /// Set by user callbacks via `CallbackInfo::set_copy_content()`
43
    pending_copy_content: Option<ClipboardContent>,
44
}
45

            
46
impl ClipboardManager {
47
    /// Create a new empty clipboard manager
48
5741
    #[must_use] pub const fn new() -> Self {
49
5741
        Self {
50
5741
            pending_paste_content: None,
51
5741
            pending_copy_content: None,
52
5741
        }
53
5741
    }
54

            
55
    // Paste Operations (System → Application)
56

            
57
    /// Sets content from the system clipboard (called before paste callbacks).
58
10892
    pub fn set_paste_content(&mut self, content: ClipboardContent) {
59
10892
        self.pending_paste_content = Some(content);
60
10892
    }
61

            
62
    /// Returns the pending paste content, if any.
63
6742
    #[must_use] pub const fn get_paste_content(&self) -> Option<&ClipboardContent> {
64
6742
        self.pending_paste_content.as_ref()
65
6742
    }
66

            
67
    // Copy Operations (Application → System)
68

            
69
    /// Sets content to be copied to the system clipboard.
70
10907
    pub fn set_copy_content(&mut self, content: ClipboardContent) {
71
10907
        self.pending_copy_content = Some(content);
72
10907
    }
73

            
74
    /// Returns the pending copy content, if any.
75
5736
    #[must_use] pub const fn get_copy_content(&self) -> Option<&ClipboardContent> {
76
5736
        self.pending_copy_content.as_ref()
77
5736
    }
78

            
79
    /// Takes the copy content, consuming it.
80
10811
    pub const fn take_copy_content(&mut self) -> Option<ClipboardContent> {
81
10811
        self.pending_copy_content.take()
82
10811
    }
83

            
84
    // Lifecycle Management
85

            
86
    /// Clears all pending clipboard content.
87
741
    pub fn clear(&mut self) {
88
741
        self.pending_paste_content = None;
89
741
        self.pending_copy_content = None;
90
741
    }
91

            
92
    /// Clears only paste content.
93
10758
    pub fn clear_paste(&mut self) {
94
10758
        self.pending_paste_content = None;
95
10758
    }
96

            
97
    /// Clears only copy content.
98
739
    pub fn clear_copy(&mut self) {
99
739
        self.pending_copy_content = None;
100
739
    }
101

            
102
    /// Returns `true` if there's pending paste content.
103
16761
    #[must_use] pub const fn has_paste_content(&self) -> bool {
104
16761
        self.pending_paste_content.is_some()
105
16761
    }
106

            
107
    /// Returns `true` if there's pending copy content.
108
15751
    #[must_use] pub const fn has_copy_content(&self) -> bool {
109
15751
        self.pending_copy_content.is_some()
110
15751
    }
111
}
112

            
113
#[cfg(test)]
114
mod autotest_generated {
115
    use azul_css::{props::basic::ColorU, AzString, OptionString};
116

            
117
    use super::*;
118
    use crate::managers::selection::StyledTextRun;
119

            
120
    // =========================================================================
121
    // Fixtures
122
    //
123
    // `ClipboardManager` is a two-slot `Option` cell, so the adversarial
124
    // surface is not arithmetic but *ownership and state*: the payload
125
    // (`ClipboardContent`) owns FFI vectors (`AzString`/`StyledTextRunVec`)
126
    // with destructor function pointers, so every set / take / clear / clone
127
    // is a chance for a double-free, a shallow clone that aliases a heap
128
    // buffer, or a slot leaking into the wrong slot. The tests below therefore
129
    // hammer: slot isolation, take-consumes semantics, deep-clone
130
    // independence + use-after-free, hostile payloads (1 MiB text, NUL bytes,
131
    // emoji/RTL/combining marks, NaN / infinite / negative-zero font sizes),
132
    // and a model-based state machine over the whole API.
133
    // =========================================================================
134

            
135
    /// Plain-text clipboard payload with no styled runs (what every live
136
    /// producer in the engine currently builds).
137
    fn plain(text: &str) -> ClipboardContent {
138
        ClipboardContent {
139
            plain_text: AzString::from(text),
140
            styled_runs: Vec::<StyledTextRun>::new().into(),
141
        }
142
    }
143

            
144
    /// A single styled run, parameterized on the numerically hostile field.
145
    fn run(text: &str, font_size_px: f32, family: Option<&str>) -> StyledTextRun {
146
        StyledTextRun {
147
            text: AzString::from(text),
148
            font_family: family.map_or(OptionString::None, |f| {
149
                OptionString::Some(AzString::from(f))
150
            }),
151
            font_size_px,
152
            color: ColorU {
153
                r: 1,
154
                g: 2,
155
                b: 3,
156
                a: 4,
157
            },
158
            is_bold: true,
159
            is_italic: false,
160
        }
161
    }
162

            
163
    /// Rich clipboard payload carrying `runs`.
164
    fn rich(text: &str, runs: Vec<StyledTextRun>) -> ClipboardContent {
165
        ClipboardContent {
166
            plain_text: AzString::from(text),
167
            styled_runs: runs.into(),
168
        }
169
    }
170

            
171
    /// Strings that have historically broken UTF-8 / FFI string handling.
172
    fn hostile_strings() -> Vec<String> {
173
        vec![
174
            String::new(),                                  // empty
175
            "\0".to_string(),                               // lone NUL
176
            "a\0b\0\0c".to_string(),                        // interior NULs
177
            "\r\n\t\x0b\x0c\x1b[0m".to_string(),            // control chars + ANSI
178
            "👨‍👩‍👧‍👦".to_string(),                            // ZWJ emoji family
179
            "e\u{0301}\u{0301}\u{0301}".to_string(),        // stacked combining marks
180
            "مرحبا بالعالم".to_string(),                    // RTL
181
            "\u{202e}reversed\u{202c}".to_string(),         // bidi override
182
            "\u{feff}bom".to_string(),                      // BOM
183
            "𝕬𝖟𝖚𝖑".to_string(),                            // 4-byte codepoints
184
            "\u{10ffff}".to_string(),                       // max scalar value
185
            "line1\nline2\r\nline3".to_string(),            // mixed newlines
186
            "x".repeat(1024 * 1024),                        // 1 MiB
187
            "🦀".repeat(100_000),                           // 400 KiB of 4-byte chars
188
        ]
189
    }
190

            
191
    /// NaN-tolerant structural comparison: `ClipboardContent` derives
192
    /// `PartialEq`, but `StyledTextRun::font_size_px` is an `f32`, so `==` is
193
    /// not reflexive once a NaN is in play. Compare bit patterns instead.
194
    fn content_eq_bitwise(a: &ClipboardContent, b: &ClipboardContent) -> bool {
195
        if a.plain_text.as_str() != b.plain_text.as_str() {
196
            return false;
197
        }
198
        let (ra, rb) = (a.styled_runs.as_slice(), b.styled_runs.as_slice());
199
        ra.len() == rb.len()
200
            && ra.iter().zip(rb.iter()).all(|(x, y)| {
201
                x.text.as_str() == y.text.as_str()
202
                    && x.font_family == y.font_family
203
                    && x.font_size_px.to_bits() == y.font_size_px.to_bits()
204
                    && x.color == y.color
205
                    && x.is_bold == y.is_bold
206
                    && x.is_italic == y.is_italic
207
            })
208
    }
209

            
210
    // =========================================================================
211
    // 1. Constructor + invariants
212
    // =========================================================================
213

            
214
    #[test]
215
    fn new_starts_empty_on_both_slots() {
216
        let m = ClipboardManager::new();
217
        assert!(m.get_paste_content().is_none());
218
        assert!(m.get_copy_content().is_none());
219
        assert!(!m.has_paste_content());
220
        assert!(!m.has_copy_content());
221
    }
222

            
223
    #[test]
224
    fn new_is_usable_in_const_context() {
225
        // `new()` is declared `const fn`; if that ever regresses this stops
226
        // compiling rather than silently becoming a runtime constructor.
227
        const EMPTY: ClipboardManager = ClipboardManager::new();
228
        assert!(!EMPTY.has_paste_content());
229
        assert!(!EMPTY.has_copy_content());
230
    }
231

            
232
    #[test]
233
    fn default_is_indistinguishable_from_new() {
234
        let d = ClipboardManager::default();
235
        let n = ClipboardManager::new();
236
        assert_eq!(d.has_paste_content(), n.has_paste_content());
237
        assert_eq!(d.has_copy_content(), n.has_copy_content());
238
        assert_eq!(d.get_paste_content(), n.get_paste_content());
239
        assert_eq!(d.get_copy_content(), n.get_copy_content());
240
    }
241

            
242
    // =========================================================================
243
    // 2. Round-trip: what goes in comes back out, byte for byte
244
    // =========================================================================
245

            
246
    #[test]
247
    fn paste_roundtrip_preserves_hostile_payloads() {
248
        for s in hostile_strings() {
249
            let mut m = ClipboardManager::new();
250
            m.set_paste_content(plain(&s));
251

            
252
            let got = m.get_paste_content().expect("paste content must be set");
253
            assert_eq!(
254
                got.plain_text.as_str(),
255
                s.as_str(),
256
                "paste payload mutated (len {})",
257
                s.len()
258
            );
259
            assert_eq!(got.plain_text.as_str().len(), s.len(), "byte length changed");
260
            assert!(m.has_paste_content());
261
            // The copy slot must stay untouched by a paste write.
262
            assert!(!m.has_copy_content());
263
        }
264
    }
265

            
266
    #[test]
267
    fn copy_roundtrip_preserves_hostile_payloads() {
268
        for s in hostile_strings() {
269
            let mut m = ClipboardManager::new();
270
            m.set_copy_content(plain(&s));
271

            
272
            assert_eq!(
273
                m.get_copy_content()
274
                    .expect("copy content must be set")
275
                    .plain_text
276
                    .as_str(),
277
                s.as_str()
278
            );
279
            // take() must hand back exactly what was put in.
280
            let taken = m.take_copy_content().expect("take must yield the content");
281
            assert_eq!(taken.plain_text.as_str(), s.as_str());
282
            assert_eq!(taken.plain_text.as_str().chars().count(), s.chars().count());
283
            assert!(!m.has_paste_content());
284
        }
285
    }
286

            
287
    #[test]
288
    fn empty_string_content_is_still_present_content() {
289
        // Presence, not emptiness: an empty selection copied to the clipboard
290
        // must still register as "there is content", otherwise the copy path
291
        // would silently fall back to a stale system clipboard.
292
        let mut m = ClipboardManager::new();
293
        m.set_paste_content(plain(""));
294
        m.set_copy_content(plain(""));
295

            
296
        assert!(m.has_paste_content());
297
        assert!(m.has_copy_content());
298
        assert_eq!(m.get_paste_content().unwrap().plain_text.as_str(), "");
299
        assert_eq!(m.get_copy_content().unwrap().plain_text.as_str(), "");
300
    }
301

            
302
    #[test]
303
    fn one_mib_payload_survives_a_full_set_take_cycle() {
304
        let big = "az".repeat(512 * 1024); // exactly 1 MiB
305
        let mut m = ClipboardManager::new();
306
        m.set_copy_content(plain(&big));
307

            
308
        let taken = m.take_copy_content().expect("1 MiB payload must round-trip");
309
        assert_eq!(taken.plain_text.as_str().len(), 1024 * 1024);
310
        assert_eq!(taken.plain_text.as_str(), big.as_str());
311
        assert!(!m.has_copy_content());
312
    }
313

            
314
    #[test]
315
    fn styled_runs_roundtrip_intact() {
316
        let runs = vec![
317
            run("hello", 12.0, Some("Arial")),
318
            run("", 0.0, None),
319
            run("🦀", 999.5, Some("")),
320
        ];
321
        let content = rich("hello🦀", runs);
322

            
323
        let mut m = ClipboardManager::new();
324
        m.set_copy_content(content.clone());
325

            
326
        let got = m.get_copy_content().expect("rich content must be set");
327
        assert_eq!(got.styled_runs.as_slice().len(), 3);
328
        assert!(content_eq_bitwise(got, &content));
329
        // Derived PartialEq must agree with the structural compare when no NaN
330
        // is involved.
331
        assert_eq!(*got, content);
332

            
333
        let taken = m.take_copy_content().unwrap();
334
        assert!(content_eq_bitwise(&taken, &content));
335
    }
336

            
337
    #[test]
338
    fn many_styled_runs_roundtrip() {
339
        let runs: Vec<StyledTextRun> = (0..5_000)
340
            .map(|i| run(&format!("run-{i}"), i as f32, Some("Font")))
341
            .collect();
342
        let content = rich("many", runs);
343

            
344
        let mut m = ClipboardManager::new();
345
        m.set_paste_content(content.clone());
346

            
347
        let got = m.get_paste_content().unwrap();
348
        assert_eq!(got.styled_runs.as_slice().len(), 5_000);
349
        assert_eq!(got.styled_runs.as_slice()[4_999].text.as_str(), "run-4999");
350
        assert!(content_eq_bitwise(got, &content));
351
    }
352

            
353
    // =========================================================================
354
    // 3. Numeric hostility carried through the manager
355
    // =========================================================================
356

            
357
    #[test]
358
    fn extreme_font_sizes_pass_through_bit_exact() {
359
        let extremes = [
360
            f32::INFINITY,
361
            f32::NEG_INFINITY,
362
            f32::MAX,
363
            f32::MIN,
364
            f32::MIN_POSITIVE,
365
            f32::EPSILON,
366
            0.0_f32,
367
            -0.0_f32,
368
            -1.0_f32,
369
        ];
370

            
371
        for size in extremes {
372
            let content = rich("x", vec![run("x", size, None)]);
373
            let mut m = ClipboardManager::new();
374
            m.set_copy_content(content.clone());
375

            
376
            let got = m.take_copy_content().expect("content must survive");
377
            let stored = got.styled_runs.as_slice()[0].font_size_px;
378
            // `to_bits` so that -0.0 != 0.0 is actually caught (they compare
379
            // equal under `==`).
380
            assert_eq!(
381
                stored.to_bits(),
382
                size.to_bits(),
383
                "font_size_px {size} was not preserved bit-exactly"
384
            );
385
        }
386
    }
387

            
388
    #[test]
389
    fn nan_font_size_survives_but_breaks_derived_equality() {
390
        let content = rich("nan", vec![run("nan", f32::NAN, Some("F"))]);
391
        let mut m = ClipboardManager::new();
392
        m.set_paste_content(content.clone());
393

            
394
        let got = m.get_paste_content().expect("NaN payload must still be stored");
395
        assert!(
396
            got.styled_runs.as_slice()[0].font_size_px.is_nan(),
397
            "NaN font size must be stored as-is, not normalized"
398
        );
399
        assert!(content_eq_bitwise(got, &content));
400

            
401
        // Documented consequence: derived PartialEq on ClipboardContent is not
402
        // reflexive once a NaN run is present. Callers must not use `==` on
403
        // clipboard content to detect "unchanged" when styled runs are in play.
404
        assert_ne!(content, content.clone());
405
        assert_ne!(m.get_paste_content(), Some(&content));
406

            
407
        // Predicates are unaffected by the payload's numeric contents.
408
        assert!(m.has_paste_content());
409
    }
410

            
411
    #[test]
412
    fn extreme_colors_pass_through() {
413
        for (r, g, b, a) in [(0, 0, 0, 0), (255, 255, 255, 255), (0, 255, 0, 1)] {
414
            let mut st = run("c", 1.0, None);
415
            st.color = ColorU { r, g, b, a };
416
            let mut m = ClipboardManager::new();
417
            m.set_copy_content(rich("c", vec![st]));
418

            
419
            let got = m.take_copy_content().unwrap();
420
            assert_eq!(got.styled_runs.as_slice()[0].color, ColorU { r, g, b, a });
421
        }
422
    }
423

            
424
    // =========================================================================
425
    // 4. take / clear semantics + slot isolation
426
    // =========================================================================
427

            
428
    #[test]
429
    fn take_copy_content_consumes_exactly_once() {
430
        let mut m = ClipboardManager::new();
431
        m.set_copy_content(plain("once"));
432
        assert!(m.has_copy_content());
433

            
434
        assert_eq!(
435
            m.take_copy_content().unwrap().plain_text.as_str(),
436
            "once",
437
            "first take must yield the content"
438
        );
439
        assert!(!m.has_copy_content(), "take must consume the slot");
440
        assert!(m.get_copy_content().is_none());
441
        assert!(
442
            m.take_copy_content().is_none(),
443
            "a second take must not resurrect the content"
444
        );
445
        assert!(m.take_copy_content().is_none());
446
    }
447

            
448
    #[test]
449
    fn take_on_fresh_manager_returns_none_repeatedly() {
450
        let mut m = ClipboardManager::new();
451
        for _ in 0..100 {
452
            assert!(m.take_copy_content().is_none());
453
        }
454
        assert!(!m.has_copy_content());
455
    }
456

            
457
    #[test]
458
    fn take_copy_does_not_disturb_paste_slot() {
459
        let mut m = ClipboardManager::new();
460
        m.set_paste_content(plain("paste"));
461
        m.set_copy_content(plain("copy"));
462

            
463
        let taken = m.take_copy_content().unwrap();
464
        assert_eq!(taken.plain_text.as_str(), "copy");
465
        assert!(m.has_paste_content(), "taking copy must not clear paste");
466
        assert_eq!(m.get_paste_content().unwrap().plain_text.as_str(), "paste");
467
    }
468

            
469
    #[test]
470
    fn set_overwrites_rather_than_accumulates() {
471
        let mut m = ClipboardManager::new();
472
        for i in 0..50 {
473
            m.set_paste_content(plain(&format!("paste-{i}")));
474
            m.set_copy_content(plain(&format!("copy-{i}")));
475
        }
476
        assert_eq!(m.get_paste_content().unwrap().plain_text.as_str(), "paste-49");
477
        assert_eq!(m.get_copy_content().unwrap().plain_text.as_str(), "copy-49");
478

            
479
        // And the last write wins for take() too.
480
        assert_eq!(
481
            m.take_copy_content().unwrap().plain_text.as_str(),
482
            "copy-49"
483
        );
484
    }
485

            
486
    #[test]
487
    fn clear_empties_both_slots() {
488
        let mut m = ClipboardManager::new();
489
        m.set_paste_content(plain("p"));
490
        m.set_copy_content(plain("c"));
491
        m.clear();
492

            
493
        assert!(!m.has_paste_content());
494
        assert!(!m.has_copy_content());
495
        assert!(m.get_paste_content().is_none());
496
        assert!(m.get_copy_content().is_none());
497
        assert!(m.take_copy_content().is_none());
498
    }
499

            
500
    #[test]
501
    fn clear_paste_and_clear_copy_are_slot_isolated() {
502
        let mut m = ClipboardManager::new();
503
        m.set_paste_content(plain("p"));
504
        m.set_copy_content(plain("c"));
505

            
506
        m.clear_paste();
507
        assert!(!m.has_paste_content());
508
        assert!(m.has_copy_content(), "clear_paste must not touch the copy slot");
509
        assert_eq!(m.get_copy_content().unwrap().plain_text.as_str(), "c");
510

            
511
        m.set_paste_content(plain("p2"));
512
        m.clear_copy();
513
        assert!(!m.has_copy_content());
514
        assert!(m.has_paste_content(), "clear_copy must not touch the paste slot");
515
        assert_eq!(m.get_paste_content().unwrap().plain_text.as_str(), "p2");
516
    }
517

            
518
    #[test]
519
    fn clears_are_idempotent_on_an_empty_manager() {
520
        let mut m = ClipboardManager::new();
521
        for _ in 0..10 {
522
            m.clear();
523
            m.clear_paste();
524
            m.clear_copy();
525
        }
526
        assert!(!m.has_paste_content());
527
        assert!(!m.has_copy_content());
528

            
529
        // ...and idempotent after a real clear, too.
530
        m.set_paste_content(plain("x"));
531
        m.clear_paste();
532
        m.clear_paste();
533
        m.clear_paste();
534
        assert!(m.get_paste_content().is_none());
535
    }
536

            
537
    // =========================================================================
538
    // 5. Predicate / getter invariants
539
    // =========================================================================
540

            
541
    #[test]
542
    fn predicates_always_agree_with_getters() {
543
        let mut m = ClipboardManager::new();
544
        let check = |m: &ClipboardManager| {
545
            assert_eq!(m.has_paste_content(), m.get_paste_content().is_some());
546
            assert_eq!(m.has_copy_content(), m.get_copy_content().is_some());
547
        };
548

            
549
        check(&m);
550
        m.set_paste_content(plain(""));
551
        check(&m);
552
        m.set_copy_content(plain("\0"));
553
        check(&m);
554
        m.clear_paste();
555
        check(&m);
556
        let _ = m.take_copy_content();
557
        check(&m);
558
        m.clear();
559
        check(&m);
560
    }
561

            
562
    #[test]
563
    fn getters_are_stable_across_repeated_reads() {
564
        let mut m = ClipboardManager::new();
565
        m.set_paste_content(plain("stable"));
566

            
567
        for _ in 0..1_000 {
568
            assert_eq!(m.get_paste_content().unwrap().plain_text.as_str(), "stable");
569
            assert!(m.has_paste_content());
570
        }
571
    }
572

            
573
    // =========================================================================
574
    // 6. Ownership: deep clone, no aliasing, no use-after-free
575
    // =========================================================================
576

            
577
    #[test]
578
    fn clone_is_deep_and_independent() {
579
        let mut original = ClipboardManager::new();
580
        original.set_paste_content(plain("original-paste"));
581
        original.set_copy_content(plain("original-copy"));
582

            
583
        let mut cloned = original.clone();
584
        // Mutating the clone must not reach back into the original.
585
        cloned.set_paste_content(plain("clone-paste"));
586
        let _ = cloned.take_copy_content();
587

            
588
        assert_eq!(
589
            original.get_paste_content().unwrap().plain_text.as_str(),
590
            "original-paste"
591
        );
592
        assert!(
593
            original.has_copy_content(),
594
            "taking from the clone must not consume the original's copy slot"
595
        );
596
        assert_eq!(
597
            cloned.get_paste_content().unwrap().plain_text.as_str(),
598
            "clone-paste"
599
        );
600
        assert!(!cloned.has_copy_content());
601
    }
602

            
603
    #[test]
604
    fn clone_survives_the_original_being_dropped() {
605
        // `ClipboardContent` owns FFI vectors with destructor pointers: a
606
        // shallow clone here would alias the heap buffer and this test would
607
        // read freed memory / double-free on drop.
608
        let payload = "🦀".repeat(10_000);
609
        let cloned = {
610
            let mut original = ClipboardManager::new();
611
            original.set_paste_content(plain(&payload));
612
            original.set_copy_content(rich("rich", vec![run("r", 1.0, Some("Arial"))]));
613
            let c = original.clone();
614
            drop(original);
615
            c
616
        };
617

            
618
        assert_eq!(
619
            cloned.get_paste_content().unwrap().plain_text.as_str(),
620
            payload.as_str()
621
        );
622
        assert_eq!(
623
            cloned.get_copy_content().unwrap().styled_runs.as_slice()[0]
624
                .text
625
                .as_str(),
626
            "r"
627
        );
628
        // Dropping the clone afterwards must not double-free.
629
        drop(cloned);
630
    }
631

            
632
    #[test]
633
    fn taken_content_outlives_the_manager() {
634
        let taken = {
635
            let mut m = ClipboardManager::new();
636
            m.set_copy_content(plain("outlives"));
637
            let t = m.take_copy_content();
638
            drop(m);
639
            t
640
        };
641
        assert_eq!(taken.unwrap().plain_text.as_str(), "outlives");
642
    }
643

            
644
    #[test]
645
    fn dropping_a_loaded_manager_is_clean() {
646
        for _ in 0..100 {
647
            let mut m = ClipboardManager::new();
648
            m.set_paste_content(plain(&"x".repeat(4096)));
649
            m.set_copy_content(rich("c", vec![run("c", f32::NAN, Some("F"))]));
650
            // Dropped fully loaded, without any clear() — the destructors must
651
            // run exactly once each.
652
            drop(m);
653
        }
654
    }
655

            
656
    #[test]
657
    fn debug_format_does_not_panic_on_hostile_content() {
658
        let mut m = ClipboardManager::new();
659
        m.set_paste_content(plain("a\0b\u{202e}\u{feff}🦀"));
660
        m.set_copy_content(rich("r", vec![run("r", f32::NAN, None)]));
661

            
662
        let s = format!("{m:?}");
663
        assert!(s.contains("ClipboardManager"));
664
    }
665

            
666
    // =========================================================================
667
    // 7. Churn + model-based state machine
668
    // =========================================================================
669

            
670
    #[test]
671
    fn ten_thousand_set_take_cycles_leave_no_residue() {
672
        let mut m = ClipboardManager::new();
673
        for i in 0..10_000_u32 {
674
            m.set_copy_content(plain(&format!("c{i}")));
675
            m.set_paste_content(plain(&format!("p{i}")));
676

            
677
            let taken = m.take_copy_content().expect("copy slot was just filled");
678
            assert_eq!(taken.plain_text.as_str(), format!("c{i}"));
679
            assert!(!m.has_copy_content());
680
            m.clear_paste();
681
            assert!(!m.has_paste_content());
682
        }
683
        assert!(!m.has_paste_content());
684
        assert!(!m.has_copy_content());
685
    }
686

            
687
    #[test]
688
    fn state_machine_matches_a_two_slot_option_model() {
689
        // Drive every mutator in a deterministic pseudo-random order and check
690
        // the manager against a trivial `(Option<String>, Option<String>)`
691
        // model after every single step.
692
        let mut m = ClipboardManager::new();
693
        let mut model: (Option<String>, Option<String>) = (None, None);
694
        let mut seed: u64 = 0x5eed_1234_dead_beef;
695

            
696
        for step in 0..5_000_u32 {
697
            // xorshift64 — no rand dependency, fully reproducible.
698
            seed ^= seed << 13;
699
            seed ^= seed >> 7;
700
            seed ^= seed << 17;
701

            
702
            match seed % 7 {
703
                0 => {
704
                    let s = format!("p{step}");
705
                    m.set_paste_content(plain(&s));
706
                    model.0 = Some(s);
707
                }
708
                1 => {
709
                    let s = format!("c{step}");
710
                    m.set_copy_content(plain(&s));
711
                    model.1 = Some(s);
712
                }
713
                2 => {
714
                    let taken = m.take_copy_content();
715
                    let expected = model.1.take();
716
                    assert_eq!(
717
                        taken.map(|c| c.plain_text.as_str().to_string()),
718
                        expected,
719
                        "take_copy_content diverged from the model at step {step}"
720
                    );
721
                }
722
                3 => {
723
                    m.clear();
724
                    model = (None, None);
725
                }
726
                4 => {
727
                    m.clear_paste();
728
                    model.0 = None;
729
                }
730
                5 => {
731
                    m.clear_copy();
732
                    model.1 = None;
733
                }
734
                _ => {
735
                    // Pure reads must not mutate state.
736
                    let _ = m.get_paste_content();
737
                    let _ = m.get_copy_content();
738
                    let _ = m.has_paste_content();
739
                    let _ = m.has_copy_content();
740
                }
741
            }
742

            
743
            assert_eq!(
744
                m.get_paste_content().map(|c| c.plain_text.as_str().to_string()),
745
                model.0,
746
                "paste slot diverged at step {step}"
747
            );
748
            assert_eq!(
749
                m.get_copy_content().map(|c| c.plain_text.as_str().to_string()),
750
                model.1,
751
                "copy slot diverged at step {step}"
752
            );
753
            assert_eq!(m.has_paste_content(), model.0.is_some());
754
            assert_eq!(m.has_copy_content(), model.1.is_some());
755
        }
756
    }
757

            
758
    #[test]
759
    fn documented_paste_then_copy_flow() {
760
        // The module doc's contract: paste content is set by the platform,
761
        // read by the callback, then cleared; copy content is set by the
762
        // callback, taken by the platform, then gone.
763
        let mut m = ClipboardManager::new();
764

            
765
        // 1. Paste flow: system -> manager -> callback -> clear.
766
        m.set_paste_content(plain("from system"));
767
        assert_eq!(
768
            m.get_paste_content().unwrap().plain_text.as_str(),
769
            "from system"
770
        );
771
        m.clear_paste();
772
        assert!(!m.has_paste_content());
773

            
774
        // 2. Copy flow: callback overrides -> platform takes -> slot empties.
775
        m.set_copy_content(plain("default selection"));
776
        m.set_copy_content(plain("callback override"));
777
        assert_eq!(
778
            m.take_copy_content().unwrap().plain_text.as_str(),
779
            "callback override",
780
            "the callback's override must win over the default selection"
781
        );
782
        assert!(
783
            !m.has_copy_content(),
784
            "the copy slot must not leak into the next clipboard operation"
785
        );
786
    }
787
}