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::NodeId,
33
    geom::LogicalPosition,
34
    selection::{
35
        CursorAffinity, GraphemeClusterId, OptionSelectionRange, OptionTextCursor, SelectionRange,
36
        TextCursor,
37
    },
38
    task::Instant,
39
    window::CursorPosition,
40
};
41
use azul_css::{impl_option, impl_option_inner, AzString};
42

            
43
use super::changeset::{TextChangeset, TextOperation};
44

            
45
/// Maximum number of undo operations to keep per node
46
pub const MAX_UNDO_HISTORY: usize = 10;
47

            
48
/// Maximum number of redo operations to keep per node
49
pub 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)]
56
pub 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)]
75
pub 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

            
82
impl_option!(
83
    UndoableOperation,
84
    OptionUndoableOperation,
85
    copy = false,
86
    [Debug, Clone]
87
);
88

            
89
/// Per-node undo/redo stack
90
#[derive(Debug, Clone)]
91
pub 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

            
100
impl NodeUndoRedoStack {
101
245
    pub fn new(node_id: NodeId) -> Self {
102
245
        Self {
103
245
            node_id,
104
245
            undo_stack: VecDeque::with_capacity(MAX_UNDO_HISTORY),
105
245
            redo_stack: VecDeque::with_capacity(MAX_REDO_HISTORY),
106
245
        }
107
245
    }
108

            
109
    /// Push a new operation to the undo stack
110
455
    pub fn push_undo(&mut self, operation: UndoableOperation) {
111
        // Clear redo stack when new operation is performed
112
455
        self.redo_stack.clear();
113

            
114
        // Add to undo stack
115
455
        self.undo_stack.push_back(operation);
116

            
117
        // Limit stack size
118
455
        if self.undo_stack.len() > MAX_UNDO_HISTORY {
119
            self.undo_stack.pop_front();
120
455
        }
121
455
    }
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
    pub fn can_undo(&self) -> bool {
145
        !self.undo_stack.is_empty()
146
    }
147

            
148
    /// Check if redo is available
149
    pub fn can_redo(&self) -> bool {
150
        !self.redo_stack.is_empty()
151
    }
152

            
153
    /// Peek at the most recent undo operation without removing it
154
    pub fn peek_undo(&self) -> Option<&UndoableOperation> {
155
        self.undo_stack.back()
156
    }
157

            
158
    /// Peek at the most recent redo operation without removing it
159
    pub fn peek_redo(&self) -> Option<&UndoableOperation> {
160
        self.redo_stack.back()
161
    }
162
}
163

            
164
/// Manager for undo/redo operations across all text nodes
165
#[derive(Debug, Clone, Default)]
166
pub struct UndoRedoManager {
167
    /// Per-node undo/redo stacks
168
    /// Using Vec instead of HashMap for no_std compatibility
169
    pub node_stacks: Vec<NodeUndoRedoStack>,
170
}
171

            
172
impl UndoRedoManager {
173
    /// Create a new empty undo/redo manager
174
2321
    pub fn new() -> Self {
175
2321
        Self {
176
2321
            node_stacks: Vec::new(),
177
2321
        }
178
2321
    }
179

            
180
    /// Get or create a stack for a specific node
181
455
    pub fn get_or_create_stack_mut(&mut self, node_id: NodeId) -> &mut NodeUndoRedoStack {
182
455
        if let Some(pos) = self.node_stacks.iter().position(|s| s.node_id == node_id) {
183
210
            &mut self.node_stacks[pos]
184
        } else {
185
245
            self.node_stacks.push(NodeUndoRedoStack::new(node_id));
186
245
            self.node_stacks.last_mut().unwrap()
187
        }
188
455
    }
189

            
190
    /// Get a stack for a specific node (immutable)
191
    pub fn get_stack(&self, node_id: NodeId) -> Option<&NodeUndoRedoStack> {
192
        self.node_stacks.iter().find(|s| s.node_id == node_id)
193
    }
194

            
195
    /// Get a stack for a specific node (mutable)
196
    fn get_stack_mut(&mut self, node_id: NodeId) -> Option<&mut NodeUndoRedoStack> {
197
        self.node_stacks.iter_mut().find(|s| s.node_id == node_id)
198
    }
199

            
200
    /// Record a text operation (push to undo stack)
201
    ///
202
    /// This should be called AFTER a changeset has been successfully applied.
203
    /// The pre_state should contain the node state BEFORE the changeset was applied.
204
    ///
205
    /// ## Arguments
206
    /// * `changeset` - The changeset that was applied
207
    /// * `pre_state` - Node state before the changeset
208
455
    pub fn record_operation(&mut self, changeset: TextChangeset, pre_state: NodeStateSnapshot) {
209
        // Convert DomNodeId to NodeId for indexing
210
        // NodeHierarchyItemId.into_crate_internal() decodes the 1-based encoding to Option<NodeId>
211
455
        let node_id = changeset
212
455
            .target
213
455
            .node
214
455
            .into_crate_internal()
215
455
            .expect("TextChangeset target node should not be None");
216
455
        let stack = self.get_or_create_stack_mut(node_id);
217

            
218
455
        let operation = UndoableOperation {
219
455
            changeset,
220
455
            pre_state,
221
455
        };
222

            
223
455
        stack.push_undo(operation);
224
455
    }
225

            
226
    /// Check if undo is available for a node
227
    pub fn can_undo(&self, node_id: NodeId) -> bool {
228
        self.get_stack(node_id)
229
            .map(|s| s.can_undo())
230
            .unwrap_or(false)
231
    }
232

            
233
    /// Check if redo is available for a node
234
    pub fn can_redo(&self, node_id: NodeId) -> bool {
235
        self.get_stack(node_id)
236
            .map(|s| s.can_redo())
237
            .unwrap_or(false)
238
    }
239

            
240
    /// Peek at the next undo operation for a node (without removing it)
241
    ///
242
    /// This allows user callbacks to inspect what would be undone.
243
    pub fn peek_undo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
244
        self.get_stack(node_id).and_then(|s| s.peek_undo())
245
    }
