1
//! Undo/Redo Manager for text editing operations
2
//!
3
//! This module implements a per-node undo/redo stack that records text changesets
4
//! and the state before they were applied. This allows reverting changes with Ctrl+Z
5
//! and re-applying them with Ctrl+Y/Ctrl+Shift+Z.
6
//!
7
//! ## Architecture
8
//!
9
//! - **Per-Node Tracking**: Each text node has its own undo/redo stack
10
//! - **Changeset-Based**: Records `TextChangesets` from changeset.rs
11
//! - **State Snapshots**: Saves node state BEFORE changeset application (for revert)
12
//! - **Bounded History**: Keeps last 10 operations per node (configurable)
13
//! - **Callback Integration**: User can intercept via `preventDefault()`
14
//!
15
//! ## Usage Flow
16
//!
17
//! 1. User types text → `TextChangeset` created
18
//! 2. Pre-callback: Record current node state
19
//! 3. User callback: Can query/modify via `CallbackInfo`
20
//! 4. Apply changeset (if !preventDefault)
21
//! 5. Post-callback: Push changeset + pre-state to undo stack
22
//!
23
//! 6. User presses Ctrl+Z → Undo event detected
24
//! 7. Pre-callback: Pop undo stack, create revert changeset
25
//! 8. User callback: Can preventDefault or inspect
26
//! 9. Apply revert (if !preventDefault)
27
//! 10. Post-callback: Push original changeset to redo stack
28

            
29
use alloc::{collections::VecDeque, vec::Vec};
30

            
31
use azul_core::{
32
    dom::{DomId, NodeId},
33
    selection::{OptionSelectionRange, OptionTextCursor},
34
    task::Instant,
35
};
36
use azul_css::{impl_option, impl_option_inner, AzString};
37

            
38
use super::changeset::TextChangeset;
39

            
40
/// Maximum number of undo operations to keep per node
41
pub const MAX_UNDO_HISTORY: usize = 10;
42

            
43
/// Maximum number of redo operations to keep per node
44
pub const MAX_REDO_HISTORY: usize = 10;
45

            
46
/// Snapshot of a text node's state before a changeset was applied.
47
///
48
/// This contains enough information to fully revert a text operation.
49
#[derive(Debug, Clone)]
50
#[repr(C)]
51
pub struct NodeStateSnapshot {
52
    /// The node this snapshot belongs to
53
    pub node_id: NodeId,
54
    /// Full text content before changeset
55
    pub text_content: AzString,
56
    /// Cursor position before changeset (if applicable)
57
    /// For now, we store the logical position, not the `TextCursor`
58
    pub cursor_position: OptionTextCursor,
59
    /// Selection range before changeset (if applicable)
60
    pub selection_range: OptionSelectionRange,
61
    /// When this snapshot was taken
62
    pub timestamp: Instant,
63
}
64

            
65
/// A recorded operation that can be undone/redone.
66
///
67
/// Combines the changeset that was applied with the state before application.
68
#[derive(Debug, Clone)]
69
#[repr(C)]
70
pub struct UndoableOperation {
71
    /// The changeset that was applied
72
    pub changeset: TextChangeset,
73
    /// Node state BEFORE the changeset was applied
74
    pub pre_state: NodeStateSnapshot,
75
}
76

            
77
impl_option!(
78
    UndoableOperation,
79
    OptionUndoableOperation,
80
    copy = false,
81
    [Debug, Clone]
82
);
83

            
84
/// Per-node undo/redo stack
85
#[derive(Debug, Clone)]
86
pub struct NodeUndoRedoStack {
87
    /// Node ID this stack belongs to
88
    pub node_id: NodeId,
89
    /// Undo stack (most recent at back)
90
    pub undo_stack: VecDeque<UndoableOperation>,
91
    /// Redo stack (most recent at back)
92
    pub redo_stack: VecDeque<UndoableOperation>,
93
}
94

            
95
impl NodeUndoRedoStack {
96
218
    #[must_use] pub fn new(node_id: NodeId) -> Self {
97
218
        Self {
98
218
            node_id,
99
218
            undo_stack: VecDeque::with_capacity(MAX_UNDO_HISTORY),
100
218
            redo_stack: VecDeque::with_capacity(MAX_REDO_HISTORY),
101
218
        }
102
218
    }
103

            
104
    /// Push a new operation to the undo stack
105
1986
    pub fn push_undo(&mut self, operation: UndoableOperation) {
106
        // Clear redo stack when new operation is performed
107
1986
        self.redo_stack.clear();
108

            
109
        // Add to undo stack
110
1986
        self.undo_stack.push_back(operation);
111

            
112
        // Limit stack size
113
1986
        if self.undo_stack.len() > MAX_UNDO_HISTORY {
114
1491
            self.undo_stack.pop_front();
115
1503
        }
116
1986
    }
117

            
118
    /// MWA-C-undo_redo: move a REDONE operation back onto the undo stack
119
    /// WITHOUT clearing the redo stack — `push_undo` clears it (correct for
120
    /// fresh user edits), which made every redo destroy the remaining redo
121
    /// history.
122
44
    pub fn push_undo_preserving_redo(&mut self, operation: UndoableOperation) {
123
44
        self.undo_stack.push_back(operation);
124
44
        if self.undo_stack.len() > MAX_UNDO_HISTORY {
125
30
            self.undo_stack.pop_front();
126
30
        }
127
44
    }
128

            
129
    /// Pop the most recent operation from undo stack
130
117
    pub fn pop_undo(&mut self) -> Option<UndoableOperation> {
131
117
        self.undo_stack.pop_back()
132
117
    }
133

            
134
    /// Push an operation to the redo stack (after undo)
135
71
    pub fn push_redo(&mut self, operation: UndoableOperation) {
136
71
        self.redo_stack.push_back(operation);
137

            
138
        // Limit stack size
139
71
        if self.redo_stack.len() > MAX_REDO_HISTORY {
140
13
            self.redo_stack.pop_front();
141
58
        }
142
71
    }
143

            
144
    /// Pop the most recent operation from redo stack
145
112
    pub fn pop_redo(&mut self) -> Option<UndoableOperation> {
146
112
        self.redo_stack.pop_back()
147
112
    }
148

            
149
    /// Check if undo is available
150
127
    #[must_use] pub fn can_undo(&self) -> bool {
151
127
        !self.undo_stack.is_empty()
152
127
    }
153

            
154
    /// Check if redo is available
155
117
    #[must_use] pub fn can_redo(&self) -> bool {
156
117
        !self.redo_stack.is_empty()
157
117
    }
158

            
159
    /// Peek at the most recent undo operation without removing it
160
21
    #[must_use] pub fn peek_undo(&self) -> Option<&UndoableOperation> {
161
21
        self.undo_stack.back()
162
21
    }
163

            
164
    /// Peek at the most recent redo operation without removing it
165
17
    #[must_use] pub fn peek_redo(&self) -> Option<&UndoableOperation> {
166
17
        self.redo_stack.back()
167
17
    }
