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::NodeId,
33    geom::LogicalPosition,
34    selection::{
35        CursorAffinity, GraphemeClusterId, OptionSelectionRange, OptionTextCursor, SelectionRange,
36        TextCursor,
37    },
38    task::Instant,
39    window::CursorPosition,
40};
41use azul_css::{impl_option, impl_option_inner, AzString};
42
43use super::changeset::{TextChangeset, TextOperation};
44
45/// Maximum number of undo operations to keep per node
46pub const MAX_UNDO_HISTORY: usize = 10;
47
48/// Maximum number of redo operations to keep per node
49pub const MAX_REDO_HISTORY: usize = 10;
50
51/// Snapshot of a text node's state before a changeset was applied.
52///
53/// This contains enough information to fully revert a text operation.
54#[derive(Debug, Clone)]
55#[repr(C)]
56pub struct NodeStateSnapshot {
57    /// The node this snapshot belongs to
58    pub node_id: NodeId,
59    /// Full text content before changeset
60    pub text_content: AzString,
61    /// Cursor position before changeset (if applicable)
62    /// For now, we store the logical position, not the TextCursor
63    pub cursor_position: OptionTextCursor,
64    /// Selection range before changeset (if applicable)
65    pub selection_range: OptionSelectionRange,
66    /// When this snapshot was taken
67    pub timestamp: Instant,
68}
69
70/// A recorded operation that can be undone/redone.
71///
72/// Combines the changeset that was applied with the state before application.
73#[derive(Debug, Clone)]
74#[repr(C)]
75pub struct UndoableOperation {
76    /// The changeset that was applied
77    pub changeset: TextChangeset,
78    /// Node state BEFORE the changeset was applied
79    pub pre_state: NodeStateSnapshot,
80}
81
82impl_option!(
83    UndoableOperation,
84    OptionUndoableOperation,
85    copy = false,
86    [Debug, Clone]
87);
88
89/// Per-node undo/redo stack
90#[derive(Debug, Clone)]
91pub struct NodeUndoRedoStack {
92    /// Node ID this stack belongs to
93    pub node_id: NodeId,
94    /// Undo stack (most recent at back)
95    pub undo_stack: VecDeque<UndoableOperation>,
96    /// Redo stack (most recent at back)
97    pub redo_stack: VecDeque<UndoableOperation>,
98}
99
100impl NodeUndoRedoStack {
101    pub fn new(node_id: NodeId) -> Self {
102        Self {
103            node_id,
104            undo_stack: VecDeque::with_capacity(MAX_UNDO_HISTORY),
105            redo_stack: VecDeque::with_capacity(MAX_REDO_HISTORY),
106        }
107    }
108
109    /// Push a new operation to the undo stack
110    pub fn push_undo(&mut self, operation: UndoableOperation) {
111        // Clear redo stack when new operation is performed
112        self.redo_stack.clear();
113
114        // Add to undo stack
115        self.undo_stack.push_back(operation);
116
117        // Limit stack size
118        if self.undo_stack.len() > MAX_UNDO_HISTORY {
119            self.undo_stack.pop_front();
120        }
121    }
122
123    /// Pop the most recent operation from undo stack
124    pub fn pop_undo(&mut self) -> Option<UndoableOperation> {
125        self.undo_stack.pop_back()
126    }
127
128    /// Push an operation to the redo stack (after undo)
129    pub fn push_redo(&mut self, operation: UndoableOperation) {
130        self.redo_stack.push_back(operation);
131
132        // Limit stack size
133        if self.redo_stack.len() > MAX_REDO_HISTORY {
134            self.redo_stack.pop_front();
135        }
136    }
137
138    /// Pop the most recent operation from redo stack
139    pub fn pop_redo(&mut self) -> Option<UndoableOperation> {
140        self.redo_stack.pop_back()
141    }
142
143    /// Check if undo is available
144    /// Check if undo is available
145    pub fn can_undo(&self) -> bool {
146        !self.undo_stack.is_empty()
147    }
148
149    /// Check if redo is available
150    pub fn can_redo(&self) -> bool {
151        !self.redo_stack.is_empty()
152    }
153
154    /// Peek at the most recent undo operation without removing it
155    pub fn peek_undo(&self) -> Option<&UndoableOperation> {
156        self.undo_stack.back()
157    }
158
159    /// Peek at the most recent redo operation without removing it
160    pub fn peek_redo(&self) -> Option<&UndoableOperation> {
161        self.redo_stack.back()
162    }
163}
164
165/// Manager for undo/redo operations across all text nodes
166#[derive(Debug, Clone, Default)]
167pub struct UndoRedoManager {
168    /// Per-node undo/redo stacks
169    /// Using Vec instead of HashMap for no_std compatibility
170    pub node_stacks: Vec<NodeUndoRedoStack>,
171}
172
173impl UndoRedoManager {
174    /// Create a new empty undo/redo manager
175    pub fn new() -> Self {
176        Self {
177            node_stacks: Vec::new(),
178        }
179    }
180
181    /// Get or create a stack for a specific node
182    pub fn get_or_create_stack_mut(&mut self, node_id: NodeId) -> &mut NodeUndoRedoStack {
183        // Check if stack exists
184        let stack_exists = self.node_stacks.iter().any(|s| s.node_id == node_id);
185
186        if !stack_exists {
187            // Create new stack
188            let stack = NodeUndoRedoStack::new(node_id);
189            self.node_stacks.push(stack);
190        }
191
192        // Now find and return the stack (guaranteed to exist)
193        self.node_stacks
194            .iter_mut()
195            .find(|s| s.node_id == node_id)
196            .unwrap()
197    }
198
199    /// Get a stack for a specific node (immutable)
200    pub fn get_stack(&self, node_id: NodeId) -> Option<&NodeUndoRedoStack> {
201        self.node_stacks.iter().find(|s| s.node_id == node_id)
202    }
203
204    /// Get a stack for a specific node (mutable)
205    fn get_stack_mut(&mut self, node_id: NodeId) -> Option<&mut NodeUndoRedoStack> {
206        self.node_stacks.iter_mut().find(|s| s.node_id == node_id)
207    }
208
209    /// Record a text operation (push to undo stack)
210    ///
211    /// This should be called AFTER a changeset has been successfully applied.
212    /// The pre_state should contain the node state BEFORE the changeset was applied.
213    ///
214    /// ## Arguments
215    /// * `changeset` - The changeset that was applied
216    /// * `pre_state` - Node state before the changeset
217    pub fn record_operation(&mut self, changeset: TextChangeset, pre_state: NodeStateSnapshot) {
218        // Convert DomNodeId to NodeId for indexing
219        // NodeHierarchyItemId.into_crate_internal() decodes the 1-based encoding to Option<NodeId>
220        let node_id = changeset
221            .target
222            .node
223            .into_crate_internal()
224            .expect("TextChangeset target node should not be None");
225        let stack = self.get_or_create_stack_mut(node_id);
226
227        let operation = UndoableOperation {
228            changeset,
229            pre_state,
230        };
231
232        stack.push_undo(operation);
233    }
234
235    /// Check if undo is available for a node
236    pub fn can_undo(&self, node_id: NodeId) -> bool {
237        self.get_stack(node_id)
238            .map(|s| s.can_undo())
239            .unwrap_or(false)
240    }
241
242    /// Check if redo is available for a node
243    pub fn can_redo(&self, node_id: NodeId) -> bool {
244        self.get_stack(node_id)
245            .map(|s| s.can_redo())
246            .unwrap_or(false)
247    }
248
249    /// Peek at the next undo operation for a node (without removing it)
250    ///
251    /// This allows user callbacks to inspect what would be undone.
252    pub fn peek_undo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
253        self.get_stack(node_id).and_then(|s| s.peek_undo())
254    }
255
256    /// Peek at the next redo operation for a node (without removing it)
257    ///
258    /// This allows user callbacks to inspect what would be redone.
259    pub fn peek_redo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
260        self.get_stack(node_id).and_then(|s| s.peek_redo())
261    }
262
263    /// Pop an operation from the undo stack
264    ///
265    /// This should be called during undo processing to get the operation to revert.
266    /// After reverting, the operation should be pushed to the redo stack.
267    ///
268    /// ## Returns
269    /// * `Some(operation)` - The operation to undo
270    /// * `None` - No undo history available
271    pub fn pop_undo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
272        self.get_stack_mut(node_id)?.pop_undo()
273    }
274
275    /// Pop an operation from the redo stack
276    ///
277    /// This should be called during redo processing to get the operation to re-apply.
278    /// After re-applying, the operation should be pushed to the undo stack.
279    ///
280    /// ## Returns
281    /// * `Some(operation)` - The operation to redo
282    /// * `None` - No redo history available
283    pub fn pop_redo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
284        self.get_stack_mut(node_id)?.pop_redo()
285    }
286
287    /// Push an operation to the redo stack (after successful undo)
288    ///
289    /// This should be called AFTER an undo operation has been successfully applied.
290    pub fn push_redo(&mut self, operation: UndoableOperation) {
291        let node_id = operation
292            .changeset
293            .target
294            .node
295            .into_crate_internal()
296            .expect("TextChangeset target node should not be None");
297        let stack = self.get_or_create_stack_mut(node_id);
298        stack.push_redo(operation);
299    }
300
301    /// Push an operation to the undo stack (after successful redo)
302    ///
303    /// This should be called AFTER a redo operation has been successfully applied.
304    pub fn push_undo(&mut self, operation: UndoableOperation) {
305        let node_id = operation
306            .changeset
307            .target
308            .node
309            .into_crate_internal()
310            .expect("TextChangeset target node should not be None");
311        let stack = self.get_or_create_stack_mut(node_id);
312        stack.push_undo(operation);
313    }
314
315    /// Clear all undo/redo history for a specific node
316    pub fn clear_node(&mut self, node_id: NodeId) {
317        if let Some(stack) = self.get_stack_mut(node_id) {
318            stack.undo_stack.clear();
319            stack.redo_stack.clear();
320        }
321    }
322
323    /// Clear all undo/redo history for all nodes
324    pub fn clear_all(&mut self) {
325        self.node_stacks.clear();
326    }
327
328    /// Get the total number of operations in undo stack for a node
329    pub fn undo_depth(&self, node_id: NodeId) -> usize {
330        self.get_stack(node_id)
331            .map(|s| s.undo_stack.len())
332            .unwrap_or(0)
333    }
334
335    /// Get the total number of operations in redo stack for a node
336    pub fn redo_depth(&self, node_id: NodeId) -> usize {
337        self.get_stack(node_id)
338            .map(|s| s.redo_stack.len())
339            .unwrap_or(0)
340    }
341}
342
343/// Helper function to create a revert changeset from an undoable operation.
344///
345/// This analyzes the changeset and creates the inverse operation that will
346/// restore the pre_state.
347///
348/// ## Arguments
349///
350/// * `operation` - The operation to create a revert for
351/// * `timestamp` - Current time for the revert changeset
352///
353/// Returns: `TextChangeset` - The changeset that reverts the operation
354pub fn create_revert_changeset(operation: &UndoableOperation, timestamp: Instant) -> TextChangeset {
355    use crate::managers::changeset::{
356        TextOpClearSelection, TextOpCopy, TextOpCut, TextOpDeleteText, TextOpExtendSelection,
357        TextOpInsertText, TextOpMoveCursor, TextOpPaste, TextOpReplaceText, TextOpSelectAll,
358        TextOpSetSelection,
359    };
360
361    // Create the inverse operation based on what was done
362    let revert_operation = match &operation.changeset.operation {
363        // InsertText → DeleteText (remove what was inserted)
364        TextOperation::InsertText(op) => {
365            // To revert an insert, we need to delete the inserted text
366            // The range is from old position to new position
367            // For now, we use a simplified approach - restore the old text completely
368            let dummy_cursor = TextCursor {
369                cluster_id: GraphemeClusterId {
370                    source_run: 0,
371                    start_byte_in_run: 0,
372                },
373                affinity: CursorAffinity::Leading,
374            };
375            let end_cursor = TextCursor {
376                cluster_id: GraphemeClusterId {
377                    source_run: 0,
378                    start_byte_in_run: operation.pre_state.text_content.len() as u32,
379                },
380                affinity: CursorAffinity::Leading,
381            };
382            TextOperation::ReplaceText(TextOpReplaceText {
383                range: SelectionRange {
384                    start: dummy_cursor,
385                    end: end_cursor,
386                },
387                old_text: op.text.clone(), // What's currently there (will be removed)
388                new_text: operation.pre_state.text_content.clone(), // What to restore
389                new_cursor: operation
390                    .pre_state
391                    .cursor_position
392                    .as_ref()
393                    .map(|_| {
394                        CursorPosition::InWindow(azul_core::geom::LogicalPosition::new(0.0, 0.0))
395                    })
396                    .unwrap_or(CursorPosition::Uninitialized),
397            })
398        }
399
400        // DeleteText → InsertText (re-insert what was deleted)
401        TextOperation::DeleteText(op) => {
402            let dummy_cursor = TextCursor {
403                cluster_id: GraphemeClusterId {
404                    source_run: 0,
405                    start_byte_in_run: 0,
406                },
407                affinity: CursorAffinity::Leading,
408            };
409            TextOperation::ReplaceText(TextOpReplaceText {
410                range: SelectionRange {
411                    start: dummy_cursor,
412                    // Empty current content
413                    end: dummy_cursor,
414                },
415                // What's currently there (nothing)
416                old_text: AzString::from(""),
417                // Restore full text
418                new_text: operation.pre_state.text_content.clone(),
419                new_cursor: operation
420                    .pre_state
421                    .cursor_position
422                    .as_ref()
423                    .map(|_| {
424                        CursorPosition::InWindow(azul_core::geom::LogicalPosition::new(0.0, 0.0))
425                    })
426                    .unwrap_or(CursorPosition::Uninitialized),
427            })
428        }
429
430        // ReplaceText → ReplaceText (swap old and new)
431        TextOperation::ReplaceText(op) => {
432            let end_cursor = TextCursor {
433                cluster_id: GraphemeClusterId {
434                    source_run: 0,
435                    start_byte_in_run: op.new_text.len() as u32,
436                },
437                affinity: CursorAffinity::Leading,
438            };
439            TextOperation::ReplaceText(TextOpReplaceText {
440                range: SelectionRange {
441                    start: TextCursor {
442                        cluster_id: GraphemeClusterId {
443                            source_run: 0,
444                            start_byte_in_run: 0,
445                        },
446                        affinity: CursorAffinity::Leading,
447                    },
448                    end: end_cursor,
449                },
450                // What's currently there
451                old_text: op.new_text.clone(),
452                // Restore to pre-state
453                new_text: operation.pre_state.text_content.clone(),
454                new_cursor: operation
455                    .pre_state
456                    .cursor_position
457                    .as_ref()
458                    .map(|_| CursorPosition::InWindow(LogicalPosition::new(0.0, 0.0)))
459                    .unwrap_or(CursorPosition::Uninitialized),
460            })
461        }
462
463        // For non-text-mutating operations, return the inverse
464        TextOperation::SetSelection(op) => TextOperation::SetSelection(TextOpSetSelection {
465            old_range: OptionSelectionRange::Some(op.new_range),
466            new_range: op.old_range.into_option().unwrap_or(op.new_range),
467        }),
468
469        TextOperation::ExtendSelection(op) => TextOperation::SetSelection(TextOpSetSelection {
470            old_range: OptionSelectionRange::Some(op.new_range),
471            new_range: op.old_range,
472        }),
473
474        TextOperation::ClearSelection(op) => TextOperation::SetSelection(TextOpSetSelection {
475            old_range: OptionSelectionRange::None,
476            new_range: op.old_range,
477        }),
478
479        TextOperation::MoveCursor(op) => {
480            TextOperation::MoveCursor(TextOpMoveCursor {
481                old_position: op.new_position,
482                new_position: op.old_position,
483                movement: op.movement, // Keep same movement type
484            })
485        }
486
487        // SelectAll → restore old selection
488        TextOperation::SelectAll(op) => {
489            if let OptionSelectionRange::Some(old_sel) = op.old_range {
490                TextOperation::SetSelection(TextOpSetSelection {
491                    old_range: OptionSelectionRange::Some(op.new_range),
492                    new_range: old_sel,
493                })
494            } else {
495                // If there was no selection, clear it
496                // We use a zero-width selection at start
497                let dummy_cursor = TextCursor {
498                    cluster_id: GraphemeClusterId {
499                        source_run: 0,
500                        start_byte_in_run: 0,
501                    },
502                    affinity: CursorAffinity::Leading,
503                };
504                TextOperation::SetSelection(TextOpSetSelection {
505                    old_range: OptionSelectionRange::Some(op.new_range),
506                    new_range: SelectionRange {
507                        start: dummy_cursor,
508                        end: dummy_cursor,
509                    },
510                })
511            }
512        }
513
514        // Clipboard operations - these don't change text, so no revert needed
515        TextOperation::Copy(_) | TextOperation::Cut(_) | TextOperation::Paste(_) => {
516            // For clipboard operations, we treat them as no-op for revert
517            // The actual text changes are tracked separately
518            operation.changeset.operation.clone()
519        }
520    };
521
522    TextChangeset::new(operation.changeset.target, revert_operation, timestamp)
523}