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}