168
}
169

            
170
/// MWA-C-undo_redo: styled-content snapshots for an operation.
171
///
172
/// Kept OUT of the FFI-exposed `UndoableOperation` (which crosses the C API
173
/// via `inspect_undo_operation`) and keyed by `TextChangeset.id`. Undo restores
174
/// `pre`, redo restores `post` — previously both rebuilt the text with
175
/// `StyleProperties::default()`, discarding all styling, and redo re-entered
176
/// the recording pipeline (double-recording + clearing the redo stack).
177
#[derive(Debug, Clone)]
178
pub struct ContentSnapshot {
179
    /// Full styled inline content BEFORE the operation
180
    pub pre: Vec<crate::text3::cache::InlineContent>,
181
    /// Full styled inline content AFTER the operation
182
    pub post: Vec<crate::text3::cache::InlineContent>,
183
}
184

            
185
/// Bound on the styled-snapshot side table (undo+redo stacks hold at most
186
/// 10+10 per node; 64 gives headroom across a handful of editable nodes —
187
/// lookup misses fall back to the plain-text restore).
188
const MAX_CONTENT_SNAPSHOTS: usize = 64;
189

            
190
/// A recorded STRUCTURAL edit (Enter split / merge / …) — the tree-shaped
191
/// undo entry.
192
///
193
/// Undoing never mutates: it re-RECORDS `inverse` as a fresh
194
/// `DocumentChangeset` through the same record→app-apply loop the original
195
/// edit took (`LayoutWindow::undo_structural_edit`).
196
#[derive(Debug, Clone)]
197
pub struct StructuralUndoEntry {
198
    /// The operation the app applied.
199
    pub op: super::changeset::DocumentOperation,
200
    /// The operation that undoes it (as returned by
201
    /// `document_edit::apply_document_operation` or supplied by the app).
202
    pub inverse: super::changeset::DocumentOperation,
203
    /// The resume point of the APPLIED edit (caret after redo).
204
    pub resume_after: super::changeset::EditResumePoint,
205
}
206

            
207
/// Maximum retained structural undo/redo entries (whole-document scope, so a
208
/// flat cap — not per-node like text).
209
pub const MAX_STRUCTURAL_HISTORY: usize = 64;
210

            
211
/// Manager for undo/redo operations across all text nodes
212
#[derive(Debug, Clone, Default)]
213
pub struct UndoRedoManager {
214
    /// Per-node undo/redo stacks
215
    /// Using Vec instead of `HashMap` for `no_std` compatibility
216
    pub node_stacks: Vec<NodeUndoRedoStack>,
217
    /// Styled-content snapshots keyed by `TextChangeset.id` (see
218
    /// [`ContentSnapshot`]); FIFO-capped at [`MAX_CONTENT_SNAPSHOTS`].
219
    pub content_snapshots: Vec<(super::changeset::ChangesetId, ContentSnapshot)>,
220
    /// Structural undo stack (most recent at back). Document-scoped: block
221
    /// splits/merges cross node boundaries, so per-node stacks cannot hold
222
    /// them.
223
    pub structural_undo: VecDeque<StructuralUndoEntry>,
224
    /// Structural redo stack (most recent at back).
225
    pub structural_redo: VecDeque<StructuralUndoEntry>,
226
}
227

            
228
impl UndoRedoManager {
229
    /// Push an APPLIED structural edit. Clears the redo stack (a fresh user
230
    /// edit invalidates redo, same rule as text).
231
94
    pub fn push_structural(&mut self, entry: StructuralUndoEntry) {
232
94
        self.structural_redo.clear();
233
94
        self.structural_undo.push_back(entry);
234
94
        if self.structural_undo.len() > MAX_STRUCTURAL_HISTORY {
235
10
            self.structural_undo.pop_front();
236
84
        }
237
94
    }
238

            
239
    /// Pop the newest structural edit for undoing; the caller re-records its
240
    /// `inverse` and must push the SWAPPED entry onto [`Self::structural_redo`]
241
    /// (see `redo_structural`).
242
20
    pub fn pop_structural_undo(&mut self) -> Option<StructuralUndoEntry> {
243
20
        self.structural_undo.pop_back()
244
20
    }
245

            
246
    /// Move an undone entry onto the redo stack (op/inverse roles swap at
247
    /// USE time, not storage time — the entry stays as recorded).
248
20
    pub fn push_structural_redo(&mut self, entry: StructuralUndoEntry) {
249
20
        self.structural_redo.push_back(entry);
250
20
        if self.structural_redo.len() > MAX_STRUCTURAL_HISTORY {
251
            self.structural_redo.pop_front();
252
20
        }
253
20
    }
254

            
255
    /// Pop the newest redoable structural edit; the caller re-records its
256
    /// `op` and pushes the entry back onto the undo stack WITHOUT clearing
257
    /// redo (`push_structural` would — a redo is not a fresh edit).
258
19
    pub fn pop_structural_redo(&mut self) -> Option<StructuralUndoEntry> {
259
19
        self.structural_redo.pop_back()
260
19
    }
261

            
262
    /// Re-push after a redo (bypasses the redo-clearing of `push_structural`).
263
19
    pub fn push_structural_undo_after_redo(&mut self, entry: StructuralUndoEntry) {
264
19
        self.structural_undo.push_back(entry);
265
19
        if self.structural_undo.len() > MAX_STRUCTURAL_HISTORY {
266
            self.structural_undo.pop_front();
267
19
        }
268
19
    }
269

            
270
    /// Create a new empty undo/redo manager
271
5607
    #[must_use] pub const fn new() -> Self {
272
5607
        Self {
273
5607
            node_stacks: Vec::new(),
274
5607
            content_snapshots: Vec::new(),
275
5607
            structural_undo: VecDeque::new(),
276
5607
            structural_redo: VecDeque::new(),
277
5607
        }
278
5607
    }
279

            
280
    /// Store the styled pre/post content for a changeset (see [`ContentSnapshot`]).
281
2670
    pub fn store_content_snapshot(
282
2670
        &mut self,
283
2670
        id: super::changeset::ChangesetId,
284
2670
        pre: Vec<crate::text3::cache::InlineContent>,
285
2670
        post: Vec<crate::text3::cache::InlineContent>,
286
2670
    ) {
287
110201
        self.content_snapshots.retain(|(existing, _)| *existing != id);
288
2670
        self.content_snapshots.push((id, ContentSnapshot { pre, post }));
289
2670
        if self.content_snapshots.len() > MAX_CONTENT_SNAPSHOTS {
290
1082
            self.content_snapshots.remove(0);
291
1964
        }
292
2670
    }
293

            
294
    /// Look up the styled snapshot for a changeset id.
295
22
    #[must_use] pub fn get_content_snapshot(
296
22
        &self,
297
22
        id: super::changeset::ChangesetId,
298
22
    ) -> Option<&ContentSnapshot> {
299
22
        self.content_snapshots
300
22
            .iter()
301
525
            .find(|(existing, _)| *existing == id)
302
22
            .map(|(_, snap)| snap)
303
22
    }
304

            
305
    /// MWA-C-undo_redo: put a redone operation back on the undo stack
306
    /// WITHOUT clearing the redo stack (see
307
    /// [`NodeUndoRedoStack::push_undo_preserving_redo`]).
308
    /// # Panics
309
    /// Panics if the operation's changeset target node is None.
310
4
    pub fn reinstate_undo(&mut self, operation: UndoableOperation) {
311
4
        let node_id = operation
312
4
            .changeset
313
4
            .target
314
4
            .node
315
4
            .into_crate_internal()
316
4
            .expect("TextChangeset target node should not be None");
317
4
        let stack = self.get_or_create_stack_mut(node_id);
318
4
        stack.push_undo_preserving_redo(operation);
319
4
    }
320

            
321
    /// Get or create a stack for a specific node
322
    /// # Panics
323
    ///
324
    /// Panics if the per-node stack list is unexpectedly empty after insertion.
325
1958
    pub fn get_or_create_stack_mut(&mut self, node_id: NodeId) -> &mut NodeUndoRedoStack {
326
1958
        if let Some(pos) = self.node_stacks.iter().position(|s| s.node_id == node_id) {
327
1755
            &mut self.node_stacks[pos]
328
        } else {
329
203
            self.node_stacks.push(NodeUndoRedoStack::new(node_id));
330
203
            self.node_stacks.last_mut().unwrap()
331
        }
332
1958
    }
333

            
334
    /// Get a stack for a specific node (immutable)
335
67
    #[must_use] pub fn get_stack(&self, node_id: NodeId) -> Option<&NodeUndoRedoStack> {
336
67
        self.node_stacks.iter().find(|s| s.node_id == node_id)
337
67
    }
338

            
339
    /// Get a stack for a specific node (mutable)
340
15
    fn get_stack_mut(&mut self, node_id: NodeId) -> Option<&mut NodeUndoRedoStack> {
341
15
        self.node_stacks.iter_mut().find(|s| s.node_id == node_id)
342
15
    }
343

            
344
    /// Record a text operation (push to undo stack)
345
    ///
346
    /// This should be called AFTER a changeset has been successfully applied.
347
    /// The `pre_state` should contain the node state BEFORE the changeset was applied.
348
    ///
349
    /// ## Arguments
350
    /// * `changeset` - The changeset that was applied
351
    /// * `pre_state` - Node state before the changeset
352
    /// # Panics
353
    ///
354
    /// Panics if the changeset's target node is None.
