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

            
25
use azul_core::{
26
    dom::DomNodeId,
27
    selection::{OptionSelectionRange, SelectionRange},
28
    task::Instant,
29
    window::CursorPosition,
30
};
31
use azul_css::{impl_option, impl_option_inner, AzString};
32

            
33
use crate::managers::selection::ClipboardContent;
34

            
35
/// Unique identifier for a changeset (for undo/redo)
36
pub type ChangesetId = usize;
37

            
38
/// A text editing changeset that can be inspected before application
39
#[derive(Debug, Clone)]
40
#[repr(C)]
41
pub 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)]
55
pub 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)]
64
pub 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)]
73
pub 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)]
83
pub 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)]
91
pub 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)]
100
pub struct TextOpClearSelection {
101
    pub old_range: SelectionRange,
102
}
103

            
104
/// Move cursor to new position
105
#[derive(Copy, Debug, Clone)]
106
#[repr(C)]
107
pub 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)]
116
pub 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)]
124
pub 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)]
133
pub 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)]
142
pub 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)]
150
pub 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
176
pub use azul_core::events::SelectionDirection;
177

            
178
// ============================================================================
179
// Structural document changesets (`DocumentOperation`)
180
// ============================================================================
181
//
182
// Azul NEVER applies these to `StyledDom` — the DOM is immutable. Azul
183
// records intent (Enter → SplitBlock, Backspace-at-start → MergeBlocks, a
184
// bold-toolbar → WrapRange) and delivers it; the APP applies it to its own
185
// document model and regenerates, or uses the provided helper
186
// (`crate::document_edit::apply_document_operation`) on its XML tree. The
187
// existing remap machinery (`NodeIdRemap` + `calculate_contenteditable_key`)
188
// preserves caret/selection/undo across the resulting generation swap.
189

            
190
use azul_css::corety::U32Vec;
191

            
192
/// A position INSIDE a node, expressed structurally: before direct child
193
/// `child_index`, optionally at `text_byte` INSIDE that child when (and only
194
/// when) it is a text node.
195
///
196
/// This is the vocabulary's split/join coordinate — deliberately NOT a text
197
/// cursor: a `<ul>` splits between two `<li>`s (`text_byte: None`), a `<p>`
198
/// splits mid-word (`text_byte: Some(5)` inside its text child), a `<div>`
199
/// splits between arbitrary subtrees. Element children always move WHOLESALE
200
/// to one side; only a text child is ever cut, and only at a char boundary.
201
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202
#[repr(C)]
203
pub struct NodePosition {
204
    /// Index among the node's DIRECT children the position sits before /
205
    /// inside.
206
    pub child_index: u32,
207
    /// Byte offset inside `children[child_index]` when that child is a TEXT
208
    /// node and the position falls inside it. `None` = the position is
209
    /// BETWEEN children (a pure structural boundary).
210
    pub text_byte: azul_css::corety::OptionU32,
211
}
212

            
213
impl NodePosition {
214
    /// A pure structural boundary before child `child_index`.
215
    #[must_use]
216
247
    pub const fn before_child(child_index: u32) -> Self {
217
247
        Self {
218
247
            child_index,
219
247
            text_byte: azul_css::corety::OptionU32::None,
220
247
        }
221
247
    }
222

            
223
    /// A position inside the text child at `child_index`.
224
    #[must_use]
225
263
    pub const fn in_text_child(child_index: u32, text_byte: u32) -> Self {
226
263
        Self {
227
263
            child_index,
228
263
            text_byte: azul_css::corety::OptionU32::Some(text_byte),
229
263
        }
230
263
    }
231
}
232

            
233
/// Split a node at a structural position.
234
///
235
/// Enter in a contenteditable is ONE producer; splitting any container
236
/// between children is the same op.
237
#[derive(Debug, Clone, Copy)]
238
#[repr(C)]
239
pub struct DocOpSplitNode {
240
    /// The node being split.
241
    pub node: DomNodeId,
242
    /// Where: children before the position stay, children after move to the
243
    /// new sibling; a text child AT the position is cut at `text_byte`.
244
    pub at: NodePosition,
245
}
246

            
247
/// Merge two adjacent sibling nodes.
248
///
249
/// Backspace at start / Delete at end are ONE producer; joining any two
250
/// containers is the same op: `second`'s children are appended to `first`,
251
/// `second` is removed. Subtrees are preserved wholesale; only adjacent TEXT
252
/// children at the seam coalesce.
253
#[derive(Debug, Clone, Copy)]
254
#[repr(C)]
255
pub struct DocOpMergeNodes {
256
    /// The surviving first node.
257
    pub first: DomNodeId,
258
    /// The node whose children are appended to `first`.
259
    pub second: DomNodeId,
260
    /// The seam (where the caret lands): `first`'s old child count, with
261
    /// `text_byte` set when the seam coalesces two text nodes.
262
    pub join: NodePosition,
263
}
264

            
265
/// Wrap a contiguous CONTENT RANGE of a node in a new wrapper element.
266
///
267
/// The toolbar bold/italic/link op, expressed structurally: everything
268
/// between `start` and `end` moves INTO the wrapper (boundary TEXT children
269
/// are cut at the range edges; element children move wholesale). Wrapping a
270
/// word in `<strong>` and wrapping three paragraphs in a `<blockquote>` are
271
/// the SAME operation.
272
#[derive(Debug, Clone)]
273
#[repr(C)]
274
pub struct DocOpWrapRange {
275
    /// The node whose children the range covers.
276
    pub node: DomNodeId,
277
    /// Range start (inclusive; byte inside a text child cuts it).
278
    pub start: NodePosition,
279
    /// Range end (exclusive at a child boundary; a byte inside a text child
280
    /// includes that child's text up to the byte).
281
    pub end: NodePosition,
282
    /// The wrapper ELEMENT as a node payload: `wrapper.root` is the element
283
    /// (its `NodeData` carries tag, classes and attributes — an `<a href>`
284
    /// rides its dataset/attributes); `wrapper.children` is ignored.
285
    pub wrapper: azul_core::dom::Dom,
286
}
287

            
288
/// Remove a wrapper element, splicing its children into its place.
289
///
290
/// The wrapper is the direct child of `node` at `at`; adjacent text at both
291
/// seams coalesces, so wrap → unwrap round-trips. The inverse of wrap.
292
#[derive(Debug, Clone, Copy)]
293
#[repr(C)]
294
pub struct DocOpUnwrapRange {
295
    /// The node whose direct child is the wrapper.
296
    pub node: DomNodeId,
297
    /// Position of the wrapper child (`text_byte` is ignored).
298
    pub at: NodePosition,
299
}
300

            
301
/// Insert node SUBTREES under `parent` at child `index` — the immutable-DOM
302
/// analog of `.insertChild()`.
303
///
304
/// The content is a [`azul_core::dom::Dom`] (the native tree apps already
305
/// build), NOT a markup string: a paragraph, a list item, a whole table —
306
/// any subtree, the same op.
307
#[derive(Debug, Clone)]
308
#[repr(C)]
309
pub struct DocOpInsertChildren {
310
    /// Parent the new children are inserted under.
311
    pub parent: DomNodeId,
312
    /// Child index within `parent` (clamped by the applier).
313
    pub index: u32,
314
    /// The subtree(s) to insert. `content.root` is the FIRST inserted child;
315
    /// `content.children`-siblings pattern: a `Dom` is one subtree — multiple
316
    /// siblings are inserted by wrapping in a fragment container is NOT
317
    /// required: the applier inserts exactly this one subtree. (Insert
318
    /// several = several ops, or a `ReplaceChildren`.)
319
    pub content: azul_core::dom::Dom,
320
}
321

            
322
/// Remove a RANGE of direct children of `parent` (with their subtrees) —
323
/// the analog of `.removeChild()`, generalized to a contiguous range.
324
#[derive(Debug, Clone, Copy)]
325
#[repr(C)]
326
pub struct DocOpRemoveChildren {
327
    pub parent: DomNodeId,
328
    /// `[start, end)` among `parent`'s direct children.
329
    pub start: u32,
330
    pub end: u32,
331
}
332

            
333
/// Replace a range of direct children of `parent` with a subtree — the
334
/// analog of `.replaceChild()`. `Insert` and `Remove` are its two
335
/// degenerate forms; the three share one inverse algebra.
336
#[derive(Debug, Clone)]
337
#[repr(C)]
338
pub struct DocOpReplaceChildren {
339
    pub parent: DomNodeId,
340
    /// `[start, end)` among `parent`'s direct children to replace.
341
    pub start: u32,
342
    pub end: u32,
343
    /// The replacement subtree.
344
    pub content: azul_core::dom::Dom,
345
}
346

            
347
/// A structural document edit — the vocabulary `TextOperation` lacks
348
/// (everything here crosses or creates block boundaries).
349
///
350
/// The tree-mutation vocabulary a MUTABLE DOM would express as methods
351
/// (`insertChild` / `removeChild` / `replaceChild` / split / merge),
352
/// expressed here as RECORDED INTENT because the DOM is immutable: azul
353
/// delivers the operation, the app applies it to ITS model (or the
354
/// `document_edit` helper applies it to a `Dom`), and the overlay previews
355
/// it until the re-render lands.
356
///
357
/// Positions are STRUCTURAL ([`NodePosition`]: child index + byte only when
358
/// the boundary child is text). Content payloads are node SUBTREES
359
/// ([`azul_core::dom::Dom`]), never markup strings. The two `*Range` ops are
360
/// the deliberately text-specific pair (inline formatting).
361
#[derive(Debug, Clone)]
362
#[repr(C, u8)]
363
pub enum DocumentOperation {
364
    /// Split ANY node at a structural position.
365
    SplitNode(DocOpSplitNode),
366
    /// Merge two adjacent sibling nodes.
367
    MergeNodes(DocOpMergeNodes),
368
    /// Insert a subtree under a parent (`.insertChild`).
369
    InsertChildren(DocOpInsertChildren),
370
    /// Remove a range of direct children (`.removeChild`).
371
    RemoveChildren(DocOpRemoveChildren),
372
    /// Replace a range of direct children with a subtree (`.replaceChild`).
373
    ReplaceChildren(DocOpReplaceChildren),
374
    /// TEXT-SPECIFIC: wrap a text range in an inline element (bold/italic).
375
    WrapRange(DocOpWrapRange),
376
    /// TEXT-SPECIFIC: remove an inline wrapper from a text range.
377
    UnwrapRange(DocOpUnwrapRange),
378
}
379

            
380
/// Where the caret/selection anchor should land after the app re-renders,
381
/// expressed RE-RENDER-STABLY.
382
///
383
/// `NodeId`s in the changeset refer to the current generation and die at the
384
/// swap, so the resume point is defined against the POST-edit logical
385
/// structure instead. Nothing here presumes text: the anchor is any stable
386
/// node, the path is child indices, the position is structural.
387
#[derive(Debug, Clone)]
388
#[repr(C)]
389
pub struct EditResumePoint {
390
    /// Stable key of the ANCHOR node (via `calculate_contenteditable_key`,
391
    /// which works for any node: explicit key > css id > structural path).
392
    pub anchor_key: u64,
393
    /// Child-index path from the anchor to the target node, in the POST-edit
394
    /// tree (e.g. after a split: the path to the NEW second part).
395
    pub node_path: U32Vec,
396
    /// Where inside the target node (child boundary, or byte in a text
397
    /// child).
398
    pub position: NodePosition,
399
}
400

            
401
/// A recorded structural edit: intent + resume point + identity for the
402
/// commit handshake (`LayoutWindow::mark_document_edit_applied`).
403
#[derive(Debug, Clone)]
404
#[repr(C)]
405
pub struct DocumentChangeset {
406
    /// Monotonic id — the commit-handshake token.
407
    pub id: u64,
408
    /// Primary affected node (the event target), CURRENT generation.
409
    pub target: DomNodeId,
410
    pub operation: DocumentOperation,
411
    pub resume: EditResumePoint,
412
    pub timestamp: Instant,
413
}
414

            
415
impl DocumentChangeset {
416
    /// Create a changeset with a fresh monotonic id.
417
264
    pub fn new(
418
264
        target: DomNodeId,
419
264
        operation: DocumentOperation,
420
264
        resume: EditResumePoint,
421
264
        timestamp: Instant,
422
264
    ) -> Self {
423
        use std::sync::atomic::{AtomicU64, Ordering};
424
        static DOCUMENT_CHANGESET_ID: AtomicU64 = AtomicU64::new(1);
425
264
        Self {
426
264
            id: DOCUMENT_CHANGESET_ID.fetch_add(1, Ordering::Relaxed),
427
264
            target,
428
264
            operation,
429
264
            resume,
430
264
            timestamp,
431
264
        }
432
264
    }
433

            
434
    /// Whether this operation restructures the node tree (vs inline-only wrap).
435
    #[must_use]
436
    pub const fn changes_block_structure(&self) -> bool {
437
        matches!(
438
            self.operation,
439
            DocumentOperation::SplitNode(_)
440
                | DocumentOperation::MergeNodes(_)
441
                | DocumentOperation::InsertChildren(_)
442
                | DocumentOperation::RemoveChildren(_)
443
                | DocumentOperation::ReplaceChildren(_)
444
        )
445
    }
446
}
447

            
448
impl_option!(
449
    DocumentChangeset,
450
    OptionDocumentChangeset,
451
    copy = false,
452
    [Debug, Clone]
453
);
454

            
455
/// Type of cursor movement
456
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457
#[repr(C)]
458
pub enum CursorMovement {
459
    /// Move left one character
460
    Left,
461
    /// Move right one character
462
    Right,
463
    /// Move up one line
464
    Up,
465
    /// Move down one line
466
    Down,
467
    /// Jump to previous word boundary
468
    WordLeft,
469
    /// Jump to next word boundary
470
    WordRight,
471
    /// Jump to start of line
472
    LineStart,
473
    /// Jump to end of line
474
    LineEnd,
475
    /// Jump to start of document
476
    DocumentStart,
477
    /// Jump to end of document
478
    DocumentEnd,
479
    /// Absolute position (not relative)
480
    Absolute,
481
}
482

            
483
impl TextChangeset {
484
    /// Create a new changeset with unique ID
485
2351
    pub fn new(target: DomNodeId, operation: TextOperation, timestamp: Instant) -> Self {
486
        use std::sync::atomic::{AtomicUsize, Ordering};
487
        static CHANGESET_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
488

            
489
2351
        Self {
490
2351
            id: CHANGESET_ID_COUNTER.fetch_add(1, Ordering::Relaxed),
491
2351
            target,
492
2351
            operation,
493
2351
            timestamp,
494
2351
        }
495
2351
    }
496

            
497
    /// Check if this changeset actually mutates text (vs just selection/cursor)
498
91
    #[must_use] pub const fn mutates_text(&self) -> bool {
499
49
        matches!(
500
91
            self.operation,
501
            TextOperation::InsertText { .. }
502
                | TextOperation::DeleteText { .. }
503
                | TextOperation::ReplaceText { .. }
504
                | TextOperation::Cut { .. }
505
                | TextOperation::Paste { .. }
506
        )
507
91
    }
