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}