355
1933
    pub fn record_operation(&mut self, changeset: TextChangeset, pre_state: NodeStateSnapshot) {
356
        // Convert DomNodeId to NodeId for indexing
357
        // NodeHierarchyItemId.into_crate_internal() decodes the 1-based encoding to Option<NodeId>
358
1933
        let node_id = changeset
359
1933
            .target
360
1933
            .node
361
1933
            .into_crate_internal()
362
1933
            .expect("TextChangeset target node should not be None");
363
1933
        let stack = self.get_or_create_stack_mut(node_id);
364

            
365
1933
        let operation = UndoableOperation {
366
1933
            changeset,
367
1933
            pre_state,
368
1933
        };
369

            
370
1933
        stack.push_undo(operation);
371
1933
    }
372

            
373
    /// Check if undo is available for a node
374
22
    #[must_use] pub fn can_undo(&self, node_id: NodeId) -> bool {
375
22
        self.get_stack(node_id)
376
22
            .is_some_and(NodeUndoRedoStack::can_undo)
377
22
    }
378

            
379
    /// Check if redo is available for a node
380
10
    #[must_use] pub fn can_redo(&self, node_id: NodeId) -> bool {
381
10
        self.get_stack(node_id)
382
10
            .is_some_and(NodeUndoRedoStack::can_redo)
383
10
    }
384

            
385
    /// Peek at the next undo operation for a node (without removing it)
386
    ///
387
    /// This allows user callbacks to inspect what would be undone.
388
11
    #[must_use] pub fn peek_undo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
389
11
        self.get_stack(node_id).and_then(|s| s.peek_undo())
390
11
    }
391

            
392
    /// Peek at the next redo operation for a node (without removing it)
393
    ///
394
    /// This allows user callbacks to inspect what would be redone.
395
7
    #[must_use] pub fn peek_redo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
396
7
        self.get_stack(node_id).and_then(|s| s.peek_redo())
397
7
    }
398

            
399
    /// Pop an operation from the undo stack
400
    ///
401
    /// This should be called during undo processing to get the operation to revert.
402
    /// After reverting, the operation should be pushed to the redo stack.
403
    ///
404
    /// ## Returns
405
    /// * `Some(operation)` - The operation to undo
406
    /// * `None` - No undo history available
407
9
    pub fn pop_undo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
408
9
        self.get_stack_mut(node_id)?.pop_undo()
409
9
    }
410

            
411
    /// Pop an operation from the redo stack
412
    ///
413
    /// This should be called during redo processing to get the operation to re-apply.
414
    /// After re-applying, the operation should be pushed to the undo stack.
415
    ///
416
    /// ## Returns
417
    /// * `Some(operation)` - The operation to redo
418
    /// * `None` - No redo history available
419
6
    pub fn pop_redo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
420
6
        self.get_stack_mut(node_id)?.pop_redo()
421
6
    }
422

            
423
    /// Push an operation to the redo stack (after successful undo)
424
    ///
425
    /// This should be called AFTER an undo operation has been successfully applied.
426
    /// # Panics
427
    ///
428
    /// Panics if the operation's changeset target node is None.
429
17
    pub fn push_redo(&mut self, operation: UndoableOperation) {
430
17
        let node_id = operation
431
17
            .changeset
432
17
            .target
433
17
            .node
434
17
            .into_crate_internal()
435
17
            .expect("TextChangeset target node should not be None");
436
17
        let stack = self.get_or_create_stack_mut(node_id);
437
17
        stack.push_redo(operation);
438
17
    }
439

            
440
    /// Push an operation to the undo stack (after successful redo)
441
    ///
442
    /// This should be called AFTER a redo operation has been successfully applied.
443
    /// # Panics
444
    ///
445
    /// Panics if the operation's changeset target node is None.
446
2
    pub fn push_undo(&mut self, operation: UndoableOperation) {
447
2
        let node_id = operation
448
2
            .changeset
449
2
            .target
450
2
            .node
451
2
            .into_crate_internal()
452
2
            .expect("TextChangeset target node should not be None");
453
2
        let stack = self.get_or_create_stack_mut(node_id);
454
2
        stack.push_undo(operation);
455
2
    }
456

            
457
}
458

            
459
impl crate::managers::NodeIdRemap for UndoRedoManager {
460
    /// Remap the per-node undo/redo stacks after a DOM rebuild.
461
    ///
462
    /// This is the worst offender of the "stale manager" family: the stacks are
463
    /// keyed by a bare `NodeId`, so deleting a PRECEDING SIBLING (which shifts
464
    /// every following index down by one) used to leave the whole undo history
465
    /// silently re-attached to a DIFFERENT, still-live element — undo would edit
466
    /// the wrong node, with no panic and no error.
467
    ///
468
    /// A stack is attributed to a DOM through the `DomNodeId` target of its
469
    /// operations (an empty stack carries no information and is treated as
470
    /// belonging to the DOM being reconciled). Stacks for unmounted nodes are
471
    /// dropped, and the content snapshots they referenced are GC'd with them.
472
35
    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
473
35
        let old_stacks = core::mem::take(&mut self.node_stacks);
474

            
475
51
        for mut stack in old_stacks {
476
            // Which DOM does this stack belong to? Derived from its operations.
477
16
            let stack_dom = stack
478
16
                .undo_stack
479
16
                .front()
480
16
                .or_else(|| stack.redo_stack.front())
481
16
                .map(|op| op.changeset.target.dom);
482

            
483
16
            if stack_dom.is_some_and(|d| d != dom) {
484
                // Belongs to a different DOM — this reconciliation says nothing about it.
485
1
                self.node_stacks.push(stack);
486
1
                continue;
487
15
            }
488

            
489
15
            let Some(new_node_id) = map.resolve(stack.node_id) else {
490
                // Node unmounted — drop the whole history (GC). Keeping it would
491
                // re-attach this history to whichever node inherits the index.
492
8
                continue;
493
            };
494

            
495
7
            stack.node_id = new_node_id;
496
7
            for op in stack.undo_stack.iter_mut().chain(stack.redo_stack.iter_mut()) {
497
7
                remap_operation(op, dom, map, new_node_id);
498
7
            }
499
7
            self.node_stacks.push(stack);
500
        }
501

            
502
        // GC content snapshots whose changesets no longer exist in any stack.
503
35
        let live: alloc::collections::BTreeSet<_> = self
504
35
            .node_stacks
505
35
            .iter()
506
35
            .flat_map(|s| s.undo_stack.iter().chain(s.redo_stack.iter()))
507
35
            .map(|op| op.changeset.id)
508
35
            .collect();
509
35
        self.content_snapshots.retain(|(id, _)| live.contains(id));
510
35
    }