508

            
509
    /// Check if this changeset changes selection (including cursor moves)
510
72
    #[must_use] pub const fn changes_selection(&self) -> bool {
511
37
        matches!(
512
72
            self.operation,
513
            TextOperation::SetSelection { .. }
514
                | TextOperation::ExtendSelection { .. }
515
                | TextOperation::ClearSelection { .. }
516
                | TextOperation::MoveCursor { .. }
517
                | TextOperation::SelectAll { .. }
518
        )
519
72
    }
520

            
521
    /// Check if this changeset involves clipboard
522
58
    #[must_use] pub const fn uses_clipboard(&self) -> bool {
523
40
        matches!(
524
58
            self.operation,
525
            TextOperation::Copy { .. } | TextOperation::Cut { .. } | TextOperation::Paste { .. }
526
        )
527
58
    }
528

            
529
    /// Get the target cursor position after this changeset is applied
530
29
    #[must_use] pub const fn resulting_cursor_position(&self) -> Option<CursorPosition> {
531
29
        match &self.operation {
532
6
            TextOperation::InsertText(op) => Some(op.new_cursor),
533
5
            TextOperation::DeleteText(op) => Some(op.new_cursor),
534
3
            TextOperation::ReplaceText(op) => Some(op.new_cursor),
535
2
            TextOperation::Cut(op) => Some(op.new_cursor),
536
3
            TextOperation::Paste(op) => Some(op.new_cursor),
537
3
            TextOperation::MoveCursor(op) => Some(op.new_position),
538
7
            _ => None,
539
        }
