Skip to main content

azul_layout/managers/
undo_redo.rs

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
29use alloc::{collections::VecDeque, vec::Vec};
30
31use azul_core::{
32    dom::{DomId, NodeId},
33    selection::{OptionSelectionRange, OptionTextCursor},
34    task::Instant,
35};
36use azul_css::{impl_option, impl_option_inner, AzString};
37
38use super::changeset::TextChangeset;
39
40/// Maximum number of undo operations to keep per node
41pub const MAX_UNDO_HISTORY: usize = 10;
42
43/// Maximum number of redo operations to keep per node
44pub 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)]
51pub 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)]
70pub 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
77impl_option!(
78    UndoableOperation,
79    OptionUndoableOperation,
80    copy = false,
81    [Debug, Clone]
82);
83
84/// Per-node undo/redo stack
85#[derive(Debug, Clone)]
86pub 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
95impl NodeUndoRedoStack {
96    #[must_use] pub fn new(node_id: NodeId) -> Self {
97        Self {
98            node_id,
99            undo_stack: VecDeque::with_capacity(MAX_UNDO_HISTORY),
100            redo_stack: VecDeque::with_capacity(MAX_REDO_HISTORY),
101        }
102    }
103
104    /// Push a new operation to the undo stack
105    pub fn push_undo(&mut self, operation: UndoableOperation) {
106        // Clear redo stack when new operation is performed
107        self.redo_stack.clear();
108
109        // Add to undo stack
110        self.undo_stack.push_back(operation);
111
112        // Limit stack size
113        if self.undo_stack.len() > MAX_UNDO_HISTORY {
114            self.undo_stack.pop_front();
115        }
116    }
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    pub fn push_undo_preserving_redo(&mut self, operation: UndoableOperation) {
123        self.undo_stack.push_back(operation);
124        if self.undo_stack.len() > MAX_UNDO_HISTORY {
125            self.undo_stack.pop_front();
126        }
127    }
128
129    /// Pop the most recent operation from undo stack
130    pub fn pop_undo(&mut self) -> Option<UndoableOperation> {
131        self.undo_stack.pop_back()
132    }
133
134    /// Push an operation to the redo stack (after undo)
135    pub fn push_redo(&mut self, operation: UndoableOperation) {
136        self.redo_stack.push_back(operation);
137
138        // Limit stack size
139        if self.redo_stack.len() > MAX_REDO_HISTORY {
140            self.redo_stack.pop_front();
141        }
142    }
143
144    /// Pop the most recent operation from redo stack
145    pub fn pop_redo(&mut self) -> Option<UndoableOperation> {
146        self.redo_stack.pop_back()
147    }
148
149    /// Check if undo is available
150    #[must_use] pub fn can_undo(&self) -> bool {
151        !self.undo_stack.is_empty()
152    }
153
154    /// Check if redo is available
155    #[must_use] pub fn can_redo(&self) -> bool {
156        !self.redo_stack.is_empty()
157    }
158
159    /// Peek at the most recent undo operation without removing it
160    #[must_use] pub fn peek_undo(&self) -> Option<&UndoableOperation> {
161        self.undo_stack.back()
162    }
163
164    /// Peek at the most recent redo operation without removing it
165    #[must_use] pub fn peek_redo(&self) -> Option<&UndoableOperation> {
166        self.redo_stack.back()
167    }
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)]
178pub 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).
188const MAX_CONTENT_SNAPSHOTS: usize = 64;
189
190/// Manager for undo/redo operations across all text nodes
191#[derive(Debug, Clone, Default)]
192pub struct UndoRedoManager {
193    /// Per-node undo/redo stacks
194    /// Using Vec instead of `HashMap` for `no_std` compatibility
195    pub node_stacks: Vec<NodeUndoRedoStack>,
196    /// Styled-content snapshots keyed by `TextChangeset.id` (see
197    /// [`ContentSnapshot`]); FIFO-capped at [`MAX_CONTENT_SNAPSHOTS`].
198    pub content_snapshots: Vec<(super::changeset::ChangesetId, ContentSnapshot)>,
199}
200
201impl UndoRedoManager {
202    /// Create a new empty undo/redo manager
203    #[must_use] pub const fn new() -> Self {
204        Self {
205            node_stacks: Vec::new(),
206            content_snapshots: Vec::new(),
207        }
208    }
209
210    /// Store the styled pre/post content for a changeset (see [`ContentSnapshot`]).
211    pub fn store_content_snapshot(
212        &mut self,
213        id: super::changeset::ChangesetId,
214        pre: Vec<crate::text3::cache::InlineContent>,
215        post: Vec<crate::text3::cache::InlineContent>,
216    ) {
217        self.content_snapshots.retain(|(existing, _)| *existing != id);
218        self.content_snapshots.push((id, ContentSnapshot { pre, post }));
219        if self.content_snapshots.len() > MAX_CONTENT_SNAPSHOTS {
220            self.content_snapshots.remove(0);
221        }
222    }
223
224    /// Look up the styled snapshot for a changeset id.
225    #[must_use] pub fn get_content_snapshot(
226        &self,
227        id: super::changeset::ChangesetId,
228    ) -> Option<&ContentSnapshot> {
229        self.content_snapshots
230            .iter()
231            .find(|(existing, _)| *existing == id)
232            .map(|(_, snap)| snap)
233    }
234
235    /// MWA-C-undo_redo: put a redone operation back on the undo stack
236    /// WITHOUT clearing the redo stack (see
237    /// [`NodeUndoRedoStack::push_undo_preserving_redo`]).
238    /// # Panics
239    /// Panics if the operation's changeset target node is None.
240    pub fn reinstate_undo(&mut self, operation: UndoableOperation) {
241        let node_id = operation
242            .changeset
243            .target
244            .node
245            .into_crate_internal()
246            .expect("TextChangeset target node should not be None");
247        let stack = self.get_or_create_stack_mut(node_id);
248        stack.push_undo_preserving_redo(operation);
249    }
250
251    /// Get or create a stack for a specific node
252    /// # Panics
253    ///
254    /// Panics if the per-node stack list is unexpectedly empty after insertion.
255    pub fn get_or_create_stack_mut(&mut self, node_id: NodeId) -> &mut NodeUndoRedoStack {
256        if let Some(pos) = self.node_stacks.iter().position(|s| s.node_id == node_id) {
257            &mut self.node_stacks[pos]
258        } else {
259            self.node_stacks.push(NodeUndoRedoStack::new(node_id));
260            self.node_stacks.last_mut().unwrap()
261        }
262    }
263
264    /// Get a stack for a specific node (immutable)
265    #[must_use] pub fn get_stack(&self, node_id: NodeId) -> Option<&NodeUndoRedoStack> {
266        self.node_stacks.iter().find(|s| s.node_id == node_id)
267    }
268
269    /// Get a stack for a specific node (mutable)
270    fn get_stack_mut(&mut self, node_id: NodeId) -> Option<&mut NodeUndoRedoStack> {
271        self.node_stacks.iter_mut().find(|s| s.node_id == node_id)
272    }
273
274    /// Record a text operation (push to undo stack)
275    ///
276    /// This should be called AFTER a changeset has been successfully applied.
277    /// The `pre_state` should contain the node state BEFORE the changeset was applied.
278    ///
279    /// ## Arguments
280    /// * `changeset` - The changeset that was applied
281    /// * `pre_state` - Node state before the changeset
282    /// # Panics
283    ///
284    /// Panics if the changeset's target node is None.
285    pub fn record_operation(&mut self, changeset: TextChangeset, pre_state: NodeStateSnapshot) {
286        // Convert DomNodeId to NodeId for indexing
287        // NodeHierarchyItemId.into_crate_internal() decodes the 1-based encoding to Option<NodeId>
288        let node_id = changeset
289            .target
290            .node
291            .into_crate_internal()
292            .expect("TextChangeset target node should not be None");
293        let stack = self.get_or_create_stack_mut(node_id);
294
295        let operation = UndoableOperation {
296            changeset,
297            pre_state,
298        };
299
300        stack.push_undo(operation);
301    }
302
303    /// Check if undo is available for a node
304    #[must_use] pub fn can_undo(&self, node_id: NodeId) -> bool {
305        self.get_stack(node_id)
306            .is_some_and(NodeUndoRedoStack::can_undo)
307    }
308
309    /// Check if redo is available for a node
310    #[must_use] pub fn can_redo(&self, node_id: NodeId) -> bool {
311        self.get_stack(node_id)
312            .is_some_and(NodeUndoRedoStack::can_redo)
313    }
314
315    /// Peek at the next undo operation for a node (without removing it)
316    ///
317    /// This allows user callbacks to inspect what would be undone.
318    #[must_use] pub fn peek_undo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
319        self.get_stack(node_id).and_then(|s| s.peek_undo())
320    }
321
322    /// Peek at the next redo operation for a node (without removing it)
323    ///
324    /// This allows user callbacks to inspect what would be redone.
325    #[must_use] pub fn peek_redo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
326        self.get_stack(node_id).and_then(|s| s.peek_redo())
327    }
328
329    /// Pop an operation from the undo stack
330    ///
331    /// This should be called during undo processing to get the operation to revert.
332    /// After reverting, the operation should be pushed to the redo stack.
333    ///
334    /// ## Returns
335    /// * `Some(operation)` - The operation to undo
336    /// * `None` - No undo history available
337    pub fn pop_undo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
338        self.get_stack_mut(node_id)?.pop_undo()
339    }
340
341    /// Pop an operation from the redo stack
342    ///
343    /// This should be called during redo processing to get the operation to re-apply.
344    /// After re-applying, the operation should be pushed to the undo stack.
345    ///
346    /// ## Returns
347    /// * `Some(operation)` - The operation to redo
348    /// * `None` - No redo history available
349    pub fn pop_redo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
350        self.get_stack_mut(node_id)?.pop_redo()
351    }
352
353    /// Push an operation to the redo stack (after successful undo)
354    ///
355    /// This should be called AFTER an undo operation has been successfully applied.
356    /// # Panics
357    ///
358    /// Panics if the operation's changeset target node is None.
359    pub fn push_redo(&mut self, operation: UndoableOperation) {
360        let node_id = operation
361            .changeset
362            .target
363            .node
364            .into_crate_internal()
365            .expect("TextChangeset target node should not be None");
366        let stack = self.get_or_create_stack_mut(node_id);
367        stack.push_redo(operation);
368    }
369
370    /// Push an operation to the undo stack (after successful redo)
371    ///
372    /// This should be called AFTER a redo operation has been successfully applied.
373    /// # Panics
374    ///
375    /// Panics if the operation's changeset target node is None.
376    pub fn push_undo(&mut self, operation: UndoableOperation) {
377        let node_id = operation
378            .changeset
379            .target
380            .node
381            .into_crate_internal()
382            .expect("TextChangeset target node should not be None");
383        let stack = self.get_or_create_stack_mut(node_id);
384        stack.push_undo(operation);
385    }
386
387}
388
389impl crate::managers::NodeIdRemap for UndoRedoManager {
390    /// Remap the per-node undo/redo stacks after a DOM rebuild.
391    ///
392    /// This is the worst offender of the "stale manager" family: the stacks are
393    /// keyed by a bare `NodeId`, so deleting a PRECEDING SIBLING (which shifts
394    /// every following index down by one) used to leave the whole undo history
395    /// silently re-attached to a DIFFERENT, still-live element — undo would edit
396    /// the wrong node, with no panic and no error.
397    ///
398    /// A stack is attributed to a DOM through the `DomNodeId` target of its
399    /// operations (an empty stack carries no information and is treated as
400    /// belonging to the DOM being reconciled). Stacks for unmounted nodes are
401    /// dropped, and the content snapshots they referenced are GC'd with them.
402    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
403        let old_stacks = core::mem::take(&mut self.node_stacks);
404
405        for mut stack in old_stacks {
406            // Which DOM does this stack belong to? Derived from its operations.
407            let stack_dom = stack
408                .undo_stack
409                .front()
410                .or_else(|| stack.redo_stack.front())
411                .map(|op| op.changeset.target.dom);
412
413            if stack_dom.is_some_and(|d| d != dom) {
414                // Belongs to a different DOM — this reconciliation says nothing about it.
415                self.node_stacks.push(stack);
416                continue;
417            }
418
419            let Some(new_node_id) = map.resolve(stack.node_id) else {
420                // Node unmounted — drop the whole history (GC). Keeping it would
421                // re-attach this history to whichever node inherits the index.
422                continue;
423            };
424
425            stack.node_id = new_node_id;
426            for op in stack.undo_stack.iter_mut().chain(stack.redo_stack.iter_mut()) {
427                remap_operation(op, dom, map, new_node_id);
428            }
429            self.node_stacks.push(stack);
430        }
431
432        // GC content snapshots whose changesets no longer exist in any stack.
433        let live: alloc::collections::BTreeSet<_> = self
434            .node_stacks
435            .iter()
436            .flat_map(|s| s.undo_stack.iter().chain(s.redo_stack.iter()))
437            .map(|op| op.changeset.id)
438            .collect();
439        self.content_snapshots.retain(|(id, _)| live.contains(id));
440    }
441}
442
443/// Rewrite the `NodeIds` embedded inside a single undoable operation.
444fn remap_operation(
445    op: &mut UndoableOperation,
446    dom: DomId,
447    map: &crate::managers::NodeIdMap,
448    new_node_id: NodeId,
449) {
450    use azul_core::styled_dom::NodeHierarchyItemId;
451    if op.changeset.target.dom == dom {
452        op.changeset.target.node = NodeHierarchyItemId::from_crate_internal(Some(new_node_id));
453    }
454    if let Some(remapped) = map.resolve(op.pre_state.node_id) {
455        op.pre_state.node_id = remapped;
456    } else {
457        // The snapshot's node vanished but the stack's node survived: keep the
458        // stack coherent by pointing the snapshot at the (surviving) stack node.
459        op.pre_state.node_id = new_node_id;
460    }
461}
462
463
464#[cfg(test)]
465mod undo_redo_tests {
466    use super::*;
467    use crate::managers::changeset::{TextChangeset, TextOpInsertText, TextOperation};
468    use azul_core::dom::{DomId, DomNodeId};
469    use azul_core::styled_dom::NodeHierarchyItemId;
470    use azul_core::task::SystemTick;
471    use azul_core::window::CursorPosition;
472
473    fn ts() -> Instant {
474        Instant::Tick(SystemTick { tick_counter: 0 })
475    }
476
477    fn op(id: usize, node: usize) -> UndoableOperation {
478        UndoableOperation {
479            changeset: TextChangeset {
480                id,
481                target: DomNodeId {
482                    dom: DomId { inner: 0 },
483                    node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
484                },
485                operation: TextOperation::InsertText(TextOpInsertText {
486                    text: "x".into(),
487                    position: CursorPosition::Uninitialized,
488                    new_cursor: CursorPosition::Uninitialized,
489                }),
490                timestamp: ts(),
491            },
492            pre_state: NodeStateSnapshot {
493                node_id: NodeId::new(node),
494                text_content: "".into(),
495                cursor_position: None.into(),
496                selection_range: None.into(),
497                timestamp: ts(),
498            },
499        }
500    }
501
502    #[test]
503    fn push_undo_clears_redo_but_reinstate_preserves_it() {
504        let mut stack = NodeUndoRedoStack::new(NodeId::new(1));
505        stack.push_redo(op(1, 1));
506        stack.push_redo(op(2, 1));
507        assert_eq!(stack.redo_stack.len(), 2);
508
509        // Fresh user edit: redo history is invalidated.
510        stack.push_undo(op(3, 1));
511        assert_eq!(stack.redo_stack.len(), 0);
512
513        // Redone operation moving back to undo: remaining redos survive.
514        stack.push_redo(op(4, 1));
515        stack.push_redo(op(5, 1));
516        stack.push_undo_preserving_redo(op(6, 1));
517        assert_eq!(stack.redo_stack.len(), 2);
518        assert!(stack.can_undo());
519    }
520
521    #[test]
522    fn content_snapshots_replace_lookup_and_evict() {
523        let mut mgr = UndoRedoManager::new();
524        mgr.store_content_snapshot(7, Vec::new(), Vec::new());
525        assert!(mgr.get_content_snapshot(7).is_some());
526        assert!(mgr.get_content_snapshot(8).is_none());
527
528        // Same id replaces, no duplicate entries.
529        mgr.store_content_snapshot(7, Vec::new(), Vec::new());
530        assert_eq!(mgr.content_snapshots.len(), 1);
531
532        // FIFO cap: oldest evicted once over MAX_CONTENT_SNAPSHOTS.
533        for id in 100..(100 + MAX_CONTENT_SNAPSHOTS) {
534            mgr.store_content_snapshot(id, Vec::new(), Vec::new());
535        }
536        assert!(mgr.content_snapshots.len() <= MAX_CONTENT_SNAPSHOTS);
537        assert!(mgr.get_content_snapshot(7).is_none(), "oldest entry evicted");
538        assert!(mgr
539            .get_content_snapshot(100 + MAX_CONTENT_SNAPSHOTS - 1)
540            .is_some());
541    }
542
543    #[test]
544    fn reinstate_undo_keeps_manager_redo_stack() {
545        let mut mgr = UndoRedoManager::new();
546        mgr.record_operation(op(1, 3).changeset, op(1, 3).pre_state);
547        let popped = mgr.pop_undo(NodeId::new(3)).unwrap();
548        mgr.push_redo(popped);
549        let redone = mgr.pop_redo(NodeId::new(3)).unwrap();
550        // Second redo entry that must survive the reinstate:
551        mgr.push_redo(op(2, 3));
552        mgr.reinstate_undo(redone);
553        assert!(mgr.can_undo(NodeId::new(3)));
554        assert!(mgr.can_redo(NodeId::new(3)), "redo stack preserved");
555    }
556}