Skip to main content

ftui_widgets/
undo_support.rs

1#![forbid(unsafe_code)]
2
3//! Undo support for widgets.
4//!
5//! This module provides the [`UndoSupport`] trait that widgets can implement
6//! to enable undo/redo functionality for their state changes.
7//!
8//! # Design
9//!
10//! The undo system is based on the Command Pattern. Each undoable operation
11//! creates a command that knows how to:
12//! 1. Execute the operation (already done when the command is created)
13//! 2. Undo the operation (reverse the change)
14//! 3. Redo the operation (reapply the change)
15//!
16//! Commands are stored in a history stack managed by [`HistoryManager`].
17//!
18//! # Usage
19//!
20//! Widgets that implement `UndoSupport` can generate commands for their
21//! state changes. These commands can then be pushed to a history manager
22//! for undo/redo support.
23//!
24//! ```ignore
25//! use ftui_widgets::undo_support::{UndoSupport, TextEditOperation};
26//! use ftui_runtime::undo::HistoryManager;
27//!
28//! let mut history = HistoryManager::default();
29//! let mut input = TextInput::new();
30//!
31//! // Perform an edit and create an undo command
32//! if let Some(cmd) = input.create_undo_command(TextEditOperation::Insert {
33//!     position: 0,
34//!     text: "Hello".to_string(),
35//! }) {
36//!     history.push(cmd);
37//! }
38//! ```
39//!
40//! [`HistoryManager`]: ftui_runtime::undo::HistoryManager
41
42use std::any::Any;
43use std::fmt;
44use std::sync::atomic::{AtomicU64, Ordering};
45
46/// Unique identifier for a widget instance.
47///
48/// Used to associate undo commands with specific widgets.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub struct UndoWidgetId(u64);
51
52impl UndoWidgetId {
53    /// Create a new unique widget ID.
54    pub fn new() -> Self {
55        static COUNTER: AtomicU64 = AtomicU64::new(1);
56        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
57    }
58
59    /// Create a widget ID from a raw value.
60    ///
61    /// Use this when you need to associate commands with a specific widget.
62    #[must_use]
63    pub const fn from_raw(id: u64) -> Self {
64        Self(id)
65    }
66
67    /// Get the raw value.
68    #[must_use]
69    pub const fn raw(self) -> u64 {
70        self.0
71    }
72}
73
74impl Default for UndoWidgetId {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl fmt::Display for UndoWidgetId {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(f, "Widget({})", self.0)
83    }
84}
85
86/// Text edit operation types.
87///
88/// These represent the atomic operations that can be performed on text.
89#[derive(Debug, Clone)]
90pub enum TextEditOperation {
91    /// Insert text at a position.
92    Insert {
93        /// Grapheme index where text was inserted.
94        position: usize,
95        /// The inserted text.
96        text: String,
97    },
98    /// Delete text at a position.
99    Delete {
100        /// Grapheme index where deletion started.
101        position: usize,
102        /// The deleted text (for undo).
103        deleted_text: String,
104    },
105    /// Replace text at a position.
106    Replace {
107        /// Grapheme index where replacement started.
108        position: usize,
109        /// The old text (for undo).
110        old_text: String,
111        /// The new text.
112        new_text: String,
113    },
114    /// Set the entire value.
115    SetValue {
116        /// The old value (for undo).
117        old_value: String,
118        /// The new value.
119        new_value: String,
120    },
121}
122
123impl TextEditOperation {
124    /// Get a description of this operation.
125    #[must_use]
126    pub fn description(&self) -> &'static str {
127        match self {
128            Self::Insert { .. } => "Insert text",
129            Self::Delete { .. } => "Delete text",
130            Self::Replace { .. } => "Replace text",
131            Self::SetValue { .. } => "Set value",
132        }
133    }
134
135    /// Calculate the size in bytes of this operation.
136    #[must_use]
137    pub fn size_bytes(&self) -> usize {
138        std::mem::size_of::<Self>()
139            + match self {
140                Self::Insert { text, .. } => text.len(),
141                Self::Delete { deleted_text, .. } => deleted_text.len(),
142                Self::Replace {
143                    old_text, new_text, ..
144                } => old_text.len() + new_text.len(),
145                Self::SetValue {
146                    old_value,
147                    new_value,
148                } => old_value.len() + new_value.len(),
149            }
150    }
151}
152
153/// Selection state operation types.
154#[derive(Debug, Clone)]
155pub enum SelectionOperation {
156    /// Selection changed.
157    Changed {
158        /// Old selection anchor.
159        old_anchor: Option<usize>,
160        /// Old cursor position.
161        old_cursor: usize,
162        /// New selection anchor.
163        new_anchor: Option<usize>,
164        /// New cursor position.
165        new_cursor: usize,
166    },
167}
168
169/// Tree expansion operation types.
170#[derive(Debug, Clone)]
171pub enum TreeOperation {
172    /// Node expanded.
173    Expand {
174        /// Path to the node (indices).
175        path: Vec<usize>,
176    },
177    /// Node collapsed.
178    Collapse {
179        /// Path to the node (indices).
180        path: Vec<usize>,
181    },
182    /// Multiple nodes toggled.
183    ToggleBatch {
184        /// Paths that were expanded.
185        expanded: Vec<Vec<usize>>,
186        /// Paths that were collapsed.
187        collapsed: Vec<Vec<usize>>,
188    },
189}
190
191impl TreeOperation {
192    /// Get a description of this operation.
193    #[must_use]
194    pub fn description(&self) -> &'static str {
195        match self {
196            Self::Expand { .. } => "Expand node",
197            Self::Collapse { .. } => "Collapse node",
198            Self::ToggleBatch { .. } => "Toggle nodes",
199        }
200    }
201}
202
203/// List selection operation types.
204#[derive(Debug, Clone)]
205pub enum ListOperation {
206    /// Selection changed.
207    Select {
208        /// Old selection.
209        old_selection: Option<usize>,
210        /// New selection.
211        new_selection: Option<usize>,
212    },
213    /// Multiple selection changed.
214    MultiSelect {
215        /// Old selections.
216        old_selections: Vec<usize>,
217        /// New selections.
218        new_selections: Vec<usize>,
219    },
220}
221
222impl ListOperation {
223    /// Get a description of this operation.
224    #[must_use]
225    pub fn description(&self) -> &'static str {
226        match self {
227            Self::Select { .. } => "Change selection",
228            Self::MultiSelect { .. } => "Change selections",
229        }
230    }
231}
232
233/// Table operation types.
234#[derive(Debug, Clone)]
235pub enum TableOperation {
236    /// Sort column changed.
237    Sort {
238        /// Old sort column.
239        old_column: Option<usize>,
240        /// Old sort ascending.
241        old_ascending: bool,
242        /// New sort column.
243        new_column: Option<usize>,
244        /// New sort ascending.
245        new_ascending: bool,
246    },
247    /// Filter applied.
248    Filter {
249        /// Old filter string.
250        old_filter: String,
251        /// New filter string.
252        new_filter: String,
253    },
254    /// Row selection changed.
255    SelectRow {
256        /// Old selected row.
257        old_row: Option<usize>,
258        /// New selected row.
259        new_row: Option<usize>,
260    },
261}
262
263impl TableOperation {
264    /// Get a description of this operation.
265    #[must_use]
266    pub fn description(&self) -> &'static str {
267        match self {
268            Self::Sort { .. } => "Change sort",
269            Self::Filter { .. } => "Apply filter",
270            Self::SelectRow { .. } => "Select row",
271        }
272    }
273}
274
275/// Callback for applying a text edit operation.
276pub type TextEditApplyFn =
277    Box<dyn Fn(UndoWidgetId, &TextEditOperation) -> Result<(), String> + Send + Sync>;
278
279/// Callback for undoing a text edit operation.
280pub type TextEditUndoFn =
281    Box<dyn Fn(UndoWidgetId, &TextEditOperation) -> Result<(), String> + Send + Sync>;
282
283/// A widget undo command for text editing.
284pub struct WidgetTextEditCmd {
285    /// Widget ID this command operates on.
286    widget_id: UndoWidgetId,
287    /// The operation.
288    operation: TextEditOperation,
289    /// Apply callback.
290    apply_fn: Option<TextEditApplyFn>,
291    /// Undo callback.
292    undo_fn: Option<TextEditUndoFn>,
293    /// Whether the operation has been executed.
294    executed: bool,
295}
296
297impl WidgetTextEditCmd {
298    /// Create a new text edit command.
299    #[must_use]
300    pub fn new(widget_id: UndoWidgetId, operation: TextEditOperation) -> Self {
301        Self {
302            widget_id,
303            operation,
304            apply_fn: None,
305            undo_fn: None,
306            executed: false,
307        }
308    }
309
310    /// Set the apply callback (builder).
311    #[must_use]
312    pub fn with_apply<F>(mut self, f: F) -> Self
313    where
314        F: Fn(UndoWidgetId, &TextEditOperation) -> Result<(), String> + Send + Sync + 'static,
315    {
316        self.apply_fn = Some(Box::new(f));
317        self
318    }
319
320    /// Set the undo callback (builder).
321    #[must_use]
322    pub fn with_undo<F>(mut self, f: F) -> Self
323    where
324        F: Fn(UndoWidgetId, &TextEditOperation) -> Result<(), String> + Send + Sync + 'static,
325    {
326        self.undo_fn = Some(Box::new(f));
327        self
328    }
329
330    /// Get the widget ID.
331    #[must_use]
332    pub fn widget_id(&self) -> UndoWidgetId {
333        self.widget_id
334    }
335
336    /// Get the operation.
337    #[must_use]
338    pub fn operation(&self) -> &TextEditOperation {
339        &self.operation
340    }
341}
342
343impl fmt::Debug for WidgetTextEditCmd {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        f.debug_struct("WidgetTextEditCmd")
346            .field("widget_id", &self.widget_id)
347            .field("operation", &self.operation)
348            .field("executed", &self.executed)
349            .finish()
350    }
351}
352
353// Implement UndoableCmd trait from ftui_runtime
354// Note: We can't directly implement the trait here because it's in ftui_runtime
355// and we can't have a circular dependency. Instead, we provide methods that
356// match the trait's interface, and the integration happens at runtime.
357
358impl WidgetTextEditCmd {
359    /// Execute the command.
360    pub fn execute(&mut self) -> Result<(), String> {
361        if let Some(ref apply_fn) = self.apply_fn {
362            apply_fn(self.widget_id, &self.operation)?;
363        }
364        self.executed = true;
365        Ok(())
366    }
367
368    /// Undo the command.
369    pub fn undo(&mut self) -> Result<(), String> {
370        if let Some(ref undo_fn) = self.undo_fn {
371            undo_fn(self.widget_id, &self.operation)?;
372        }
373        self.executed = false;
374        Ok(())
375    }
376
377    /// Redo the command (same as execute).
378    pub fn redo(&mut self) -> Result<(), String> {
379        self.execute()
380    }
381
382    /// Get the description.
383    #[must_use]
384    pub fn description(&self) -> &'static str {
385        self.operation.description()
386    }
387
388    /// Get the size in bytes.
389    #[must_use]
390    pub fn size_bytes(&self) -> usize {
391        std::mem::size_of::<Self>() + self.operation.size_bytes()
392    }
393}
394
395/// Trait for widgets that support undo operations.
396///
397/// Widgets implement this trait to provide undo/redo functionality.
398/// The trait provides a standardized way to:
399/// 1. Track widget identity for command association
400/// 2. Create undo commands for state changes
401/// 3. Restore state from undo/redo operations
402pub trait UndoSupport {
403    /// Get the widget's unique ID for undo tracking.
404    fn undo_widget_id(&self) -> UndoWidgetId;
405
406    /// Create a snapshot of the current state for undo purposes.
407    ///
408    /// This is used to create "before" state for operations.
409    fn create_snapshot(&self) -> Box<dyn Any + Send>;
410
411    /// Restore state from a snapshot.
412    ///
413    /// Returns true if the restore was successful.
414    fn restore_snapshot(&mut self, snapshot: &dyn Any) -> bool;
415}
416
417/// Extension trait for text input widgets with undo support.
418pub trait TextInputUndoExt: UndoSupport {
419    /// Get the current text value.
420    fn text_value(&self) -> &str;
421
422    /// Set the text value directly (for undo/redo).
423    fn set_text_value(&mut self, value: &str);
424
425    /// Get the current cursor position.
426    fn cursor_position(&self) -> usize;
427
428    /// Set the cursor position directly.
429    fn set_cursor_position(&mut self, pos: usize);
430
431    /// Insert text at a position.
432    fn insert_text_at(&mut self, position: usize, text: &str);
433
434    /// Delete text at a range.
435    fn delete_text_range(&mut self, start: usize, end: usize);
436}
437
438/// Extension trait for tree widgets with undo support.
439pub trait TreeUndoExt: UndoSupport {
440    /// Check if a node is expanded.
441    fn is_node_expanded(&self, path: &[usize]) -> bool;
442
443    /// Expand a node.
444    fn expand_node(&mut self, path: &[usize]);
445
446    /// Collapse a node.
447    fn collapse_node(&mut self, path: &[usize]);
448}
449
450/// Extension trait for list widgets with undo support.
451pub trait ListUndoExt: UndoSupport {
452    /// Get the current selection.
453    fn selected_index(&self) -> Option<usize>;
454
455    /// Set the selection.
456    fn set_selected_index(&mut self, index: Option<usize>);
457}
458
459/// Extension trait for table widgets with undo support.
460pub trait TableUndoExt: UndoSupport {
461    /// Get the current sort state.
462    fn sort_state(&self) -> (Option<usize>, bool);
463
464    /// Set the sort state.
465    fn set_sort_state(&mut self, column: Option<usize>, ascending: bool);
466
467    /// Get the current filter.
468    fn filter_text(&self) -> &str;
469
470    /// Set the filter.
471    fn set_filter_text(&mut self, filter: &str);
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_undo_widget_id_uniqueness() {
480        let id1 = UndoWidgetId::new();
481        let id2 = UndoWidgetId::new();
482        assert_ne!(id1, id2);
483    }
484
485    #[test]
486    fn test_undo_widget_id_from_raw() {
487        let id = UndoWidgetId::from_raw(42);
488        assert_eq!(id.raw(), 42);
489    }
490
491    #[test]
492    fn test_text_edit_operation_description() {
493        assert_eq!(
494            TextEditOperation::Insert {
495                position: 0,
496                text: "x".to_string()
497            }
498            .description(),
499            "Insert text"
500        );
501        assert_eq!(
502            TextEditOperation::Delete {
503                position: 0,
504                deleted_text: "x".to_string()
505            }
506            .description(),
507            "Delete text"
508        );
509    }
510
511    #[test]
512    fn test_text_edit_operation_size_bytes() {
513        let op = TextEditOperation::Insert {
514            position: 0,
515            text: "hello".to_string(),
516        };
517        assert!(op.size_bytes() > 5);
518    }
519
520    #[test]
521    fn test_widget_text_edit_cmd_creation() {
522        let widget_id = UndoWidgetId::new();
523        let cmd = WidgetTextEditCmd::new(
524            widget_id,
525            TextEditOperation::Insert {
526                position: 0,
527                text: "test".to_string(),
528            },
529        );
530        assert_eq!(cmd.widget_id(), widget_id);
531        assert_eq!(cmd.description(), "Insert text");
532    }
533
534    #[test]
535    fn test_widget_text_edit_cmd_with_callbacks() {
536        use std::sync::Arc;
537        use std::sync::atomic::{AtomicBool, Ordering};
538
539        let applied = Arc::new(AtomicBool::new(false));
540        let undone = Arc::new(AtomicBool::new(false));
541        let applied_clone = applied.clone();
542        let undone_clone = undone.clone();
543
544        let widget_id = UndoWidgetId::new();
545        let mut cmd = WidgetTextEditCmd::new(
546            widget_id,
547            TextEditOperation::Insert {
548                position: 0,
549                text: "test".to_string(),
550            },
551        )
552        .with_apply(move |_, _| {
553            applied_clone.store(true, Ordering::SeqCst);
554            Ok(())
555        })
556        .with_undo(move |_, _| {
557            undone_clone.store(true, Ordering::SeqCst);
558            Ok(())
559        });
560
561        cmd.execute().unwrap();
562        assert!(applied.load(Ordering::SeqCst));
563
564        cmd.undo().unwrap();
565        assert!(undone.load(Ordering::SeqCst));
566    }
567
568    #[test]
569    fn test_tree_operation_description() {
570        assert_eq!(
571            TreeOperation::Expand { path: vec![0] }.description(),
572            "Expand node"
573        );
574        assert_eq!(
575            TreeOperation::Collapse { path: vec![0] }.description(),
576            "Collapse node"
577        );
578    }
579
580    #[test]
581    fn test_list_operation_description() {
582        assert_eq!(
583            ListOperation::Select {
584                old_selection: None,
585                new_selection: Some(0)
586            }
587            .description(),
588            "Change selection"
589        );
590    }
591
592    #[test]
593    fn test_table_operation_description() {
594        assert_eq!(
595            TableOperation::Sort {
596                old_column: None,
597                old_ascending: true,
598                new_column: Some(0),
599                new_ascending: true
600            }
601            .description(),
602            "Change sort"
603        );
604    }
605}