246

            
247
    /// Peek at the next redo operation for a node (without removing it)
248
    ///
249
    /// This allows user callbacks to inspect what would be redone.
250
    pub fn peek_redo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
251
        self.get_stack(node_id).and_then(|s| s.peek_redo())
252
    }
253

            
254
    /// Pop an operation from the undo stack
255
    ///
256
    /// This should be called during undo processing to get the operation to revert.
257
    /// After reverting, the operation should be pushed to the redo stack.
258
    ///
259
    /// ## Returns
260
    /// * `Some(operation)` - The operation to undo
261
    /// * `None` - No undo history available
262
    pub fn pop_undo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
263
        self.get_stack_mut(node_id)?.pop_undo()
264
    }
265

            
266
    /// Pop an operation from the redo stack
267
    ///
268
    /// This should be called during redo processing to get the operation to re-apply.
269
    /// After re-applying, the operation should be pushed to the undo stack.
270
    ///
271
    /// ## Returns
272
    /// * `Some(operation)` - The operation to redo
273
    /// * `None` - No redo history available
274
    pub fn pop_redo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
275
        self.get_stack_mut(node_id)?.pop_redo()
276
    }
277

            
278
    /// Push an operation to the redo stack (after successful undo)
279
    ///
280
    /// This should be called AFTER an undo operation has been successfully applied.
281
    pub fn push_redo(&mut self, operation: UndoableOperation) {
282
        let node_id = operation
283
            .changeset
284
            .target
285
            .node
286
            .into_crate_internal()
287
            .expect("TextChangeset target node should not be None");
288
        let stack = self.get_or_create_stack_mut(node_id);
289
        stack.push_redo(operation);
290
    }
291

            
292
    /// Push an operation to the undo stack (after successful redo)
293
    ///
294
    /// This should be called AFTER a redo operation has been successfully applied.
295
    pub fn push_undo(&mut self, operation: UndoableOperation) {
296
        let node_id = operation
297
            .changeset
298
            .target
299
            .node
300
            .into_crate_internal()
301
            .expect("TextChangeset target node should not be None");
302
        let stack = self.get_or_create_stack_mut(node_id);
303
        stack.push_undo(operation);
304
    }
305

            
306
    /// Clear all undo/redo history for a specific node