511
}
512

            
513
/// Rewrite the `NodeIds` embedded inside a single undoable operation.
514
13
fn remap_operation(
515
13
    op: &mut UndoableOperation,
516
13
    dom: DomId,
517
13
    map: &crate::managers::NodeIdMap,
518
13
    new_node_id: NodeId,
519
13
) {
520
    use azul_core::styled_dom::NodeHierarchyItemId;
521
13
    if op.changeset.target.dom == dom {
522
12
        op.changeset.target.node = NodeHierarchyItemId::from_crate_internal(Some(new_node_id));
523
12
    }
524
13
    if let Some(remapped) = map.resolve(op.pre_state.node_id) {
525
12
        op.pre_state.node_id = remapped;
526
12
    } else {
527
1
        // The snapshot's node vanished but the stack's node survived: keep the
528
1
        // stack coherent by pointing the snapshot at the (surviving) stack node.
529
1
        op.pre_state.node_id = new_node_id;
530
1
    }
531
13
}
532

            
533

            
534
#[cfg(test)]
535
mod undo_redo_tests {
536
    use super::*;
537
    use crate::managers::changeset::{TextChangeset, TextOpInsertText, TextOperation};
538
    use azul_core::dom::{DomId, DomNodeId};
539
    use azul_core::styled_dom::NodeHierarchyItemId;
540
    use azul_core::task::SystemTick;
541
    use azul_core::window::CursorPosition;
542

            
543
18
    fn ts() -> Instant {
544
18
        Instant::Tick(SystemTick { tick_counter: 0 })
545
18
    }
546

            
547
9
    fn op(id: usize, node: usize) -> UndoableOperation {
548
9
        UndoableOperation {
549
9
            changeset: TextChangeset {
550
9
                id,
551
9
                target: DomNodeId {
552
9
                    dom: DomId { inner: 0 },
553
9
                    node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
554
9
                },
555
9
                operation: TextOperation::InsertText(TextOpInsertText {
556
9
                    text: "x".into(),
557
9
                    position: CursorPosition::Uninitialized,
558
9
                    new_cursor: CursorPosition::Uninitialized,
559
9
                }),
560
9
                timestamp: ts(),
561
9
            },
562
9
            pre_state: NodeStateSnapshot {
563
9
                node_id: NodeId::new(node),
564
9
                text_content: "".into(),
565
9
                cursor_position: None.into(),
566
9
                selection_range: None.into(),
567
9
                timestamp: ts(),
568
9
            },
569
9
        }
570
9
    }
571

            
572
    #[test]
573
1
    fn push_undo_clears_redo_but_reinstate_preserves_it() {
574
1
        let mut stack = NodeUndoRedoStack::new(NodeId::new(1));
575
1
        stack.push_redo(op(1, 1));
576
1
        stack.push_redo(op(2, 1));
577
1
        assert_eq!(stack.redo_stack.len(), 2);
578

            
579
        // Fresh user edit: redo history is invalidated.
580
1
        stack.push_undo(op(3, 1));
581
1
        assert_eq!(stack.redo_stack.len(), 0);
582

            
583
        // Redone operation moving back to undo: remaining redos survive.
584
1
        stack.push_redo(op(4, 1));
585
1
        stack.push_redo(op(5, 1));
586
1
        stack.push_undo_preserving_redo(op(6, 1));
587
1
        assert_eq!(stack.redo_stack.len(), 2);
588
1
        assert!(stack.can_undo());
589
1
    }
590

            
591
    #[test]
592
1
    fn content_snapshots_replace_lookup_and_evict() {
593
1
        let mut mgr = UndoRedoManager::new();
594
1
        mgr.store_content_snapshot(7, Vec::new(), Vec::new());
595
1
        assert!(mgr.get_content_snapshot(7).is_some());
596
1
        assert!(mgr.get_content_snapshot(8).is_none());
597

            
598
        // Same id replaces, no duplicate entries.
599
1
        mgr.store_content_snapshot(7, Vec::new(), Vec::new());
600
1
        assert_eq!(mgr.content_snapshots.len(), 1);
601

            
602
        // FIFO cap: oldest evicted once over MAX_CONTENT_SNAPSHOTS.
603
64
        for id in 100..(100 + MAX_CONTENT_SNAPSHOTS) {
604
64
            mgr.store_content_snapshot(id, Vec::new(), Vec::new());
605
64
        }
606
1
        assert!(mgr.content_snapshots.len() <= MAX_CONTENT_SNAPSHOTS);
607
1
        assert!(mgr.get_content_snapshot(7).is_none(), "oldest entry evicted");
608
1
        assert!(mgr
609
1
            .get_content_snapshot(100 + MAX_CONTENT_SNAPSHOTS - 1)
610
1
            .is_some());
611
1
    }
612

            
613
    #[test]
614
1
    fn reinstate_undo_keeps_manager_redo_stack() {
615
1
        let mut mgr = UndoRedoManager::new();
616
1
        mgr.record_operation(op(1, 3).changeset, op(1, 3).pre_state);
617
1
        let popped = mgr.pop_undo(NodeId::new(3)).unwrap();
618
1
        mgr.push_redo(popped);
619
1
        let redone = mgr.pop_redo(NodeId::new(3)).unwrap();
620
        // Second redo entry that must survive the reinstate:
621
1
        mgr.push_redo(op(2, 3));
622
1
        mgr.reinstate_undo(redone);
623
1
        assert!(mgr.can_undo(NodeId::new(3)));
624
1
        assert!(mgr.can_redo(NodeId::new(3)), "redo stack preserved");
625
1
    }
626
}
627

            
628
#[cfg(test)]
629
mod structural_history_tests {
630
    use super::*;
631
    use crate::managers::changeset::{
632
        DocOpRemoveChildren, DocOpSplitNode, DocumentOperation, EditResumePoint, NodePosition,
633
    };
634
    use azul_core::dom::{DomId, DomNodeId};
635
    use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
636
    use azul_core::styled_dom::NodeHierarchyItemId;
637

            
638
76
    fn entry(tag: u32) -> StructuralUndoEntry {
639
76
        let node = DomNodeId {
640
76
            dom: DomId { inner: 0 },
641
76
            node: NodeHierarchyItemId::from_crate_internal(None),
642
76
        };
643
76
        StructuralUndoEntry {
644
76
            op: DocumentOperation::SplitNode(DocOpSplitNode {
645
76
                node,
646
76
                at: NodePosition::in_text_child(0, tag),
647
76
            }),
648
76
            inverse: DocumentOperation::RemoveChildren(DocOpRemoveChildren {
649
76
                parent: node,
650
76
                start: 0,
651
76
                end: 1,
652
76
            }),
653
76
            resume_after: EditResumePoint {
654
76
                anchor_key: u64::from(tag),
655
76
                node_path: vec![1].into(),
656
76
                position: NodePosition::before_child(0),
657
76
            },
658
76
        }
659
76
    }
660

            
661
    #[test]
662
1
    fn fresh_edit_clears_redo_but_redo_repush_does_not() {
663
1
        let mut m = UndoRedoManager::new();
664
1
        m.push_structural(entry(1));
665
1
        let undone = m.pop_structural_undo().expect("undo");
666
1
        m.push_structural_redo(undone);
667
1
        assert_eq!(m.structural_redo.len(), 1);
668

            
669
        // Redo: pop redo, re-push onto undo WITHOUT clearing redo.
670
1
        let redone = m.pop_structural_redo().expect("redo");
671
1
        m.push_structural_undo_after_redo(redone);
672
1
        assert_eq!(m.structural_undo.len(), 1);
673

            
674
        // A FRESH edit invalidates redo (the text rule):
675
1
        if let Some(e) = m.pop_structural_undo() { m.push_structural_redo(e) }
676
1
        assert_eq!(m.structural_redo.len(), 1);
677
1
        m.push_structural(entry(2));
678
1
        assert!(m.structural_redo.is_empty(), "fresh edit clears redo");
679
1
    }
680

            
681
    #[test]
682
1
    fn structural_history_is_capped() {
683
1
        let mut m = UndoRedoManager::new();
684
74
        for i in 0..(MAX_STRUCTURAL_HISTORY as u32 + 10) {
685
74
            m.push_structural(entry(i));
686
74
        }
687
1
        assert_eq!(m.structural_undo.len(), MAX_STRUCTURAL_HISTORY);
688
        // The OLDEST entries were dropped (FIFO at the front).
689
1
        let front = m.structural_undo.front().expect("front");
690
1
        assert_eq!(front.resume_after.anchor_key, 10);
691
1
    }
692
}
693

            
694
#[cfg(test)]
695
mod autotest_generated {
696
    use azul_core::{
697
        dom::DomNodeId,
698
        geom::LogicalPosition,
699
        selection::{CursorAffinity, GraphemeClusterId, TextCursor},
700
        styled_dom::NodeHierarchyItemId,
701
        task::SystemTick,
702
        window::CursorPosition,
703
    };
704

            
705
    use super::*;
706
    use crate::{
707
        managers::{
708
            changeset::{TextOpInsertText, TextOperation},
709
            NodeIdMap, NodeIdRemap,
710
        },
711
        text3::cache::{InlineContent, InlineSpace},
712
    };
713

            
714
    // ---------------------------------------------------------------------
715
    // helpers
716
    // ---------------------------------------------------------------------
717

            
718
    fn tick(t: u64) -> Instant {
719
        Instant::Tick(SystemTick { tick_counter: t })
720
    }
721

            
722
    fn cursor(run: u32, byte: u32) -> TextCursor {
723
        TextCursor {
724
            cluster_id: GraphemeClusterId {
725
                source_run: run,
726
                start_byte_in_run: byte,
727
            },
728
            affinity: CursorAffinity::Leading,
729
        }
730
    }
731

            
732
    /// Full operation with explicitly-chosen dom / node / changeset id / text.
733
    fn op_full(id: usize, dom: usize, node: usize, text: &str) -> UndoableOperation {
734
        UndoableOperation {
735
            changeset: TextChangeset {
736
                id,
737
                target: DomNodeId {
738
                    dom: DomId { inner: dom },
739
                    node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
740
                },
741
                operation: TextOperation::InsertText(TextOpInsertText {
742
                    text: text.into(),
743
                    position: CursorPosition::Uninitialized,
744
                    new_cursor: CursorPosition::Uninitialized,
745
                }),
746
                timestamp: tick(u64::from(id as u32)),
747
            },
748
            pre_state: NodeStateSnapshot {
749
                node_id: NodeId::new(node),
750
                text_content: text.into(),
751
                cursor_position: Some(cursor(0, 0)).into(),
752
                selection_range: None.into(),
753
                timestamp: tick(u64::from(id as u32)),
754
            },
755
        }
756
    }
757

            
758
    /// Operation on DOM 0 (the common case).
759
    fn op(id: usize, node: usize) -> UndoableOperation {
760
        op_full(id, 0, node, "x")
761
    }
762

            
763
    /// Operation whose changeset target node is `None` — the input every
764
    /// `expect()` in this module is documented to panic on.
765
    fn op_with_none_target(id: usize) -> UndoableOperation {
766
        let mut o = op(id, 0);
767
        o.changeset.target.node = NodeHierarchyItemId::from_crate_internal(None);
768
        o
769
    }
770

            
771
    fn text_of(o: &UndoableOperation) -> &str {
772
        match &o.changeset.operation {
773
            TextOperation::InsertText(i) => i.text.as_str(),
774
            _ => panic!("helper only builds InsertText operations"),
775
        }
776
    }
777

            
778
    fn target_node(o: &UndoableOperation) -> Option<NodeId> {
779
        o.changeset.target.node.into_crate_internal()
780
    }
781

            
782
    fn space(width: f32) -> InlineContent {
783
        InlineContent::Space(InlineSpace {
784
            width,
785
            is_breaking: false,
786
            is_stretchy: false,
787
        })
788
    }
789

            
790
    fn space_width(c: &InlineContent) -> f32 {
791
        match c {
792
            InlineContent::Space(s) => s.width,
793
            _ => panic!("expected an InlineContent::Space"),
794
        }
795
    }
796

            
797
    fn one(c: InlineContent) -> Vec<InlineContent> {
798
        vec![c]
799
    }
800

            
801
    // ---------------------------------------------------------------------
802
    // NodeUndoRedoStack — constructor + invariants
803
    // ---------------------------------------------------------------------
804

            
805
    #[test]
806
    fn stack_new_holds_invariants_for_extreme_node_ids() {
807
        // NodeId::ZERO, a normal id, and the largest id that still survives the
808
        // 1-based FFI encoding (`usize::MAX` would overflow `into_raw`).
809
        for node in [0usize, 7, usize::MAX - 1] {
810
            let s = NodeUndoRedoStack::new(NodeId::new(node));
811
            assert_eq!(s.node_id.index(), node);
812
            assert!(s.undo_stack.is_empty());
813
            assert!(s.redo_stack.is_empty());
814
            assert!(!s.can_undo());
815
            assert!(!s.can_redo());
816
            assert!(s.peek_undo().is_none());
817
            assert!(s.peek_redo().is_none());
818
            assert!(s.undo_stack.capacity() >= MAX_UNDO_HISTORY);
819
            assert!(s.redo_stack.capacity() >= MAX_REDO_HISTORY);
820
        }
821
    }
822

            
823
    #[test]
824
    fn stack_draining_an_empty_stack_never_panics() {
825
        let mut s = NodeUndoRedoStack::new(NodeId::new(0));
826
        for _ in 0..100 {
827
            assert!(s.pop_undo().is_none());
828
            assert!(s.pop_redo().is_none());
829
            assert!(!s.can_undo());
830
            assert!(!s.can_redo());
831
        }
832
    }
833

            
834
    // ---------------------------------------------------------------------
835
    // NodeUndoRedoStack — bounded history (saturation, not growth)
836
    // ---------------------------------------------------------------------
837

            
838
    #[test]
839
    fn undo_stack_saturates_at_max_history_and_evicts_oldest() {
840
        let mut s = NodeUndoRedoStack::new(NodeId::new(1));
841
        let total = MAX_UNDO_HISTORY * 3 + 7;
842
        for id in 0..total {
843
            s.push_undo(op(id, 1));
844
        }
845
        assert_eq!(s.undo_stack.len(), MAX_UNDO_HISTORY, "history is bounded");
846
        // FIFO eviction: the surviving window is the LAST MAX_UNDO_HISTORY pushes.
847
        assert_eq!(s.undo_stack.front().unwrap().changeset.id, total - MAX_UNDO_HISTORY);
848
        assert_eq!(s.peek_undo().unwrap().changeset.id, total - 1);
849
    }
850

            
851
    #[test]
852
    fn redo_stack_saturates_at_max_history_and_evicts_oldest() {
853
        let mut s = NodeUndoRedoStack::new(NodeId::new(1));
854
        let total = MAX_REDO_HISTORY * 2 + 3;
855
        for id in 0..total {
856
            s.push_redo(op(id, 1));
857
        }
858
        assert_eq!(s.redo_stack.len(), MAX_REDO_HISTORY);
859
        assert_eq!(s.redo_stack.front().unwrap().changeset.id, total - MAX_REDO_HISTORY);
860
        assert_eq!(s.peek_redo().unwrap().changeset.id, total - 1);
861
    }
862

            
863
    #[test]
864
    fn push_undo_preserving_redo_is_bounded_too_and_leaves_redo_alone() {
865
        let mut s = NodeUndoRedoStack::new(NodeId::new(1));
866
        for id in 0..MAX_REDO_HISTORY {
867
            s.push_redo(op(id, 1));
868
        }
869
        for id in 1000..(1000 + MAX_UNDO_HISTORY * 4) {
870
            s.push_undo_preserving_redo(op(id, 1));
871
        }
872
        assert_eq!(s.undo_stack.len(), MAX_UNDO_HISTORY);
873
        assert_eq!(
874
            s.redo_stack.len(),
875
            MAX_REDO_HISTORY,
876
            "preserving variant must not touch the redo stack"
877
        );
878
        assert_eq!(s.peek_redo().unwrap().changeset.id, MAX_REDO_HISTORY - 1);
879
    }
880

            
881
    #[test]
882
    fn push_undo_clears_a_full_redo_stack() {
883
        let mut s = NodeUndoRedoStack::new(NodeId::new(1));
884
        for id in 0..MAX_REDO_HISTORY {
885
            s.push_redo(op(id, 1));
886
        }
887
        assert!(s.can_redo());
888
        s.push_undo(op(999, 1));
889
        assert!(!s.can_redo(), "a fresh edit invalidates the whole redo branch");
890
        assert!(s.redo_stack.is_empty());
891
        assert!(s.peek_redo().is_none());
892
        assert!(s.can_undo());
893
    }
894

            
895
    // ---------------------------------------------------------------------
896
    // NodeUndoRedoStack — LIFO ordering + peek is non-destructive
897
    // ---------------------------------------------------------------------
898

            
899
    #[test]
900
    fn pop_undo_and_pop_redo_are_lifo_then_drain_to_none() {
901
        let mut s = NodeUndoRedoStack::new(NodeId::new(2));
902
        for id in 0..5 {
903
            s.push_undo(op(id, 2));
904
        }
905
        for expected in (0..5).rev() {
906
            assert_eq!(s.pop_undo().unwrap().changeset.id, expected);
907
        }
908
        assert!(s.pop_undo().is_none());
909

            
910
        for id in 0..5 {
911
            s.push_redo(op(id, 2));
912
        }
913
        for expected in (0..5).rev() {
914
            assert_eq!(s.pop_redo().unwrap().changeset.id, expected);
915
        }
916
        assert!(s.pop_redo().is_none());
917
    }
918

            
919
    #[test]
920
    fn peek_is_non_destructive_and_agrees_with_pop() {
921
        let mut s = NodeUndoRedoStack::new(NodeId::new(2));
922
        s.push_undo(op(1, 2));
923
        s.push_undo(op(2, 2));
924
        s.push_redo(op(3, 2));
925

            
926
        for _ in 0..10 {
927
            assert_eq!(s.peek_undo().unwrap().changeset.id, 2);
928
            assert_eq!(s.peek_redo().unwrap().changeset.id, 3);
929
        }
930
        assert_eq!(s.undo_stack.len(), 2, "peek must not consume");
931
        assert_eq!(s.redo_stack.len(), 1);
932
        assert_eq!(s.pop_undo().unwrap().changeset.id, 2, "peek == next pop");
933
        assert_eq!(s.pop_redo().unwrap().changeset.id, 3);
934
    }
935

            
936
    #[test]
937
    fn can_undo_can_redo_track_emptiness_exactly() {
938
        let mut s = NodeUndoRedoStack::new(NodeId::new(2));
939
        assert!(!s.can_undo() && !s.can_redo());
940

            
941
        s.push_undo(op(1, 2));
942
        assert!(s.can_undo() && !s.can_redo());
943
        assert_eq!(s.can_undo(), !s.undo_stack.is_empty());
944

            
945
        s.push_redo(op(2, 2));
946
        assert!(s.can_undo() && s.can_redo());
947

            
948
        assert!(s.pop_undo().is_some());
949
        assert!(!s.can_undo() && s.can_redo());
950
        assert!(s.pop_redo().is_some());
951
        assert!(!s.can_undo() && !s.can_redo());
952
    }
953

            
954
    // ---------------------------------------------------------------------
955
    // Round-trip: what goes onto a stack comes back off byte-identical
956
    // ---------------------------------------------------------------------
957

            
958
    #[test]
959
    fn undo_redo_round_trip_preserves_unicode_payload_exactly() {
960
        // Combining mark, ZWJ emoji sequence, RTL override, astral plane, and a
961
        // NUL byte in the middle — none of which the stacks may normalize.
962
        let nasty = "a\u{0301}👩\u{200D}👩\u{200D}👧\u{202E}rtl\u{0000}end\u{FFFD}";
963
        let mut s = NodeUndoRedoStack::new(NodeId::new(4));
964
        s.push_undo(op_full(usize::MAX, 0, 4, nasty));
965

            
966
        let undone = s.pop_undo().unwrap();
967
        assert_eq!(text_of(&undone), nasty);
968
        assert_eq!(undone.pre_state.text_content.as_str(), nasty);
969
        assert_eq!(undone.changeset.id, usize::MAX, "ChangesetId::MAX survives");
970

            
971
        s.push_redo(undone);
972
        let redone = s.pop_redo().unwrap();
973
        assert_eq!(text_of(&redone), nasty, "encode == decode across undo→redo");
974
        assert_eq!(redone.pre_state.text_content.as_str(), nasty);
975
        assert_eq!(target_node(&redone), Some(NodeId::new(4)));
976
        assert_eq!(redone.pre_state.node_id, NodeId::new(4));
977
    }
978

            
979
    #[test]
980
    fn round_trip_preserves_huge_text_and_extreme_timestamps() {
981
        let huge: AzString = "é".repeat(50_000).into();
982
        assert_eq!(huge.as_str().len(), 100_000, "2 bytes per 'é'");
983

            
984
        let mut o = op(1, 6);
985
        o.pre_state.text_content = huge.clone();
986
        o.pre_state.timestamp = tick(u64::MAX);
987
        o.changeset.timestamp = tick(u64::MAX);
988

            
989
        let mut s = NodeUndoRedoStack::new(NodeId::new(6));
990
        s.push_undo(o);
991
        let back = s.pop_undo().unwrap();
992
        assert_eq!(back.pre_state.text_content.as_str().len(), 100_000);
993
        assert_eq!(back.pre_state.text_content.as_str(), huge.as_str());
994
        match back.pre_state.timestamp {
995
            Instant::Tick(t) => assert_eq!(t.tick_counter, u64::MAX, "u64::MAX tick unclamped"),
996
            Instant::System(_) => panic!("helper builds Tick instants"),
997
        }
998
    }
999

            
    #[test]
    fn round_trip_preserves_nan_and_infinite_cursor_coordinates() {
        let mut o = op(1, 6);
        o.changeset.operation = TextOperation::InsertText(TextOpInsertText {
            text: "q".into(),
            position: CursorPosition::InWindow(LogicalPosition {
                x: f32::NAN,
                y: f32::NEG_INFINITY,
            }),
            new_cursor: CursorPosition::OutOfWindow(LogicalPosition {
                x: f32::INFINITY,
                y: -0.0,
            }),
        });
        let mut s = NodeUndoRedoStack::new(NodeId::new(6));
        s.push_undo(o);
        let back = s.pop_undo().unwrap();
        match back.changeset.operation {
            TextOperation::InsertText(i) => {
                match i.position {
                    CursorPosition::InWindow(p) => {
                        assert!(p.x.is_nan(), "NaN must not be normalized away");
                        assert_eq!(p.y, f32::NEG_INFINITY);
                    }
                    _ => panic!("position variant changed across the stack"),
                }
                match i.new_cursor {
                    CursorPosition::OutOfWindow(p) => {
                        assert_eq!(p.x, f32::INFINITY);
                        assert!(
                            p.y.is_sign_negative(),
                            "-0.0 must keep its sign bit through the stack"
                        );
                    }
                    _ => panic!("new_cursor variant changed across the stack"),
                }
            }
            _ => panic!("operation variant changed across the stack"),
        }
    }
    // ---------------------------------------------------------------------
    // UndoRedoManager — constructor + queries on unknown nodes
    // ---------------------------------------------------------------------
    #[test]
    fn manager_new_and_default_are_empty_and_agree() {
        let a = UndoRedoManager::new();
        let b = UndoRedoManager::default();
        for mgr in [&a, &b] {
            assert!(mgr.node_stacks.is_empty());
            assert!(mgr.content_snapshots.is_empty());
        }
        for node in [0usize, 1, usize::MAX - 1] {
            assert!(!a.can_undo(NodeId::new(node)));
            assert!(!a.can_redo(NodeId::new(node)));
            assert!(a.get_stack(NodeId::new(node)).is_none());
            assert!(a.peek_undo(NodeId::new(node)).is_none());
            assert!(a.peek_redo(NodeId::new(node)).is_none());
        }
        assert!(a.get_content_snapshot(0).is_none());
        assert!(a.get_content_snapshot(usize::MAX).is_none());
    }
    #[test]
    fn manager_queries_on_unknown_node_return_none_without_allocating_a_stack() {
        let mut mgr = UndoRedoManager::new();
        for node in [0usize, 42, usize::MAX - 1] {
            assert!(mgr.pop_undo(NodeId::new(node)).is_none());
            assert!(mgr.pop_redo(NodeId::new(node)).is_none());
        }
        assert!(
            mgr.node_stacks.is_empty(),
            "read/pop paths must not silently create per-node stacks"
        );
    }
    #[test]
    fn get_or_create_stack_mut_is_idempotent_and_mutations_persist() {
        let mut mgr = UndoRedoManager::new();
        let boundary = NodeId::new(usize::MAX - 1);
        mgr.get_or_create_stack_mut(NodeId::new(3)).push_undo(op(1, 3));
        // Second call for the same node must reuse, not duplicate.
        mgr.get_or_create_stack_mut(NodeId::new(3)).push_undo(op(2, 3));
        assert_eq!(mgr.node_stacks.len(), 1);
        assert_eq!(mgr.get_stack(NodeId::new(3)).unwrap().undo_stack.len(), 2);
        mgr.get_or_create_stack_mut(boundary).push_undo(op(3, 0));
        assert_eq!(mgr.node_stacks.len(), 2);
        assert_eq!(mgr.get_or_create_stack_mut(boundary).node_id, boundary);
        assert_eq!(mgr.node_stacks.len(), 2, "no duplicate for the boundary id");
        assert!(mgr.can_undo(boundary));
    }
    // ---------------------------------------------------------------------
    // UndoRedoManager — recording, per-node isolation, bounds
    // ---------------------------------------------------------------------
    #[test]
    fn record_operation_is_bounded_and_isolated_per_node() {
        let mut mgr = UndoRedoManager::new();
        for id in 0..(MAX_UNDO_HISTORY * 2 + 5) {
            let o = op(id, 1);
            mgr.record_operation(o.changeset, o.pre_state);
        }
        for id in 500..503 {
            let o = op(id, 2);
            mgr.record_operation(o.changeset, o.pre_state);
        }
        assert_eq!(mgr.node_stacks.len(), 2);
        assert_eq!(
            mgr.get_stack(NodeId::new(1)).unwrap().undo_stack.len(),
            MAX_UNDO_HISTORY
        );
        assert_eq!(mgr.get_stack(NodeId::new(2)).unwrap().undo_stack.len(), 3);
        // Draining node 2 must leave node 1 untouched.
        while mgr.pop_undo(NodeId::new(2)).is_some() {}
        assert!(!mgr.can_undo(NodeId::new(2)));
        assert!(mgr.can_undo(NodeId::new(1)));
        assert_eq!(mgr.peek_undo(NodeId::new(1)).unwrap().changeset.id, MAX_UNDO_HISTORY * 2 + 4);
    }
    #[test]
    fn manager_push_undo_clears_only_the_target_nodes_redo_stack() {
        let mut mgr = UndoRedoManager::new();
        mgr.push_redo(op(1, 1));
        mgr.push_redo(op(2, 2));
        assert!(mgr.can_redo(NodeId::new(1)) && mgr.can_redo(NodeId::new(2)));
        mgr.push_undo(op(3, 1));
        assert!(!mgr.can_redo(NodeId::new(1)), "fresh edit drops node 1's redo branch");
        assert!(mgr.can_redo(NodeId::new(2)), "node 2 is an independent history");
    }
    #[test]
    fn full_undo_then_redo_cycle_returns_the_same_operation() {
        let mut mgr = UndoRedoManager::new();
        let original = op_full(11, 0, 5, "hello");
        mgr.record_operation(original.changeset.clone(), original.pre_state.clone());
        // Undo: pop from undo, push to redo.
        let undone = mgr.pop_undo(NodeId::new(5)).unwrap();
        assert_eq!(undone.changeset.id, 11);
        mgr.push_redo(undone);
        assert!(!mgr.can_undo(NodeId::new(5)));
        assert!(mgr.can_redo(NodeId::new(5)));
        // Redo: pop from redo, reinstate onto undo.
        let redone = mgr.pop_redo(NodeId::new(5)).unwrap();
        assert_eq!(text_of(&redone), "hello", "payload survives undo→redo");
        mgr.reinstate_undo(redone);
        assert!(mgr.can_undo(NodeId::new(5)));
        assert!(!mgr.can_redo(NodeId::new(5)));
        assert_eq!(mgr.peek_undo(NodeId::new(5)).unwrap().changeset.id, 11);
    }
    #[test]
    fn reinstate_undo_preserves_a_full_redo_stack() {
        let mut mgr = UndoRedoManager::new();
        for id in 0..MAX_REDO_HISTORY {
            mgr.push_redo(op(id, 9));
        }
        let redone = mgr.pop_redo(NodeId::new(9)).unwrap();
        mgr.reinstate_undo(redone);
        assert_eq!(
            mgr.get_stack(NodeId::new(9)).unwrap().redo_stack.len(),
            MAX_REDO_HISTORY - 1,
            "reinstating one redo must not wipe the remaining redo branch"
        );
        assert!(mgr.can_undo(NodeId::new(9)));
    }
    // ---------------------------------------------------------------------
    // UndoRedoManager — documented panics on a None changeset target
    // ---------------------------------------------------------------------
    #[test]
    #[should_panic(expected = "TextChangeset target node should not be None")]
    fn record_operation_panics_on_none_target() {
        let mut mgr = UndoRedoManager::new();
        let o = op_with_none_target(1);
        mgr.record_operation(o.changeset, o.pre_state);
    }
    #[test]
    #[should_panic(expected = "TextChangeset target node should not be None")]
    fn manager_push_undo_panics_on_none_target() {
        UndoRedoManager::new().push_undo(op_with_none_target(1));
    }
    #[test]
    #[should_panic(expected = "TextChangeset target node should not be None")]
    fn manager_push_redo_panics_on_none_target() {
        UndoRedoManager::new().push_redo(op_with_none_target(1));
    }
    #[test]
    #[should_panic(expected = "TextChangeset target node should not be None")]
    fn reinstate_undo_panics_on_none_target() {
        UndoRedoManager::new().reinstate_undo(op_with_none_target(1));
    }
    // ---------------------------------------------------------------------
    // Content snapshots — lookup, replacement, FIFO cap, float payloads
    // ---------------------------------------------------------------------
    #[test]
    fn content_snapshot_does_not_swap_pre_and_post() {
        let mut mgr = UndoRedoManager::new();
        mgr.store_content_snapshot(3, one(space(1.0)), one(space(2.0)));
        let snap = mgr.get_content_snapshot(3).expect("stored id must be found");
        assert_eq!(space_width(&snap.pre[0]), 1.0, "pre must stay pre");
        assert_eq!(space_width(&snap.post[0]), 2.0, "post must stay post");
    }
    #[test]
    fn content_snapshot_preserves_nan_and_infinite_widths() {
        let mut mgr = UndoRedoManager::new();
        mgr.store_content_snapshot(1, one(space(f32::NAN)), one(space(f32::INFINITY)));
        let snap = mgr.get_content_snapshot(1).unwrap();
        assert!(space_width(&snap.pre[0]).is_nan(), "NaN width round-trips");
        assert_eq!(space_width(&snap.post[0]), f32::INFINITY);
    }
    #[test]
    fn content_snapshot_boundary_ids_and_misses() {
        let mut mgr = UndoRedoManager::new();
        mgr.store_content_snapshot(0, Vec::new(), Vec::new());
        mgr.store_content_snapshot(usize::MAX, one(space(0.0)), Vec::new());
        assert!(mgr.get_content_snapshot(0).is_some(), "id 0 is a real key, not a sentinel");
        assert!(mgr.get_content_snapshot(usize::MAX).is_some());
        assert!(mgr.get_content_snapshot(1).is_none());
        assert!(mgr.get_content_snapshot(usize::MAX - 1).is_none());
        assert_eq!(mgr.content_snapshots.len(), 2);
    }
    #[test]
    fn storing_the_same_id_replaces_the_entry_and_refreshes_its_recency() {
        let mut mgr = UndoRedoManager::new();
        for id in 0..MAX_CONTENT_SNAPSHOTS {
            mgr.store_content_snapshot(id, one(space(0.0)), Vec::new());
        }
        assert_eq!(mgr.content_snapshots.len(), MAX_CONTENT_SNAPSHOTS);
        // Re-store the OLDEST id with a new payload: it is replaced (no duplicate)
        // and moves to the back of the FIFO.
        mgr.store_content_snapshot(0, one(space(42.0)), Vec::new());
        assert_eq!(mgr.content_snapshots.len(), MAX_CONTENT_SNAPSHOTS, "no duplicate key");
        assert_eq!(space_width(&mgr.get_content_snapshot(0).unwrap().pre[0]), 42.0);
        // One more insert evicts id 1 (now the oldest), NOT the refreshed id 0.
        mgr.store_content_snapshot(9_999, Vec::new(), Vec::new());
        assert_eq!(mgr.content_snapshots.len(), MAX_CONTENT_SNAPSHOTS, "cap holds");
        assert!(mgr.get_content_snapshot(0).is_some(), "refreshed entry survived");
        assert!(mgr.get_content_snapshot(1).is_none(), "second-oldest evicted");
        assert!(mgr.get_content_snapshot(9_999).is_some());
    }
    #[test]
    fn content_snapshot_table_never_exceeds_its_cap_under_heavy_churn() {
        let mut mgr = UndoRedoManager::new();
        for id in 0..(MAX_CONTENT_SNAPSHOTS * 10) {
            mgr.store_content_snapshot(id, one(space(id as f32)), one(space(0.0)));
            assert!(mgr.content_snapshots.len() <= MAX_CONTENT_SNAPSHOTS);
        }
        assert_eq!(mgr.content_snapshots.len(), MAX_CONTENT_SNAPSHOTS);
        // The surviving window is the newest MAX_CONTENT_SNAPSHOTS ids.
        let newest = MAX_CONTENT_SNAPSHOTS * 10 - 1;
        assert!(mgr.get_content_snapshot(newest).is_some());
        assert!(mgr.get_content_snapshot(newest - MAX_CONTENT_SNAPSHOTS).is_none());
    }
    // ---------------------------------------------------------------------
    // remap_node_ids — the stale-manager failure mode
    // ---------------------------------------------------------------------
    #[test]
    fn remap_shifts_stack_and_every_embedded_node_id() {
        let mut mgr = UndoRedoManager::new();
        let o = op_full(1, 0, 3, "a");
        mgr.record_operation(o.changeset, o.pre_state);
        mgr.push_redo(op_full(2, 0, 3, "b"));
        mgr.store_content_snapshot(1, one(space(1.0)), Vec::new());
        // Preceding sibling deleted: node 3 becomes node 2.
        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
        mgr.remap_node_ids(DomId { inner: 0 }, &map);
        assert!(mgr.get_stack(NodeId::new(3)).is_none(), "old key must be gone");
        let stack = mgr.get_stack(NodeId::new(2)).expect("stack re-keyed to the new id");
        assert_eq!(stack.node_id, NodeId::new(2));
        let undo = stack.peek_undo().unwrap();
        assert_eq!(target_node(undo), Some(NodeId::new(2)), "changeset target remapped");
        assert_eq!(undo.pre_state.node_id, NodeId::new(2), "snapshot node remapped");
        let redo = stack.peek_redo().unwrap();
        assert_eq!(target_node(redo), Some(NodeId::new(2)), "redo stack remapped too");
        assert_eq!(redo.pre_state.node_id, NodeId::new(2));
        assert!(
            mgr.get_content_snapshot(1).is_some(),
            "snapshot of a still-live changeset must survive the GC"
        );
    }
    #[test]
    fn remap_drops_unmounted_history_and_gcs_its_snapshots() {
        let mut mgr = UndoRedoManager::new();
        let o = op_full(1, 0, 3, "a");
        mgr.record_operation(o.changeset, o.pre_state);
        mgr.store_content_snapshot(1, one(space(1.0)), Vec::new());
        // Node 3 is not in the map → it was unmounted.
        let map = NodeIdMap::from_pairs([(NodeId::new(0), NodeId::new(0))]);
        mgr.remap_node_ids(DomId { inner: 0 }, &map);
        assert!(mgr.node_stacks.is_empty(), "history of an unmounted node is dropped");
        assert!(!mgr.can_undo(NodeId::new(3)));
        assert!(!mgr.can_undo(NodeId::new(2)), "history must NOT re-attach to a neighbour");
        assert!(
            mgr.content_snapshots.is_empty(),
            "snapshots of dropped changesets are GC'd"
        );
    }
    #[test]
    fn remap_leaves_stacks_belonging_to_another_dom_untouched() {
        let mut mgr = UndoRedoManager::new();
        // Stack on node 3 of DOM 1 (a *different* DOM from the one being rebuilt).
        let o = op_full(1, 1, 3, "a");
        mgr.record_operation(o.changeset, o.pre_state);
        mgr.store_content_snapshot(1, one(space(1.0)), Vec::new());
        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
        mgr.remap_node_ids(DomId { inner: 0 }, &map);
        let stack = mgr
            .get_stack(NodeId::new(3))
            .expect("a foreign DOM's stack keeps its key");
        assert_eq!(stack.node_id, NodeId::new(3));
        let undo = stack.peek_undo().unwrap();
        assert_eq!(target_node(undo), Some(NodeId::new(3)), "foreign target untouched");
        assert_eq!(undo.changeset.target.dom, DomId { inner: 1 });
        assert_eq!(undo.pre_state.node_id, NodeId::new(3));
        assert!(mgr.get_content_snapshot(1).is_some());
    }
    #[test]
    fn remap_of_an_empty_stack_follows_the_map_or_drops_it() {
        // An empty stack carries no DOM evidence, so it is treated as belonging to
        // the DOM being reconciled: mapped → re-keyed, unmapped → dropped.
        let mut mapped = UndoRedoManager::new();
        mapped.get_or_create_stack_mut(NodeId::new(7));
        mapped.remap_node_ids(
            DomId { inner: 0 },
            &NodeIdMap::from_pairs([(NodeId::new(7), NodeId::new(9))]),
        );
        assert!(mapped.get_stack(NodeId::new(9)).is_some());
        assert!(mapped.get_stack(NodeId::new(7)).is_none());
        let mut dropped = UndoRedoManager::new();
        dropped.get_or_create_stack_mut(NodeId::new(7));
        dropped.remap_node_ids(DomId { inner: 0 }, &NodeIdMap::from_pairs([]));
        assert!(dropped.node_stacks.is_empty());
    }
    #[test]
    fn remap_with_an_empty_map_drops_every_stack_of_that_dom() {
        let mut mgr = UndoRedoManager::new();
        for node in 0..5 {
            let o = op_full(node, 0, node, "a");
            mgr.record_operation(o.changeset, o.pre_state);
        }
        assert_eq!(mgr.node_stacks.len(), 5);
        mgr.remap_node_ids(DomId { inner: 0 }, &NodeIdMap::from_pairs([]));
        assert!(mgr.node_stacks.is_empty(), "nothing survived the rebuild");
        assert!(mgr.content_snapshots.is_empty());
    }
    #[test]
    fn remap_is_idempotent_when_the_map_is_the_identity() {
        let mut mgr = UndoRedoManager::new();
        let o = op_full(1, 0, 3, "a");
        mgr.record_operation(o.changeset, o.pre_state);
        let identity = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(3))]);
        for _ in 0..3 {
            mgr.remap_node_ids(DomId { inner: 0 }, &identity);
        }
        assert_eq!(mgr.node_stacks.len(), 1);
        let stack = mgr.get_stack(NodeId::new(3)).unwrap();
        assert_eq!(stack.undo_stack.len(), 1, "repeated remaps must not duplicate ops");
        assert_eq!(target_node(stack.peek_undo().unwrap()), Some(NodeId::new(3)));
    }
    #[test]
    fn remap_gcs_a_snapshot_whose_changeset_was_never_recorded() {
        // A snapshot stored for an operation that never made it onto a stack is
        // collected by the very next remap — documented GC behaviour, pinned here
        // so a future change to the GC predicate is a visible test failure.
        let mut mgr = UndoRedoManager::new();
        mgr.store_content_snapshot(77, one(space(1.0)), Vec::new());
        assert!(mgr.get_content_snapshot(77).is_some());
        mgr.remap_node_ids(DomId { inner: 0 }, &NodeIdMap::from_pairs([]));
        assert!(mgr.get_content_snapshot(77).is_none());
    }
    // ---------------------------------------------------------------------
    // remap_operation (private) — direct unit tests
    // ---------------------------------------------------------------------
    #[test]
    fn remap_operation_rewrites_target_and_snapshot_for_the_matching_dom() {
        let mut o = op_full(1, 0, 3, "a");
        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
        assert_eq!(target_node(&o), Some(NodeId::new(2)));
        assert_eq!(o.pre_state.node_id, NodeId::new(2));
    }
    #[test]
    fn remap_operation_leaves_a_foreign_doms_target_alone() {
        let mut o = op_full(1, 1, 3, "a");
        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
        assert_eq!(
            target_node(&o),
            Some(NodeId::new(3)),
            "target in DOM 1 must not be rewritten by a DOM 0 reconciliation"
        );
        assert_eq!(o.changeset.target.dom, DomId { inner: 1 });
        // The bare pre_state NodeId has no DOM tag, so it still follows the map.
        assert_eq!(o.pre_state.node_id, NodeId::new(2));
    }
    #[test]
    fn remap_operation_falls_back_to_the_stack_node_for_a_vanished_snapshot() {
        let mut o = op_full(1, 0, 3, "a");
        o.pre_state.node_id = NodeId::new(88); // snapshot node not in the map
        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
        assert_eq!(target_node(&o), Some(NodeId::new(2)));
        assert_eq!(
            o.pre_state.node_id,
            NodeId::new(2),
            "unresolvable snapshot node is pinned to the surviving stack node"
        );
    }
    #[test]
    fn remap_operation_handles_the_largest_encodable_node_id() {
        // NodeId(usize::MAX - 1) is the largest id the 1-based FFI encoding can
        // hold (`into_raw` computes n + 1); it must survive a remap round-trip.
        let boundary = NodeId::new(usize::MAX - 1);
        let mut o = op_full(1, 0, 3, "a");
        let map = NodeIdMap::from_pairs([(NodeId::new(3), boundary)]);
        remap_operation(&mut o, DomId { inner: 0 }, &map, boundary);
        assert_eq!(target_node(&o), Some(boundary), "encode/decode is lossless at the boundary");
        assert_eq!(o.pre_state.node_id, boundary);
        assert_eq!(target_node(&o).unwrap().index(), usize::MAX - 1);
    }
    #[test]
    fn remap_operation_is_idempotent() {
        let mut o = op_full(1, 0, 3, "a");
        let map = NodeIdMap::from_pairs([
            (NodeId::new(3), NodeId::new(2)),
            (NodeId::new(2), NodeId::new(2)),
        ]);
        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
        assert_eq!(target_node(&o), Some(NodeId::new(2)), "no double-shift");
        assert_eq!(o.pre_state.node_id, NodeId::new(2));
    }
}