Skip to main content

azul_layout/managers/
changeset.rs

1//! Text editing changeset system
2//!
3//! **STATUS:** The core types (`TextChangeset`, `TextOperation`, `TextOp*` structs) are
4//! actively used by `window.rs`, `undo_redo.rs`, `event.rs`, and platform code.
5//!
6//! The live copy/cut/select-all/delete paths run through `common/event.rs`
7//! (`SystemChange::CopyToClipboard`/`CutToClipboard`, `CallbackChange::SetSelectAllRange`,
8//! `LayoutWindow::delete_selection`), not through changeset constructors. The earlier
9//! `create_*_changeset` helpers were a never-wired parallel implementation (with
10//! placeholder `deleted_text`, `CursorPosition::Uninitialized` cursors, and byte±1
11//! UTF-8 deletion) and have been removed.
12//!
13//! ## Architecture
14//!
15//! This module implements a two-phase changeset system for all text editing operations:
16//! 1. **Create changesets** (pre-callback): Analyze what would change, don't mutate yet
17//! 2. **Apply changesets** (post-callback): Actually mutate state if !preventDefault
18//!
19//! This pattern enables:
20//! - preventDefault support for ALL operations (not just text input)
21//! - Undo/redo stack (record changesets before applying)
22//! - Validation (check bounds, permissions before mutation)
23//! - Inspection (user callbacks can see planned changes)
24
25use azul_core::{
26    dom::DomNodeId,
27    selection::{OptionSelectionRange, SelectionRange},
28    task::Instant,
29    window::CursorPosition,
30};
31use azul_css::AzString;
32
33use crate::managers::selection::ClipboardContent;
34
35/// Unique identifier for a changeset (for undo/redo)
36pub type ChangesetId = usize;
37
38/// A text editing changeset that can be inspected before application
39#[derive(Debug, Clone)]
40#[repr(C)]
41pub struct TextChangeset {
42    /// Unique ID for undo/redo tracking
43    pub id: ChangesetId,
44    /// Target DOM node
45    pub target: DomNodeId,
46    /// The operation to perform
47    pub operation: TextOperation,
48    /// When this changeset was created
49    pub timestamp: Instant,
50}
51
52/// Insert text at cursor position
53#[derive(Debug, Clone)]
54#[repr(C)]
55pub struct TextOpInsertText {
56    pub text: AzString,
57    pub position: CursorPosition,
58    pub new_cursor: CursorPosition,
59}
60
61/// Delete text in range
62#[derive(Debug, Clone)]
63#[repr(C)]
64pub struct TextOpDeleteText {
65    pub range: SelectionRange,
66    pub deleted_text: AzString,
67    pub new_cursor: CursorPosition,
68}
69
70/// Replace text in range with new text
71#[derive(Debug, Clone)]
72#[repr(C)]
73pub struct TextOpReplaceText {
74    pub range: SelectionRange,
75    pub old_text: AzString,
76    pub new_text: AzString,
77    pub new_cursor: CursorPosition,
78}
79
80/// Set selection to new range
81#[derive(Copy, Debug, Clone)]
82#[repr(C)]
83pub struct TextOpSetSelection {
84    pub old_range: OptionSelectionRange,
85    pub new_range: SelectionRange,
86}
87
88/// Extend selection in a direction
89#[derive(Copy, Debug, Clone)]
90#[repr(C)]
91pub struct TextOpExtendSelection {
92    pub old_range: SelectionRange,
93    pub new_range: SelectionRange,
94    pub direction: SelectionDirection,
95}
96
97/// Clear all selections
98#[derive(Copy, Debug, Clone)]
99#[repr(C)]
100pub struct TextOpClearSelection {
101    pub old_range: SelectionRange,
102}
103
104/// Move cursor to new position
105#[derive(Copy, Debug, Clone)]
106#[repr(C)]
107pub struct TextOpMoveCursor {
108    pub old_position: CursorPosition,
109    pub new_position: CursorPosition,
110    pub movement: CursorMovement,
111}
112
113/// Copy selection to clipboard (no text change)
114#[derive(Debug, Clone)]
115#[repr(C)]
116pub struct TextOpCopy {
117    pub range: SelectionRange,
118    pub content: ClipboardContent,
119}
120
121/// Cut selection to clipboard (deletes text)
122#[derive(Debug, Clone)]
123#[repr(C)]
124pub struct TextOpCut {
125    pub range: SelectionRange,
126    pub content: ClipboardContent,
127    pub new_cursor: CursorPosition,
128}
129
130/// Paste from clipboard (inserts text)
131#[derive(Debug, Clone)]
132#[repr(C)]
133pub struct TextOpPaste {
134    pub content: ClipboardContent,
135    pub position: CursorPosition,
136    pub new_cursor: CursorPosition,
137}
138
139/// Select all text in node
140#[derive(Copy, Debug, Clone)]
141#[repr(C)]
142pub struct TextOpSelectAll {
143    pub old_range: OptionSelectionRange,
144    pub new_range: SelectionRange,
145}
146
147/// Text editing operation (what will change)
148#[derive(Debug, Clone)]
149#[repr(C, u8)]
150pub enum TextOperation {
151    /// Insert text at cursor position
152    InsertText(TextOpInsertText),
153    /// Delete text in range
154    DeleteText(TextOpDeleteText),
155    /// Replace text in range with new text
156    ReplaceText(TextOpReplaceText),
157    /// Set selection to new range
158    SetSelection(TextOpSetSelection),
159    /// Extend selection in a direction
160    ExtendSelection(TextOpExtendSelection),
161    /// Clear all selections
162    ClearSelection(TextOpClearSelection),
163    /// Move cursor to new position
164    MoveCursor(TextOpMoveCursor),
165    /// Copy selection to clipboard (no text change)
166    Copy(TextOpCopy),
167    /// Cut selection to clipboard (deletes text)
168    Cut(TextOpCut),
169    /// Paste from clipboard (inserts text)
170    Paste(TextOpPaste),
171    /// Select all text in node
172    SelectAll(TextOpSelectAll),
173}
174
175/// Re-export from events module
176pub use azul_core::events::SelectionDirection;
177
178/// Type of cursor movement
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180#[repr(C)]
181pub enum CursorMovement {
182    /// Move left one character
183    Left,
184    /// Move right one character
185    Right,
186    /// Move up one line
187    Up,
188    /// Move down one line
189    Down,
190    /// Jump to previous word boundary
191    WordLeft,
192    /// Jump to next word boundary
193    WordRight,
194    /// Jump to start of line
195    LineStart,
196    /// Jump to end of line
197    LineEnd,
198    /// Jump to start of document
199    DocumentStart,
200    /// Jump to end of document
201    DocumentEnd,
202    /// Absolute position (not relative)
203    Absolute,
204}
205
206impl TextChangeset {
207    /// Create a new changeset with unique ID
208    pub fn new(target: DomNodeId, operation: TextOperation, timestamp: Instant) -> Self {
209        use std::sync::atomic::{AtomicUsize, Ordering};
210        static CHANGESET_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
211
212        Self {
213            id: CHANGESET_ID_COUNTER.fetch_add(1, Ordering::Relaxed),
214            target,
215            operation,
216            timestamp,
217        }
218    }
219
220    /// Check if this changeset actually mutates text (vs just selection/cursor)
221    #[must_use] pub const fn mutates_text(&self) -> bool {
222        matches!(
223            self.operation,
224            TextOperation::InsertText { .. }
225                | TextOperation::DeleteText { .. }
226                | TextOperation::ReplaceText { .. }
227                | TextOperation::Cut { .. }
228                | TextOperation::Paste { .. }
229        )
230    }
231
232    /// Check if this changeset changes selection (including cursor moves)
233    #[must_use] pub const fn changes_selection(&self) -> bool {
234        matches!(
235            self.operation,
236            TextOperation::SetSelection { .. }
237                | TextOperation::ExtendSelection { .. }
238                | TextOperation::ClearSelection { .. }
239                | TextOperation::MoveCursor { .. }
240                | TextOperation::SelectAll { .. }
241        )
242    }
243
244    /// Check if this changeset involves clipboard
245    #[must_use] pub const fn uses_clipboard(&self) -> bool {
246        matches!(
247            self.operation,
248            TextOperation::Copy { .. } | TextOperation::Cut { .. } | TextOperation::Paste { .. }
249        )
250    }
251
252    /// Get the target cursor position after this changeset is applied
253    #[must_use] pub const fn resulting_cursor_position(&self) -> Option<CursorPosition> {
254        match &self.operation {
255            TextOperation::InsertText(op) => Some(op.new_cursor),
256            TextOperation::DeleteText(op) => Some(op.new_cursor),
257            TextOperation::ReplaceText(op) => Some(op.new_cursor),
258            TextOperation::Cut(op) => Some(op.new_cursor),
259            TextOperation::Paste(op) => Some(op.new_cursor),
260            TextOperation::MoveCursor(op) => Some(op.new_position),
261            _ => None,
262        }
263    }
264
265    /// Get the target selection range after this changeset is applied
266    #[must_use] pub const fn resulting_selection_range(&self) -> Option<SelectionRange> {
267        match &self.operation {
268            TextOperation::SetSelection(op) => Some(op.new_range),
269            TextOperation::ExtendSelection(op) => Some(op.new_range),
270            TextOperation::SelectAll(op) => Some(op.new_range),
271            _ => None,
272        }
273    }
274}
275
276#[cfg(test)]
277mod autotest_generated {
278    use std::{collections::HashSet, thread};
279
280    use azul_core::{
281        dom::DomId,
282        geom::LogicalPosition,
283        selection::{CursorAffinity, GraphemeClusterId, TextCursor},
284        styled_dom::NodeHierarchyItemId,
285        task::SystemTick,
286    };
287
288    use super::*;
289    use crate::managers::selection::StyledTextRun;
290
291    // =========================================================================
292    // Fixtures
293    //
294    // `TextChangeset` is a plain data carrier: the constructor stamps a unique
295    // id and the five getters are pure classifiers over `TextOperation`. The
296    // adversarial surface is therefore (a) the atomic id counter under
297    // contention, (b) whether the getters partition the 11 operation variants
298    // exactly as documented, and (c) whether extreme payloads (NaN / infinite
299    // cursors, u32::MAX cluster ids, huge and non-ASCII strings) survive a
300    // round trip through the getters bit-for-bit instead of being normalized.
301    // =========================================================================
302
303    fn node(dom: usize, raw: usize) -> DomNodeId {
304        DomNodeId {
305            dom: DomId { inner: dom },
306            node: NodeHierarchyItemId::from_raw(raw),
307        }
308    }
309
310    fn ts(tick: u64) -> Instant {
311        Instant::Tick(SystemTick::new(tick))
312    }
313
314    fn cur(x: f32, y: f32) -> CursorPosition {
315        CursorPosition::InWindow(LogicalPosition::new(x, y))
316    }
317
318    fn tc(run: u32, byte: u32, affinity: CursorAffinity) -> TextCursor {
319        TextCursor {
320            cluster_id: GraphemeClusterId {
321                source_run: run,
322                start_byte_in_run: byte,
323            },
324            affinity,
325        }
326    }
327
328    fn range(start: TextCursor, end: TextCursor) -> SelectionRange {
329        SelectionRange { start, end }
330    }
331
332    /// A plain zero-to-one-character forward range.
333    fn simple_range() -> SelectionRange {
334        range(
335            tc(0, 0, CursorAffinity::Leading),
336            tc(0, 1, CursorAffinity::Trailing),
337        )
338    }
339
340    /// A range at the numeric ceiling, selected *backwards* (end before start).
341    fn extreme_range() -> SelectionRange {
342        range(
343            tc(u32::MAX, u32::MAX, CursorAffinity::Trailing),
344            tc(0, 0, CursorAffinity::Leading),
345        )
346    }
347
348    fn clip(text: &str) -> ClipboardContent {
349        ClipboardContent {
350            plain_text: AzString::from(text),
351            styled_runs: Vec::<StyledTextRun>::new().into(),
352        }
353    }
354
355    /// One changeset per `TextOperation` variant, labelled by variant name.
356    ///
357    /// Deliberately built from extreme payloads so every truth-table test
358    /// doubles as a no-panic test on hostile input.
359    fn all_ops() -> Vec<(&'static str, TextOperation)> {
360        vec![
361            (
362                "InsertText",
363                TextOperation::InsertText(TextOpInsertText {
364                    text: AzString::from("a\u{0301}\u{1F600}\u{202E}\0"),
365                    position: cur(f32::NAN, f32::NEG_INFINITY),
366                    new_cursor: cur(f32::MAX, f32::MIN),
367                }),
368            ),
369            (
370                "DeleteText",
371                TextOperation::DeleteText(TextOpDeleteText {
372                    range: extreme_range(),
373                    deleted_text: AzString::from(""),
374                    new_cursor: CursorPosition::Uninitialized,
375                }),
376            ),
377            (
378                "ReplaceText",
379                TextOperation::ReplaceText(TextOpReplaceText {
380                    range: simple_range(),
381                    old_text: AzString::from("\u{FFFD}"),
382                    new_text: AzString::from("\u{10FFFF}"),
383                    new_cursor: CursorPosition::OutOfWindow(LogicalPosition::new(-0.0, 0.0)),
384                }),
385            ),
386            (
387                "SetSelection",
388                TextOperation::SetSelection(TextOpSetSelection {
389                    old_range: OptionSelectionRange::None,
390                    new_range: extreme_range(),
391                }),
392            ),
393            (
394                "ExtendSelection",
395                TextOperation::ExtendSelection(TextOpExtendSelection {
396                    old_range: simple_range(),
397                    new_range: extreme_range(),
398                    direction: SelectionDirection::Backward,
399                }),
400            ),
401            (
402                "ClearSelection",
403                TextOperation::ClearSelection(TextOpClearSelection {
404                    old_range: extreme_range(),
405                }),
406            ),
407            (
408                "MoveCursor",
409                TextOperation::MoveCursor(TextOpMoveCursor {
410                    old_position: CursorPosition::Uninitialized,
411                    new_position: cur(f32::INFINITY, f32::NAN),
412                    movement: CursorMovement::DocumentEnd,
413                }),
414            ),
415            (
416                "Copy",
417                TextOperation::Copy(TextOpCopy {
418                    range: extreme_range(),
419                    content: clip(""),
420                }),
421            ),
422            (
423                "Cut",
424                TextOperation::Cut(TextOpCut {
425                    range: extreme_range(),
426                    content: clip("\u{1F600}"),
427                    new_cursor: cur(0.0, 0.0),
428                }),
429            ),
430            (
431                "Paste",
432                TextOperation::Paste(TextOpPaste {
433                    content: clip("\r\n\t"),
434                    position: cur(-1.0e30, 1.0e30),
435                    new_cursor: cur(f32::EPSILON, -f32::EPSILON),
436                }),
437            ),
438            (
439                "SelectAll",
440                TextOperation::SelectAll(TextOpSelectAll {
441                    old_range: OptionSelectionRange::Some(simple_range()),
442                    new_range: extreme_range(),
443                }),
444            ),
445        ]
446    }
447
448    /// Variant name -> (mutates_text, changes_selection, uses_clipboard).
449    ///
450    /// Transcribed from the doc comments, not from the `matches!` arms, so a
451    /// silent reclassification of a variant fails here.
452    fn expected_predicates(name: &str) -> (bool, bool, bool) {
453        match name {
454            "InsertText" | "DeleteText" | "ReplaceText" => (true, false, false),
455            "SetSelection" | "ExtendSelection" | "ClearSelection" | "MoveCursor" | "SelectAll" => {
456                (false, true, false)
457            }
458            "Copy" => (false, false, true),
459            "Cut" | "Paste" => (true, false, true),
460            other => panic!("unclassified TextOperation variant: {other}"),
461        }
462    }
463
464    fn changeset_for(op: TextOperation) -> TextChangeset {
465        TextChangeset::new(node(0, 1), op, ts(0))
466    }
467
468    // =========================================================================
469    // 1. Constructor
470    // =========================================================================
471
472    #[test]
473    fn new_preserves_every_argument_verbatim() {
474        let target = node(usize::MAX, usize::MAX);
475        let timestamp = ts(u64::MAX);
476        let op = TextOperation::InsertText(TextOpInsertText {
477            text: AzString::from("hello"),
478            position: cur(1.0, 2.0),
479            new_cursor: cur(3.0, 4.0),
480        });
481
482        let cs = TextChangeset::new(target, op, timestamp.clone());
483
484        assert_eq!(cs.target, target, "target must round-trip unchanged");
485        assert_eq!(
486            cs.timestamp, timestamp,
487            "timestamp must round-trip unchanged"
488        );
489        match &cs.operation {
490            TextOperation::InsertText(op) => assert_eq!(op.text.as_str(), "hello"),
491            other => panic!("constructor swapped the operation variant: {other:?}"),
492        }
493    }
494
495    #[test]
496    fn new_does_not_panic_on_extreme_arguments() {
497        // usize::MAX DomId + 1-based-encoded usize::MAX node id: the constructor
498        // must not interpret, decode or index with either.
499        let huge_text = "\u{1F600}".repeat(64 * 1024); // 256 KiB of 4-byte chars
500        let cs = TextChangeset::new(
501            node(usize::MAX, usize::MAX),
502            TextOperation::ReplaceText(TextOpReplaceText {
503                range: extreme_range(),
504                old_text: AzString::from(huge_text.as_str()),
505                new_text: AzString::from(""),
506                new_cursor: cur(f32::NAN, f32::NAN),
507            }),
508            ts(u64::MAX),
509        );
510
511        assert_eq!(cs.target.dom.inner, usize::MAX);
512        assert_eq!(cs.target.node.into_raw(), usize::MAX);
513        assert!(cs.mutates_text());
514        assert!(cs.resulting_cursor_position().is_some());
515        match &cs.operation {
516            TextOperation::ReplaceText(op) => {
517                assert_eq!(op.old_text.as_str().len(), 256 * 1024);
518                assert!(op.new_text.as_str().is_empty());
519            }
520            other => panic!("unexpected variant: {other:?}"),
521        }
522    }
523
524    #[test]
525    fn new_assigns_strictly_increasing_unique_ids() {
526        let mut ids = Vec::new();
527        for i in 0..256_u64 {
528            let cs = TextChangeset::new(
529                node(0, 1),
530                TextOperation::ClearSelection(TextOpClearSelection {
531                    old_range: simple_range(),
532                }),
533                ts(i),
534            );
535            ids.push(cs.id);
536        }
537
538        // Other tests in this binary share the global counter, so only
539        // *monotonicity within this sequence* is guaranteed — not `id == i`.
540        for w in ids.windows(2) {
541            assert!(
542                w[1] > w[0],
543                "changeset ids must strictly increase: {} then {}",
544                w[0],
545                w[1]
546            );
547        }
548        let unique: HashSet<ChangesetId> = ids.iter().copied().collect();
549        assert_eq!(unique.len(), ids.len(), "changeset ids must be unique");
550    }
551
552    #[test]
553    fn new_ids_stay_unique_across_threads() {
554        // The id comes from a `fetch_add(Relaxed)` on a process-global counter.
555        // Relaxed is fine for uniqueness (RMW ops are atomic regardless of
556        // ordering) — this pins that down under contention.
557        const THREADS: usize = 8;
558        const PER_THREAD: usize = 250;
559
560        let handles: Vec<_> = (0..THREADS)
561            .map(|_| {
562                thread::spawn(|| {
563                    (0..PER_THREAD)
564                        .map(|_| {
565                            TextChangeset::new(
566                                node(0, 1),
567                                TextOperation::Copy(TextOpCopy {
568                                    range: simple_range(),
569                                    content: clip("x"),
570                                }),
571                                ts(0),
572                            )
573                            .id
574                        })
575                        .collect::<Vec<ChangesetId>>()
576                })
577            })
578            .collect();
579
580        let mut all = Vec::new();
581        for h in handles {
582            all.extend(h.join().expect("worker thread panicked"));
583        }
584
585        let unique: HashSet<ChangesetId> = all.iter().copied().collect();
586        assert_eq!(
587            unique.len(),
588            THREADS * PER_THREAD,
589            "concurrent TextChangeset::new handed out duplicate ids"
590        );
591    }
592
593    #[test]
594    fn clone_keeps_the_id_but_new_mints_a_fresh_one() {
595        let cs = changeset_for(TextOperation::ClearSelection(TextOpClearSelection {
596            old_range: simple_range(),
597        }));
598        let cloned = cs.clone();
599        assert_eq!(cloned.id, cs.id, "Clone must not re-mint the id");
600
601        let fresh = changeset_for(TextOperation::ClearSelection(TextOpClearSelection {
602            old_range: simple_range(),
603        }));
604        assert!(fresh.id > cs.id, "new() must mint a fresh id");
605    }
606
607    // =========================================================================
608    // 2. Predicate truth table + partition invariants
609    // =========================================================================
610
611    #[test]
612    fn predicates_match_the_documented_truth_table() {
613        for (name, op) in all_ops() {
614            let cs = changeset_for(op);
615            let got = (
616                cs.mutates_text(),
617                cs.changes_selection(),
618                cs.uses_clipboard(),
619            );
620            assert_eq!(
621                got,
622                expected_predicates(name),
623                "{name}: (mutates_text, changes_selection, uses_clipboard) mismatch"
624            );
625        }
626    }
627
628    #[test]
629    fn all_eleven_variants_are_covered_and_none_is_both_text_and_selection() {
630        let ops = all_ops();
631        assert_eq!(
632            ops.len(),
633            11,
634            "all_ops() must cover every TextOperation variant"
635        );
636
637        for (name, op) in ops {
638            let cs = changeset_for(op);
639
640            // Invariant: the two predicates are documented as alternatives
641            // ("mutates text (vs just selection/cursor)"), so no variant may
642            // claim both.
643            assert!(
644                !(cs.mutates_text() && cs.changes_selection()),
645                "{name} classifies as both a text mutation and a selection change"
646            );
647
648            // Invariant: every variant is reachable through at least one
649            // predicate — otherwise a caller dispatching on these three getters
650            // would silently drop the operation.
651            assert!(
652                cs.mutates_text() || cs.changes_selection() || cs.uses_clipboard(),
653                "{name} is invisible to all three predicates"
654            );
655        }
656    }
657
658    #[test]
659    fn predicates_are_pure_and_ignore_target_and_timestamp() {
660        for (name, op) in all_ops() {
661            let a = TextChangeset::new(node(0, 0), op.clone(), ts(0));
662            let b = TextChangeset::new(node(usize::MAX, usize::MAX), op, ts(u64::MAX));
663
664            assert_eq!(a.mutates_text(), b.mutates_text(), "{name}: mutates_text");
665            assert_eq!(
666                a.changes_selection(),
667                b.changes_selection(),
668                "{name}: changes_selection"
669            );
670            assert_eq!(
671                a.uses_clipboard(),
672                b.uses_clipboard(),
673                "{name}: uses_clipboard"
674            );
675
676            // Idempotent: repeated calls on the same instance agree.
677            assert_eq!(a.mutates_text(), a.mutates_text());
678            assert_eq!(a.changes_selection(), a.changes_selection());
679            assert_eq!(a.uses_clipboard(), a.uses_clipboard());
680        }
681    }
682
683    // =========================================================================
684    // 3. resulting_cursor_position
685    // =========================================================================
686
687    #[test]
688    fn resulting_cursor_position_is_some_exactly_for_cursor_moving_ops() {
689        for (name, op) in all_ops() {
690            let cs = changeset_for(op);
691            let expected = matches!(
692                name,
693                "InsertText" | "DeleteText" | "ReplaceText" | "Cut" | "Paste" | "MoveCursor"
694            );
695            assert_eq!(
696                cs.resulting_cursor_position().is_some(),
697                expected,
698                "{name}: resulting_cursor_position() presence"
699            );
700
701            // Invariant: anything that rewrites the text must say where the
702            // cursor lands, otherwise the caller has nowhere to put it.
703            if cs.mutates_text() {
704                assert!(
705                    cs.resulting_cursor_position().is_some(),
706                    "{name} mutates text but reports no resulting cursor"
707                );
708            }
709        }
710    }
711
712    #[test]
713    fn resulting_cursor_position_returns_the_new_cursor_not_the_old_one() {
714        let cs = changeset_for(TextOperation::MoveCursor(TextOpMoveCursor {
715            old_position: cur(1.0, 1.0),
716            new_position: cur(9.0, 9.0),
717            movement: CursorMovement::Absolute,
718        }));
719        assert_eq!(cs.resulting_cursor_position(), Some(cur(9.0, 9.0)));
720
721        let cs = changeset_for(TextOperation::Paste(TextOpPaste {
722            content: clip("abc"),
723            position: cur(1.0, 1.0),
724            new_cursor: cur(4.0, 1.0),
725        }));
726        assert_eq!(cs.resulting_cursor_position(), Some(cur(4.0, 1.0)));
727    }
728
729    #[test]
730    fn resulting_cursor_position_preserves_nan_and_infinity_bit_for_bit() {
731        // `LogicalPosition`'s PartialEq quantizes (NaN -> i64::MIN, huge -> i64::MAX),
732        // so `==` would happily call NaN and f32::MAX "equal" to other values.
733        // Compare raw bits instead: the getter must hand back the exact payload
734        // it was given, without clamping, canonicalizing NaN, or flipping -0.0.
735        let payloads = [
736            (f32::NAN, f32::NEG_INFINITY),
737            (f32::INFINITY, -0.0),
738            (f32::MAX, f32::MIN),
739            (f32::MIN_POSITIVE, -f32::MIN_POSITIVE),
740        ];
741
742        for (x, y) in payloads {
743            let cs = changeset_for(TextOperation::InsertText(TextOpInsertText {
744                text: AzString::from("t"),
745                position: CursorPosition::Uninitialized,
746                new_cursor: cur(x, y),
747            }));
748
749            match cs.resulting_cursor_position() {
750                Some(CursorPosition::InWindow(p)) => {
751                    assert_eq!(p.x.to_bits(), x.to_bits(), "x mangled for ({x}, {y})");
752                    assert_eq!(p.y.to_bits(), y.to_bits(), "y mangled for ({x}, {y})");
753                }
754                other => panic!("expected InWindow cursor, got {other:?}"),
755            }
756        }
757    }
758
759    #[test]
760    fn resulting_cursor_position_preserves_the_cursor_variant() {
761        // Uninitialized / OutOfWindow must survive as themselves — a getter that
762        // "helpfully" normalized them to InWindow(0,0) would place the caret at
763        // the window origin.
764        for expected in [
765            CursorPosition::Uninitialized,
766            CursorPosition::OutOfWindow(LogicalPosition::new(-5.0, -5.0)),
767            CursorPosition::InWindow(LogicalPosition::new(0.0, 0.0)),
768        ] {
769            let cs = changeset_for(TextOperation::DeleteText(TextOpDeleteText {
770                range: simple_range(),
771                deleted_text: AzString::from("x"),
772                new_cursor: expected,
773            }));
774            assert_eq!(cs.resulting_cursor_position(), Some(expected));
775        }
776    }
777
778    // =========================================================================
779    // 4. resulting_selection_range
780    // =========================================================================
781
782    #[test]
783    fn resulting_selection_range_is_some_exactly_for_range_setting_ops() {
784        for (name, op) in all_ops() {
785            let cs = changeset_for(op);
786            let expected = matches!(name, "SetSelection" | "ExtendSelection" | "SelectAll");
787            assert_eq!(
788                cs.resulting_selection_range().is_some(),
789                expected,
790                "{name}: resulting_selection_range() presence"
791            );
792
793            // Invariant: a resulting range implies the changeset changes the
794            // selection. (The converse does NOT hold — ClearSelection and
795            // MoveCursor change the selection but produce no range; that
796            // asymmetry is asserted below.)
797            if cs.resulting_selection_range().is_some() {
798                assert!(
799                    cs.changes_selection(),
800                    "{name} yields a selection range but denies changing the selection"
801                );
802            }
803        }
804    }
805
806    #[test]
807    fn clear_and_move_change_selection_but_yield_no_range() {
808        let cleared = changeset_for(TextOperation::ClearSelection(TextOpClearSelection {
809            old_range: simple_range(),
810        }));
811        assert!(cleared.changes_selection());
812        assert_eq!(cleared.resulting_selection_range(), None);
813        assert_eq!(cleared.resulting_cursor_position(), None);
814
815        let moved = changeset_for(TextOperation::MoveCursor(TextOpMoveCursor {
816            old_position: cur(0.0, 0.0),
817            new_position: cur(1.0, 0.0),
818            movement: CursorMovement::WordRight,
819        }));
820        assert!(moved.changes_selection());
821        assert_eq!(moved.resulting_selection_range(), None);
822        assert_eq!(moved.resulting_cursor_position(), Some(cur(1.0, 0.0)));
823    }
824
825    #[test]
826    fn resulting_selection_range_does_not_normalize_a_backwards_range() {
827        // A backwards (end < start) selection is legal — "the direction is
828        // implicit". The getter must not silently swap the endpoints.
829        let backwards = extreme_range();
830        assert!(backwards.end < backwards.start);
831
832        let cs = changeset_for(TextOperation::SetSelection(TextOpSetSelection {
833            old_range: OptionSelectionRange::None,
834            new_range: backwards,
835        }));
836
837        let got = cs
838            .resulting_selection_range()
839            .expect("SetSelection must yield a range");
840        assert_eq!(got, backwards, "endpoints were reordered or clamped");
841        assert_eq!(got.start.cluster_id.source_run, u32::MAX);
842        assert_eq!(got.start.cluster_id.start_byte_in_run, u32::MAX);
843        assert_eq!(got.start.affinity, CursorAffinity::Trailing);
844        assert_eq!(got.end, tc(0, 0, CursorAffinity::Leading));
845    }
846
847    #[test]
848    fn resulting_selection_range_returns_new_range_and_preserves_empty_ranges() {
849        // Collapsed range (start == end) is a caret, not "no selection" — it
850        // must come back as Some, not None.
851        let caret = range(
852            tc(7, 3, CursorAffinity::Leading),
853            tc(7, 3, CursorAffinity::Leading),
854        );
855        let cs = changeset_for(TextOperation::ExtendSelection(TextOpExtendSelection {
856            old_range: extreme_range(),
857            new_range: caret,
858            direction: SelectionDirection::Forward,
859        }));
860        assert_eq!(cs.resulting_selection_range(), Some(caret));
861
862        // SelectAll must return `new_range`, never `old_range`.
863        let cs = changeset_for(TextOperation::SelectAll(TextOpSelectAll {
864            old_range: OptionSelectionRange::Some(simple_range()),
865            new_range: caret,
866        }));
867        assert_eq!(cs.resulting_selection_range(), Some(caret));
868    }
869
870    // =========================================================================
871    // 5. Payload round-trips (unicode / huge / empty)
872    // =========================================================================
873
874    #[test]
875    fn text_payloads_round_trip_through_the_changeset_unchanged() {
876        let cases = [
877            "",                              // empty
878            "\0",                            // interior NUL
879            "a\u{0301}",                     // combining acute
880            "\u{1F1E9}\u{1F1EA}",            // regional-indicator pair
881            "\u{202E}txet desrever\u{202C}", // bidi override
882            "\u{10FFFF}",                    // highest scalar value
883            "line1\r\nline2\u{2028}line3",   // CRLF + LINE SEPARATOR
884        ];
885
886        for s in cases {
887            let cs = changeset_for(TextOperation::ReplaceText(TextOpReplaceText {
888                range: simple_range(),
889                old_text: AzString::from(s),
890                new_text: AzString::from(s),
891                new_cursor: CursorPosition::Uninitialized,
892            }));
893
894            match &cs.operation {
895                TextOperation::ReplaceText(op) => {
896                    assert_eq!(op.old_text.as_str(), s, "old_text mangled for {s:?}");
897                    assert_eq!(op.new_text.as_str(), s, "new_text mangled for {s:?}");
898                    assert_eq!(op.old_text.as_str().len(), s.len(), "byte length changed");
899                }
900                other => panic!("unexpected variant: {other:?}"),
901            }
902        }
903    }
904
905    #[test]
906    fn clipboard_payloads_survive_and_stay_classified_as_clipboard_ops() {
907        let big = "\u{00E9}".repeat(128 * 1024); // 256 KiB of 2-byte chars
908
909        let cs = changeset_for(TextOperation::Cut(TextOpCut {
910            range: extreme_range(),
911            content: clip(&big),
912            new_cursor: cur(0.0, 0.0),
913        }));
914
915        assert!(cs.uses_clipboard());
916        assert!(
917            cs.mutates_text(),
918            "Cut deletes text, so it must count as a mutation"
919        );
920        assert!(!cs.changes_selection());
921        match &cs.operation {
922            TextOperation::Cut(op) => {
923                assert_eq!(op.content.plain_text.as_str().len(), 256 * 1024);
924                assert!(op.content.styled_runs.as_slice().is_empty());
925                // Empty styled_runs => empty <div> wrapper, no panic on a huge run.
926                assert_eq!(op.content.to_html(), "<div></div>");
927            }
928            other => panic!("unexpected variant: {other:?}"),
929        }
930
931        // An empty clipboard payload is still a clipboard op.
932        let empty = changeset_for(TextOperation::Copy(TextOpCopy {
933            range: simple_range(),
934            content: clip(""),
935        }));
936        assert!(empty.uses_clipboard());
937        assert!(!empty.mutates_text());
938        assert_eq!(empty.resulting_cursor_position(), None);
939        assert_eq!(empty.resulting_selection_range(), None);
940    }
941
942    #[test]
943    fn timestamps_round_trip_and_stay_ordered() {
944        let zero = changeset_for_ts(ts(0));
945        let max = changeset_for_ts(ts(u64::MAX));
946
947        assert_eq!(zero.timestamp, ts(0));
948        assert_eq!(max.timestamp, ts(u64::MAX));
949        assert!(
950            zero.timestamp < max.timestamp,
951            "tick ordering must survive being stored in a changeset"
952        );
953    }
954
955    fn changeset_for_ts(timestamp: Instant) -> TextChangeset {
956        TextChangeset::new(
957            node(0, 1),
958            TextOperation::ClearSelection(TextOpClearSelection {
959                old_range: simple_range(),
960            }),
961            timestamp,
962        )
963    }
964}