307
    pub fn clear_node(&mut self, node_id: NodeId) {
308
        if let Some(stack) = self.get_stack_mut(node_id) {
309
            stack.undo_stack.clear();
310
            stack.redo_stack.clear();
311
        }
312
    }
313

            
314
    /// Clear all undo/redo history for all nodes
315
    pub fn clear_all(&mut self) {
316
        self.node_stacks.clear();
317
    }
318

            
319
    /// Get the total number of operations in undo stack for a node
320
    pub fn undo_depth(&self, node_id: NodeId) -> usize {
321
        self.get_stack(node_id)
322
            .map(|s| s.undo_stack.len())
323
            .unwrap_or(0)
324
    }
325

            
326
    /// Get the total number of operations in redo stack for a node
327
    pub fn redo_depth(&self, node_id: NodeId) -> usize {
328
        self.get_stack(node_id)
329
            .map(|s| s.redo_stack.len())
330
            .unwrap_or(0)
331
    }
332
}
333

            
334
/// Helper function to create a revert changeset from an undoable operation.
335
///
336
/// This analyzes the changeset and creates the inverse operation that will
337
/// restore the pre_state.
338
///
339
/// ## Arguments
340
///
341
/// * `operation` - The operation to create a revert for
342
/// * `timestamp` - Current time for the revert changeset
343
///
344
/// Returns: `TextChangeset` - The changeset that reverts the operation
345
pub fn create_revert_changeset(operation: &UndoableOperation, timestamp: Instant) -> TextChangeset {
346
    use crate::managers::changeset::{
347
        TextOpClearSelection, TextOpCopy, TextOpCut, TextOpDeleteText, TextOpExtendSelection,
348
        TextOpInsertText, TextOpMoveCursor, TextOpPaste, TextOpReplaceText, TextOpSelectAll,
349
        TextOpSetSelection,
350
    };
351

            
352
    // Create the inverse operation based on what was done
353
    let revert_operation = match &operation.changeset.operation {
354
        // InsertText → DeleteText (remove what was inserted)
355
        TextOperation::InsertText(op) => {
356
            // To revert an insert, we need to delete the inserted text
357
            // The range is from old position to new position
358
            // For now, we use a simplified approach - restore the old text completely
359
            let dummy_cursor = TextCursor {
360
                cluster_id: GraphemeClusterId {
361
                    source_run: 0,
362
                    start_byte_in_run: 0,
363
                },
364
                affinity: CursorAffinity::Leading,
365
            };
366
            let end_cursor = TextCursor {
367
                cluster_id: GraphemeClusterId {
368
                    source_run: 0,
369
                    start_byte_in_run: operation.pre_state.text_content.len() as u32,
370
                },
371
                affinity: CursorAffinity::Leading,
372
            };
373
            TextOperation::ReplaceText(TextOpReplaceText {
374
                range: SelectionRange {
375
                    start: dummy_cursor,
376
                    end: end_cursor,
377
                },
378
                old_text: op.text.clone(), // What's currently there (will be removed)
379
                new_text: operation.pre_state.text_content.clone(), // What to restore
380
                new_cursor: operation
381
                    .pre_state
382
                    .cursor_position
383
                    .as_ref()
384
                    .map(|_| {
385
                        CursorPosition::InWindow(azul_core::geom::LogicalPosition::new(0.0, 0.0))
386
                    })
387
                    .unwrap_or(CursorPosition::Uninitialized),
388
            })
389
        }
390

            
391
        // DeleteText → InsertText (re-insert what was deleted)
392
        TextOperation::DeleteText(op) => {
393
            let dummy_cursor = TextCursor {
394
                cluster_id: GraphemeClusterId {
395
                    source_run: 0,
396
                    start_byte_in_run: 0,
397
                },
398
                affinity: CursorAffinity::Leading,
399
            };
400
            TextOperation::ReplaceText(TextOpReplaceText {
401
                range: SelectionRange {
402
                    start: dummy_cursor,
403
                    // Empty current content
404
                    end: dummy_cursor,
405
                },
406
                // What's currently there (nothing)
407
                old_text: AzString::from(""),
408
                // Restore full text
409
                new_text: operation.pre_state.text_content.clone(),
410
                new_cursor: operation
411
                    .pre_state
412
                    .cursor_position
413
                    .as_ref()
414
                    .map(|_| {
415
                        CursorPosition::InWindow(azul_core::geom::LogicalPosition::new(0.0, 0.0))
416
                    })
417
                    .unwrap_or(CursorPosition::Uninitialized),
418
            })
419
        }
420

            
421
        // ReplaceText → ReplaceText (swap old and new)
422
        TextOperation::ReplaceText(op) => {
423
            let end_cursor = TextCursor {
424
                cluster_id: GraphemeClusterId {
425
                    source_run: 0,
426
                    start_byte_in_run: op.new_text.len() as u32,
427
                },
428
                affinity: CursorAffinity::Leading,
429
            };
430
            TextOperation::ReplaceText(TextOpReplaceText {
431
                range: SelectionRange {
432
                    start: TextCursor {
433
                        cluster_id: GraphemeClusterId {
434
                            source_run: 0,
435
                            start_byte_in_run: 0,
436
                        },
437
                        affinity: CursorAffinity::Leading,
438
                    },
439
                    end: end_cursor,
440
                },
441
                // What's currently there
442
                old_text: op.new_text.clone(),
443
                // Restore to pre-state
444
                new_text: operation.pre_state.text_content.clone(),
445
                new_cursor: operation
446
                    .pre_state
447
                    .cursor_position
448
                    .as_ref()
449
                    .map(|_| CursorPosition::InWindow(LogicalPosition::new(0.0, 0.0)))
450
                    .unwrap_or(CursorPosition::Uninitialized),
451
            })
452
        }
453

            
454
        // For non-text-mutating operations, return the inverse
455
        TextOperation::SetSelection(op) => TextOperation::SetSelection(TextOpSetSelection {
456
            old_range: OptionSelectionRange::Some(op.new_range),
457
            new_range: op.old_range.into_option().unwrap_or(op.new_range),
458
        }),
459

            
460
        TextOperation::ExtendSelection(op) => TextOperation::SetSelection(TextOpSetSelection {
461
            old_range: OptionSelectionRange::Some(op.new_range),
462
            new_range: op.old_range,
463
        }),
464

            
465
        TextOperation::ClearSelection(op) => TextOperation::SetSelection(TextOpSetSelection {
466
            old_range: OptionSelectionRange::None,
467
            new_range: op.old_range,
468
        }),
469

            
470
        TextOperation::MoveCursor(op) => {
471
            TextOperation::MoveCursor(TextOpMoveCursor {
472
                old_position: op.new_position,
473
                new_position: op.old_position,
474
                movement: op.movement, // Keep same movement type
475
            })
476
        }
477

            
478
        // SelectAll → restore old selection
479
        TextOperation::SelectAll(op) => {
480
            if let OptionSelectionRange::Some(old_sel) = op.old_range {
481
                TextOperation::SetSelection(TextOpSetSelection {
482
                    old_range: OptionSelectionRange::Some(op.new_range),
483
                    new_range: old_sel,
484
                })
485
            } else {
486
                // If there was no selection, clear it
487
                // We use a zero-width selection at start
488
                let dummy_cursor = TextCursor {
489
                    cluster_id: GraphemeClusterId {
490
                        source_run: 0,
491
                        start_byte_in_run: 0,
492
                    },
493
                    affinity: CursorAffinity::Leading,
494
                };
495
                TextOperation::SetSelection(TextOpSetSelection {
496
                    old_range: OptionSelectionRange::Some(op.new_range),
497
                    new_range: SelectionRange {
498
                        start: dummy_cursor,
499
                        end: dummy_cursor,
500
                    },
501
                })
502
            }
503
        }
504

            
505
        // Clipboard operations - these don't change text, so no revert needed
506
        TextOperation::Copy(_) | TextOperation::Cut(_) | TextOperation::Paste(_) => {
507
            // For clipboard operations, we treat them as no-op for revert
508
            // The actual text changes are tracked separately
509
            operation.changeset.operation.clone()
510
        }
511
    };
512

            
513
    TextChangeset::new(operation.changeset.target, revert_operation, timestamp)
514
}