540
29
    }
541

            
542
    /// Get the target selection range after this changeset is applied
543
28
    #[must_use] pub const fn resulting_selection_range(&self) -> Option<SelectionRange> {
544
28
        match &self.operation {
545
3
            TextOperation::SetSelection(op) => Some(op.new_range),
546
3
            TextOperation::ExtendSelection(op) => Some(op.new_range),
547
3
            TextOperation::SelectAll(op) => Some(op.new_range),
548
19
            _ => None,
549
        }
550
28
    }
551
}
552

            
553
#[cfg(test)]
554
mod autotest_generated {
555
    use std::{collections::HashSet, thread};
556

            
557
    use azul_core::{
558
        dom::DomId,
559
        geom::LogicalPosition,
560
        selection::{CursorAffinity, GraphemeClusterId, TextCursor},
561
        styled_dom::NodeHierarchyItemId,
562
        task::SystemTick,
563
    };
564

            
565
    use super::*;
566
    use crate::managers::selection::StyledTextRun;
567

            
568
    // =========================================================================
569
    // Fixtures
570
    //
571
    // `TextChangeset` is a plain data carrier: the constructor stamps a unique
572
    // id and the five getters are pure classifiers over `TextOperation`. The
573
    // adversarial surface is therefore (a) the atomic id counter under
574
    // contention, (b) whether the getters partition the 11 operation variants
575
    // exactly as documented, and (c) whether extreme payloads (NaN / infinite
576
    // cursors, u32::MAX cluster ids, huge and non-ASCII strings) survive a
577
    // round trip through the getters bit-for-bit instead of being normalized.
578
    // =========================================================================
579

            
580
    fn node(dom: usize, raw: usize) -> DomNodeId {
581
        DomNodeId {
582
            dom: DomId { inner: dom },
583
            node: NodeHierarchyItemId::from_raw(raw),
584
        }
585
    }
586

            
587
    fn ts(tick: u64) -> Instant {
588
        Instant::Tick(SystemTick::new(tick))
589
    }
590

            
591
    fn cur(x: f32, y: f32) -> CursorPosition {
592
        CursorPosition::InWindow(LogicalPosition::new(x, y))
593
    }
594

            
595
    fn tc(run: u32, byte: u32, affinity: CursorAffinity) -> TextCursor {
596
        TextCursor {
597
            cluster_id: GraphemeClusterId {
598
                source_run: run,
599
                start_byte_in_run: byte,
600
            },
601
            affinity,
602
        }
603
    }
604

            
605
    fn range(start: TextCursor, end: TextCursor) -> SelectionRange {
606
        SelectionRange { start, end }
607
    }
608

            
609
    /// A plain zero-to-one-character forward range.
610
    fn simple_range() -> SelectionRange {
611
        range(
612
            tc(0, 0, CursorAffinity::Leading),
613
            tc(0, 1, CursorAffinity::Trailing),
614
        )
615
    }
616

            
617
    /// A range at the numeric ceiling, selected *backwards* (end before start).
618
    fn extreme_range() -> SelectionRange {
619
        range(
620
            tc(u32::MAX, u32::MAX, CursorAffinity::Trailing),
621
            tc(0, 0, CursorAffinity::Leading),
622
        )
623
    }
624

            
625
    fn clip(text: &str) -> ClipboardContent {
626
        ClipboardContent {
627
            plain_text: AzString::from(text),
628
            styled_runs: Vec::<StyledTextRun>::new().into(),
629
        }
630
    }
631

            
632
    /// One changeset per `TextOperation` variant, labelled by variant name.
633
    ///
634
    /// Deliberately built from extreme payloads so every truth-table test
635
    /// doubles as a no-panic test on hostile input.
636
    fn all_ops() -> Vec<(&'static str, TextOperation)> {
637
        vec![
638
            (
639
                "InsertText",
640
                TextOperation::InsertText(TextOpInsertText {
641
                    text: AzString::from("a\u{0301}\u{1F600}\u{202E}\0"),
642
                    position: cur(f32::NAN, f32::NEG_INFINITY),
643
                    new_cursor: cur(f32::MAX, f32::MIN),
644
                }),
645
            ),
646
            (
647
                "DeleteText",
648
                TextOperation::DeleteText(TextOpDeleteText {
649
                    range: extreme_range(),
650
                    deleted_text: AzString::from(""),
651
                    new_cursor: CursorPosition::Uninitialized,
652
                }),
653
            ),
654
            (
655
                "ReplaceText",
656
                TextOperation::ReplaceText(TextOpReplaceText {
657
                    range: simple_range(),
658
                    old_text: AzString::from("\u{FFFD}"),
659
                    new_text: AzString::from("\u{10FFFF}"),
660
                    new_cursor: CursorPosition::OutOfWindow(LogicalPosition::new(-0.0, 0.0)),
661
                }),
662
            ),
663
            (
664
                "SetSelection",
665
                TextOperation::SetSelection(TextOpSetSelection {
666
                    old_range: OptionSelectionRange::None,
667
                    new_range: extreme_range(),
668
                }),
669
            ),
670
            (
671
                "ExtendSelection",
672
                TextOperation::ExtendSelection(TextOpExtendSelection {
673
                    old_range: simple_range(),
674
                    new_range: extreme_range(),
675
                    direction: SelectionDirection::Backward,
676
                }),
677
            ),
678
            (
679
                "ClearSelection",
680
                TextOperation::ClearSelection(TextOpClearSelection {
681
                    old_range: extreme_range(),
682
                }),
683
            ),
684
            (
685
                "MoveCursor",
686
                TextOperation::MoveCursor(TextOpMoveCursor {
687
                    old_position: CursorPosition::Uninitialized,
688
                    new_position: cur(f32::INFINITY, f32::NAN),
689
                    movement: CursorMovement::DocumentEnd,
690
                }),
691
            ),
692
            (
693
                "Copy",
694
                TextOperation::Copy(TextOpCopy {
695
                    range: extreme_range(),
696
                    content: clip(""),
697
                }),
698
            ),
699
            (
700
                "Cut",
701
                TextOperation::Cut(TextOpCut {
702
                    range: extreme_range(),
703
                    content: clip("\u{1F600}"),
704
                    new_cursor: cur(0.0, 0.0),
705
                }),
706
            ),
707
            (
708
                "Paste",
709
                TextOperation::Paste(TextOpPaste {
710
                    content: clip("\r\n\t"),
711
                    position: cur(-1.0e30, 1.0e30),
712
                    new_cursor: cur(f32::EPSILON, -f32::EPSILON),
713
                }),
714
            ),
715
            (
716
                "SelectAll",
717
                TextOperation::SelectAll(TextOpSelectAll {
718
                    old_range: OptionSelectionRange::Some(simple_range()),
719
                    new_range: extreme_range(),
720
                }),
721
            ),
722
        ]
723
    }
724

            
725
    /// Variant name -> (mutates_text, changes_selection, uses_clipboard).
726
    ///
727
    /// Transcribed from the doc comments, not from the `matches!` arms, so a
728
    /// silent reclassification of a variant fails here.
729
    fn expected_predicates(name: &str) -> (bool, bool, bool) {
730
        match name {
731
            "InsertText" | "DeleteText" | "ReplaceText" => (true, false, false),
732
            "SetSelection" | "ExtendSelection" | "ClearSelection" | "MoveCursor" | "SelectAll" => {
733
                (false, true, false)
734
            }
735
            "Copy" => (false, false, true),
736
            "Cut" | "Paste" => (true, false, true),
737
            other => panic!("unclassified TextOperation variant: {other}"),
738
        }
739
    }
740

            
741
    fn changeset_for(op: TextOperation) -> TextChangeset {
742
        TextChangeset::new(node(0, 1), op, ts(0))
743
    }
744

            
745
    // =========================================================================
746
    // 1. Constructor
747
    // =========================================================================
748

            
749
    #[test]
750
    fn new_preserves_every_argument_verbatim() {
751
        let target = node(usize::MAX, usize::MAX);
752
        let timestamp = ts(u64::MAX);
753
        let op = TextOperation::InsertText(TextOpInsertText {
754
            text: AzString::from("hello"),
755
            position: cur(1.0, 2.0),
756
            new_cursor: cur(3.0, 4.0),
757
        });
758

            
759
        let cs = TextChangeset::new(target, op, timestamp.clone());
760

            
761
        assert_eq!(cs.target, target, "target must round-trip unchanged");
762
        assert_eq!(
763
            cs.timestamp, timestamp,
764
            "timestamp must round-trip unchanged"
765
        );
766
        match &cs.operation {
767
            TextOperation::InsertText(op) => assert_eq!(op.text.as_str(), "hello"),
768
            other => panic!("constructor swapped the operation variant: {other:?}"),
769
        }
770
    }
771

            
772
    #[test]
773
    fn new_does_not_panic_on_extreme_arguments() {
774
        // usize::MAX DomId + 1-based-encoded usize::MAX node id: the constructor
775
        // must not interpret, decode or index with either.
776
        let huge_text = "\u{1F600}".repeat(64 * 1024); // 256 KiB of 4-byte chars
777
        let cs = TextChangeset::new(
778
            node(usize::MAX, usize::MAX),
779
            TextOperation::ReplaceText(TextOpReplaceText {
780
                range: extreme_range(),
781
                old_text: AzString::from(huge_text.as_str()),
782
                new_text: AzString::from(""),
783
                new_cursor: cur(f32::NAN, f32::NAN),
784
            }),
785
            ts(u64::MAX),
786
        );
787

            
788
        assert_eq!(cs.target.dom.inner, usize::MAX);
789
        assert_eq!(cs.target.node.into_raw(), usize::MAX);
790
        assert!(cs.mutates_text());
791
        assert!(cs.resulting_cursor_position().is_some());
792
        match &cs.operation {
793
            TextOperation::ReplaceText(op) => {
794
                assert_eq!(op.old_text.as_str().len(), 256 * 1024);
795
                assert!(op.new_text.as_str().is_empty());
796
            }
797
            other => panic!("unexpected variant: {other:?}"),
798
        }
799
    }
800

            
801
    #[test]
802
    fn new_assigns_strictly_increasing_unique_ids() {
803
        let mut ids = Vec::new();
804
        for i in 0..256_u64 {
805
            let cs = TextChangeset::new(
806
                node(0, 1),
807
                TextOperation::ClearSelection(TextOpClearSelection {
808
                    old_range: simple_range(),
809
                }),
810
                ts(i),
811
            );
812
            ids.push(cs.id);
813
        }
814

            
815
        // Other tests in this binary share the global counter, so only
816
        // *monotonicity within this sequence* is guaranteed — not `id == i`.
817
        for w in ids.windows(2) {
818
            assert!(
819
                w[1] > w[0],
820
                "changeset ids must strictly increase: {} then {}",
821
                w[0],
822
                w[1]
823
            );
824
        }
825
        let unique: HashSet<ChangesetId> = ids.iter().copied().collect();
826
        assert_eq!(unique.len(), ids.len(), "changeset ids must be unique");
827
    }
828

            
829
    #[test]
830
    fn new_ids_stay_unique_across_threads() {
831
        // The id comes from a `fetch_add(Relaxed)` on a process-global counter.
832
        // Relaxed is fine for uniqueness (RMW ops are atomic regardless of
833
        // ordering) — this pins that down under contention.
834
        const THREADS: usize = 8;
835
        const PER_THREAD: usize = 250;
836

            
837
        let handles: Vec<_> = (0..THREADS)
838
            .map(|_| {
839
                thread::spawn(|| {
840
                    (0..PER_THREAD)
841
                        .map(|_| {
842
                            TextChangeset::new(
843
                                node(0, 1),
844
                                TextOperation::Copy(TextOpCopy {
845
                                    range: simple_range(),
846
                                    content: clip("x"),
847
                                }),
848
                                ts(0),
849
                            )
850
                            .id
851
                        })
852
                        .collect::<Vec<ChangesetId>>()
853
                })
854
            })
855
            .collect();
856

            
857
        let mut all = Vec::new();
858
        for h in handles {
859
            all.extend(h.join().expect("worker thread panicked"));
860
        }
861

            
862
        let unique: HashSet<ChangesetId> = all.iter().copied().collect();
863
        assert_eq!(
864
            unique.len(),
865
            THREADS * PER_THREAD,
866
            "concurrent TextChangeset::new handed out duplicate ids"
867
        );
868
    }
869

            
870
    #[test]
871
    fn clone_keeps_the_id_but_new_mints_a_fresh_one() {
872
        let cs = changeset_for(TextOperation::ClearSelection(TextOpClearSelection {
873
            old_range: simple_range(),
874
        }));
875
        let cloned = cs.clone();
876
        assert_eq!(cloned.id, cs.id, "Clone must not re-mint the id");
877

            
878
        let fresh = changeset_for(TextOperation::ClearSelection(TextOpClearSelection {
879
            old_range: simple_range(),
880
        }));
881
        assert!(fresh.id > cs.id, "new() must mint a fresh id");
882
    }
883

            
884
    // =========================================================================
885
    // 2. Predicate truth table + partition invariants
886
    // =========================================================================
887

            
888
    #[test]
889
    fn predicates_match_the_documented_truth_table() {
890
        for (name, op) in all_ops() {
891
            let cs = changeset_for(op);
892
            let got = (
893
                cs.mutates_text(),
894
                cs.changes_selection(),
895
                cs.uses_clipboard(),
896
            );
897
            assert_eq!(
898
                got,
899
                expected_predicates(name),
900
                "{name}: (mutates_text, changes_selection, uses_clipboard) mismatch"
901
            );
902
        }
903
    }
904

            
905
    #[test]
906
    fn all_eleven_variants_are_covered_and_none_is_both_text_and_selection() {
907
        let ops = all_ops();
908
        assert_eq!(
909
            ops.len(),
910
            11,
911
            "all_ops() must cover every TextOperation variant"
912
        );
913

            
914
        for (name, op) in ops {
915
            let cs = changeset_for(op);
916

            
917
            // Invariant: the two predicates are documented as alternatives
918
            // ("mutates text (vs just selection/cursor)"), so no variant may
919
            // claim both.
920
            assert!(
921
                !(cs.mutates_text() && cs.changes_selection()),
922
                "{name} classifies as both a text mutation and a selection change"
923
            );
924

            
925
            // Invariant: every variant is reachable through at least one
926
            // predicate — otherwise a caller dispatching on these three getters
927
            // would silently drop the operation.
928
            assert!(
929
                cs.mutates_text() || cs.changes_selection() || cs.uses_clipboard(),
930
                "{name} is invisible to all three predicates"
931
            );
932
        }
933
    }
934

            
935
    #[test]
936
    fn predicates_are_pure_and_ignore_target_and_timestamp() {
937
        for (name, op) in all_ops() {
938
            let a = TextChangeset::new(node(0, 0), op.clone(), ts(0));
939
            let b = TextChangeset::new(node(usize::MAX, usize::MAX), op, ts(u64::MAX));
940

            
941
            assert_eq!(a.mutates_text(), b.mutates_text(), "{name}: mutates_text");
942
            assert_eq!(
943
                a.changes_selection(),
944
                b.changes_selection(),
945
                "{name}: changes_selection"
946
            );
947
            assert_eq!(
948
                a.uses_clipboard(),
949
                b.uses_clipboard(),
950
                "{name}: uses_clipboard"
951
            );
952

            
953
            // Idempotent: repeated calls on the same instance agree.
954
            assert_eq!(a.mutates_text(), a.mutates_text());
955
            assert_eq!(a.changes_selection(), a.changes_selection());
956
            assert_eq!(a.uses_clipboard(), a.uses_clipboard());
957
        }
958
    }
959

            
960
    // =========================================================================
961
    // 3. resulting_cursor_position
962
    // =========================================================================
963

            
964
    #[test]
965
    fn resulting_cursor_position_is_some_exactly_for_cursor_moving_ops() {
966
        for (name, op) in all_ops() {
967
            let cs = changeset_for(op);
968
            let expected = matches!(
969
                name,
970
                "InsertText" | "DeleteText" | "ReplaceText" | "Cut" | "Paste" | "MoveCursor"
971
            );
972
            assert_eq!(
973
                cs.resulting_cursor_position().is_some(),
974
                expected,
975
                "{name}: resulting_cursor_position() presence"
976
            );
977

            
978
            // Invariant: anything that rewrites the text must say where the
979
            // cursor lands, otherwise the caller has nowhere to put it.
980
            if cs.mutates_text() {
981
                assert!(
982
                    cs.resulting_cursor_position().is_some(),
983
                    "{name} mutates text but reports no resulting cursor"
984
                );
985
            }
986
        }
987
    }
988

            
989
    #[test]
990
    fn resulting_cursor_position_returns_the_new_cursor_not_the_old_one() {
991
        let cs = changeset_for(TextOperation::MoveCursor(TextOpMoveCursor {
992
            old_position: cur(1.0, 1.0),
993
            new_position: cur(9.0, 9.0),
994
            movement: CursorMovement::Absolute,
995
        }));
996
        assert_eq!(cs.resulting_cursor_position(), Some(cur(9.0, 9.0)));
997

            
998
        let cs = changeset_for(TextOperation::Paste(TextOpPaste {
999
            content: clip("abc"),
            position: cur(1.0, 1.0),
            new_cursor: cur(4.0, 1.0),
        }));
        assert_eq!(cs.resulting_cursor_position(), Some(cur(4.0, 1.0)));
    }
    #[test]
    fn resulting_cursor_position_preserves_nan_and_infinity_bit_for_bit() {
        // `LogicalPosition`'s PartialEq quantizes (NaN -> i64::MIN, huge -> i64::MAX),
        // so `==` would happily call NaN and f32::MAX "equal" to other values.
        // Compare raw bits instead: the getter must hand back the exact payload
        // it was given, without clamping, canonicalizing NaN, or flipping -0.0.
        let payloads = [
            (f32::NAN, f32::NEG_INFINITY),
            (f32::INFINITY, -0.0),
            (f32::MAX, f32::MIN),
            (f32::MIN_POSITIVE, -f32::MIN_POSITIVE),
        ];
        for (x, y) in payloads {
            let cs = changeset_for(TextOperation::InsertText(TextOpInsertText {
                text: AzString::from("t"),
                position: CursorPosition::Uninitialized,
                new_cursor: cur(x, y),
            }));
            match cs.resulting_cursor_position() {
                Some(CursorPosition::InWindow(p)) => {
                    assert_eq!(p.x.to_bits(), x.to_bits(), "x mangled for ({x}, {y})");
                    assert_eq!(p.y.to_bits(), y.to_bits(), "y mangled for ({x}, {y})");
                }
                other => panic!("expected InWindow cursor, got {other:?}"),
            }
        }
    }
    #[test]
    fn resulting_cursor_position_preserves_the_cursor_variant() {
        // Uninitialized / OutOfWindow must survive as themselves — a getter that
        // "helpfully" normalized them to InWindow(0,0) would place the caret at
        // the window origin.
        for expected in [
            CursorPosition::Uninitialized,
            CursorPosition::OutOfWindow(LogicalPosition::new(-5.0, -5.0)),
            CursorPosition::InWindow(LogicalPosition::new(0.0, 0.0)),
        ] {
            let cs = changeset_for(TextOperation::DeleteText(TextOpDeleteText {
                range: simple_range(),
                deleted_text: AzString::from("x"),
                new_cursor: expected,
            }));
            assert_eq!(cs.resulting_cursor_position(), Some(expected));
        }
    }
    // =========================================================================
    // 4. resulting_selection_range
    // =========================================================================
    #[test]
    fn resulting_selection_range_is_some_exactly_for_range_setting_ops() {
        for (name, op) in all_ops() {
            let cs = changeset_for(op);
            let expected = matches!(name, "SetSelection" | "ExtendSelection" | "SelectAll");
            assert_eq!(
                cs.resulting_selection_range().is_some(),
                expected,
                "{name}: resulting_selection_range() presence"
            );
            // Invariant: a resulting range implies the changeset changes the
            // selection. (The converse does NOT hold — ClearSelection and
            // MoveCursor change the selection but produce no range; that
            // asymmetry is asserted below.)
            if cs.resulting_selection_range().is_some() {
                assert!(
                    cs.changes_selection(),
                    "{name} yields a selection range but denies changing the selection"
                );
            }
        }
    }
    #[test]
    fn clear_and_move_change_selection_but_yield_no_range() {
        let cleared = changeset_for(TextOperation::ClearSelection(TextOpClearSelection {
            old_range: simple_range(),
        }));
        assert!(cleared.changes_selection());
        assert_eq!(cleared.resulting_selection_range(), None);
        assert_eq!(cleared.resulting_cursor_position(), None);
        let moved = changeset_for(TextOperation::MoveCursor(TextOpMoveCursor {
            old_position: cur(0.0, 0.0),
            new_position: cur(1.0, 0.0),
            movement: CursorMovement::WordRight,
        }));
        assert!(moved.changes_selection());
        assert_eq!(moved.resulting_selection_range(), None);
        assert_eq!(moved.resulting_cursor_position(), Some(cur(1.0, 0.0)));
    }
    #[test]
    fn resulting_selection_range_does_not_normalize_a_backwards_range() {
        // A backwards (end < start) selection is legal — "the direction is
        // implicit". The getter must not silently swap the endpoints.
        let backwards = extreme_range();
        assert!(backwards.end < backwards.start);
        let cs = changeset_for(TextOperation::SetSelection(TextOpSetSelection {
            old_range: OptionSelectionRange::None,
            new_range: backwards,
        }));
        let got = cs
            .resulting_selection_range()
            .expect("SetSelection must yield a range");
        assert_eq!(got, backwards, "endpoints were reordered or clamped");
        assert_eq!(got.start.cluster_id.source_run, u32::MAX);
        assert_eq!(got.start.cluster_id.start_byte_in_run, u32::MAX);
        assert_eq!(got.start.affinity, CursorAffinity::Trailing);
        assert_eq!(got.end, tc(0, 0, CursorAffinity::Leading));
    }
    #[test]
    fn resulting_selection_range_returns_new_range_and_preserves_empty_ranges() {
        // Collapsed range (start == end) is a caret, not "no selection" — it
        // must come back as Some, not None.
        let caret = range(
            tc(7, 3, CursorAffinity::Leading),
            tc(7, 3, CursorAffinity::Leading),
        );
        let cs = changeset_for(TextOperation::ExtendSelection(TextOpExtendSelection {
            old_range: extreme_range(),
            new_range: caret,
            direction: SelectionDirection::Forward,
        }));
        assert_eq!(cs.resulting_selection_range(), Some(caret));
        // SelectAll must return `new_range`, never `old_range`.
        let cs = changeset_for(TextOperation::SelectAll(TextOpSelectAll {
            old_range: OptionSelectionRange::Some(simple_range()),
            new_range: caret,
        }));
        assert_eq!(cs.resulting_selection_range(), Some(caret));
    }
    // =========================================================================
    // 5. Payload round-trips (unicode / huge / empty)
    // =========================================================================
    #[test]
    fn text_payloads_round_trip_through_the_changeset_unchanged() {
        let cases = [
            "",                              // empty
            "\0",                            // interior NUL
            "a\u{0301}",                     // combining acute
            "\u{1F1E9}\u{1F1EA}",            // regional-indicator pair
            "\u{202E}txet desrever\u{202C}", // bidi override
            "\u{10FFFF}",                    // highest scalar value
            "line1\r\nline2\u{2028}line3",   // CRLF + LINE SEPARATOR
        ];
        for s in cases {
            let cs = changeset_for(TextOperation::ReplaceText(TextOpReplaceText {
                range: simple_range(),
                old_text: AzString::from(s),
                new_text: AzString::from(s),
                new_cursor: CursorPosition::Uninitialized,
            }));
            match &cs.operation {
                TextOperation::ReplaceText(op) => {
                    assert_eq!(op.old_text.as_str(), s, "old_text mangled for {s:?}");
                    assert_eq!(op.new_text.as_str(), s, "new_text mangled for {s:?}");
                    assert_eq!(op.old_text.as_str().len(), s.len(), "byte length changed");
                }
                other => panic!("unexpected variant: {other:?}"),
            }
        }
    }
    #[test]
    fn clipboard_payloads_survive_and_stay_classified_as_clipboard_ops() {
        let big = "\u{00E9}".repeat(128 * 1024); // 256 KiB of 2-byte chars
        let cs = changeset_for(TextOperation::Cut(TextOpCut {
            range: extreme_range(),
            content: clip(&big),
            new_cursor: cur(0.0, 0.0),
        }));
        assert!(cs.uses_clipboard());
        assert!(
            cs.mutates_text(),
            "Cut deletes text, so it must count as a mutation"
        );
        assert!(!cs.changes_selection());
        match &cs.operation {
            TextOperation::Cut(op) => {
                assert_eq!(op.content.plain_text.as_str().len(), 256 * 1024);
                assert!(op.content.styled_runs.as_slice().is_empty());
                // Empty styled_runs => empty <div> wrapper, no panic on a huge run.
                assert_eq!(op.content.to_html(), "<div></div>");
            }
            other => panic!("unexpected variant: {other:?}"),
        }
        // An empty clipboard payload is still a clipboard op.
        let empty = changeset_for(TextOperation::Copy(TextOpCopy {
            range: simple_range(),
            content: clip(""),
        }));
        assert!(empty.uses_clipboard());
        assert!(!empty.mutates_text());
        assert_eq!(empty.resulting_cursor_position(), None);
        assert_eq!(empty.resulting_selection_range(), None);
    }
    #[test]
    fn timestamps_round_trip_and_stay_ordered() {
        let zero = changeset_for_ts(ts(0));
        let max = changeset_for_ts(ts(u64::MAX));
        assert_eq!(zero.timestamp, ts(0));
        assert_eq!(max.timestamp, ts(u64::MAX));
        assert!(
            zero.timestamp < max.timestamp,
            "tick ordering must survive being stored in a changeset"
        );
    }
    fn changeset_for_ts(timestamp: Instant) -> TextChangeset {
        TextChangeset::new(
            node(0, 1),
            TextOperation::ClearSelection(TextOpClearSelection {
                old_range: simple_range(),
            }),
            timestamp,
        )
    }
}