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    #[must_use = "handle the result; errors indicate the edit was not applied"]
361    pub fn execute(&mut self) -> Result<(), String> {
362        if let Some(ref apply_fn) = self.apply_fn {
363            apply_fn(self.widget_id, &self.operation)?;
364        }
365        self.executed = true;
366        Ok(())
367    }
368
369    /// Undo the command.
370    #[must_use = "handle the result; errors indicate the undo was not applied"]
371    pub fn undo(&mut self) -> Result<(), String> {
372        if let Some(ref undo_fn) = self.undo_fn {
373            undo_fn(self.widget_id, &self.operation)?;
374        }
375        self.executed = false;
376        Ok(())
377    }
378
379    /// Redo the command (same as execute).
380    #[must_use = "handle the result; errors indicate the redo was not applied"]
381    pub fn redo(&mut self) -> Result<(), String> {
382        self.execute()
383    }
384
385    /// Get the description.
386    #[must_use]
387    pub fn description(&self) -> &'static str {
388        self.operation.description()
389    }
390
391    /// Get the size in bytes.
392    #[must_use]
393    pub fn size_bytes(&self) -> usize {
394        std::mem::size_of::<Self>() + self.operation.size_bytes()
395    }
396}
397
398/// Trait for widgets that support undo operations.
399///
400/// Widgets implement this trait to provide undo/redo functionality.
401/// The trait provides a standardized way to:
402/// 1. Track widget identity for command association
403/// 2. Create undo commands for state changes
404/// 3. Restore state from undo/redo operations
405pub trait UndoSupport {
406    /// Get the widget's unique ID for undo tracking.
407    fn undo_widget_id(&self) -> UndoWidgetId;
408
409    /// Create a snapshot of the current state for undo purposes.
410    ///
411    /// This is used to create "before" state for operations.
412    fn create_snapshot(&self) -> Box<dyn Any + Send>;
413
414    /// Restore state from a snapshot.
415    ///
416    /// Returns true if the restore was successful.
417    fn restore_snapshot(&mut self, snapshot: &dyn Any) -> bool;
418}
419
420/// Extension trait for text input widgets with undo support.
421pub trait TextInputUndoExt: UndoSupport {
422    /// Get the current text value.
423    fn text_value(&self) -> &str;
424
425    /// Set the text value directly (for undo/redo).
426    fn set_text_value(&mut self, value: &str);
427
428    /// Get the current cursor position.
429    fn cursor_position(&self) -> usize;
430
431    /// Set the cursor position directly.
432    fn set_cursor_position(&mut self, pos: usize);
433
434    /// Insert text at a position.
435    fn insert_text_at(&mut self, position: usize, text: &str);
436
437    /// Delete text at a range.
438    fn delete_text_range(&mut self, start: usize, end: usize);
439}
440
441/// Extension trait for tree widgets with undo support.
442pub trait TreeUndoExt: UndoSupport {
443    /// Check if a node is expanded.
444    fn is_node_expanded(&self, path: &[usize]) -> bool;
445
446    /// Expand a node.
447    fn expand_node(&mut self, path: &[usize]);
448
449    /// Collapse a node.
450    fn collapse_node(&mut self, path: &[usize]);
451}
452
453/// Extension trait for list widgets with undo support.
454pub trait ListUndoExt: UndoSupport {
455    /// Get the current selection.
456    fn selected_index(&self) -> Option<usize>;
457
458    /// Set the selection.
459    fn set_selected_index(&mut self, index: Option<usize>);
460}
461
462/// Extension trait for table widgets with undo support.
463pub trait TableUndoExt: UndoSupport {
464    /// Get the current sort state.
465    fn sort_state(&self) -> (Option<usize>, bool);
466
467    /// Set the sort state.
468    fn set_sort_state(&mut self, column: Option<usize>, ascending: bool);
469
470    /// Get the current filter.
471    fn filter_text(&self) -> &str;
472
473    /// Set the filter.
474    fn set_filter_text(&mut self, filter: &str);
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn test_undo_widget_id_uniqueness() {
483        let id1 = UndoWidgetId::new();
484        let id2 = UndoWidgetId::new();
485        assert_ne!(id1, id2);
486    }
487
488    #[test]
489    fn test_undo_widget_id_from_raw() {
490        let id = UndoWidgetId::from_raw(42);
491        assert_eq!(id.raw(), 42);
492    }
493
494    #[test]
495    fn test_text_edit_operation_description() {
496        assert_eq!(
497            TextEditOperation::Insert {
498                position: 0,
499                text: "x".to_string()
500            }
501            .description(),
502            "Insert text"
503        );
504        assert_eq!(
505            TextEditOperation::Delete {
506                position: 0,
507                deleted_text: "x".to_string()
508            }
509            .description(),
510            "Delete text"
511        );
512    }
513
514    #[test]
515    fn test_text_edit_operation_size_bytes() {
516        let op = TextEditOperation::Insert {
517            position: 0,
518            text: "hello".to_string(),
519        };
520        assert!(op.size_bytes() > 5);
521    }
522
523    #[test]
524    fn test_widget_text_edit_cmd_creation() {
525        let widget_id = UndoWidgetId::new();
526        let cmd = WidgetTextEditCmd::new(
527            widget_id,
528            TextEditOperation::Insert {
529                position: 0,
530                text: "test".to_string(),
531            },
532        );
533        assert_eq!(cmd.widget_id(), widget_id);
534        assert_eq!(cmd.description(), "Insert text");
535    }
536
537    #[test]
538    fn test_widget_text_edit_cmd_with_callbacks() {
539        use std::sync::Arc;
540        use std::sync::atomic::{AtomicBool, Ordering};
541
542        let applied = Arc::new(AtomicBool::new(false));
543        let undone = Arc::new(AtomicBool::new(false));
544        let applied_clone = applied.clone();
545        let undone_clone = undone.clone();
546
547        let widget_id = UndoWidgetId::new();
548        let mut cmd = WidgetTextEditCmd::new(
549            widget_id,
550            TextEditOperation::Insert {
551                position: 0,
552                text: "test".to_string(),
553            },
554        )
555        .with_apply(move |_, _| {
556            applied_clone.store(true, Ordering::SeqCst);
557            Ok(())
558        })
559        .with_undo(move |_, _| {
560            undone_clone.store(true, Ordering::SeqCst);
561            Ok(())
562        });
563
564        cmd.execute().unwrap();
565        assert!(applied.load(Ordering::SeqCst));
566
567        cmd.undo().unwrap();
568        assert!(undone.load(Ordering::SeqCst));
569    }
570
571    #[test]
572    fn test_tree_operation_description() {
573        assert_eq!(
574            TreeOperation::Expand { path: vec![0] }.description(),
575            "Expand node"
576        );
577        assert_eq!(
578            TreeOperation::Collapse { path: vec![0] }.description(),
579            "Collapse node"
580        );
581    }
582
583    #[test]
584    fn test_list_operation_description() {
585        assert_eq!(
586            ListOperation::Select {
587                old_selection: None,
588                new_selection: Some(0)
589            }
590            .description(),
591            "Change selection"
592        );
593    }
594
595    #[test]
596    fn test_table_operation_description() {
597        assert_eq!(
598            TableOperation::Sort {
599                old_column: None,
600                old_ascending: true,
601                new_column: Some(0),
602                new_ascending: true
603            }
604            .description(),
605            "Change sort"
606        );
607    }
608
609    // --- UndoWidgetId ---
610
611    #[test]
612    fn widget_id_display() {
613        let id = UndoWidgetId::from_raw(7);
614        assert_eq!(format!("{id}"), "Widget(7)");
615    }
616
617    #[test]
618    fn widget_id_default_is_unique() {
619        let a = UndoWidgetId::default();
620        let b = UndoWidgetId::default();
621        assert_ne!(a, b);
622    }
623
624    #[test]
625    fn widget_id_hash_eq() {
626        let id = UndoWidgetId::from_raw(99);
627        let id2 = UndoWidgetId::from_raw(99);
628        assert_eq!(id, id2);
629
630        use std::collections::HashSet;
631        let mut set = HashSet::new();
632        set.insert(id);
633        assert!(set.contains(&id2));
634    }
635
636    // --- TextEditOperation descriptions and size_bytes ---
637
638    #[test]
639    fn text_edit_replace_description() {
640        let op = TextEditOperation::Replace {
641            position: 0,
642            old_text: "old".to_string(),
643            new_text: "new".to_string(),
644        };
645        assert_eq!(op.description(), "Replace text");
646    }
647
648    #[test]
649    fn text_edit_set_value_description() {
650        let op = TextEditOperation::SetValue {
651            old_value: "".to_string(),
652            new_value: "hello".to_string(),
653        };
654        assert_eq!(op.description(), "Set value");
655    }
656
657    #[test]
658    fn text_edit_delete_size_bytes() {
659        let op = TextEditOperation::Delete {
660            position: 5,
661            deleted_text: "abc".to_string(),
662        };
663        assert!(op.size_bytes() >= 3);
664    }
665
666    #[test]
667    fn text_edit_replace_size_bytes() {
668        let op = TextEditOperation::Replace {
669            position: 0,
670            old_text: "aaa".to_string(),
671            new_text: "bbbbb".to_string(),
672        };
673        // Should include both old and new text lengths
674        assert!(op.size_bytes() >= 8); // 3 + 5
675    }
676
677    #[test]
678    fn text_edit_set_value_size_bytes() {
679        let op = TextEditOperation::SetValue {
680            old_value: "x".to_string(),
681            new_value: "yyyy".to_string(),
682        };
683        assert!(op.size_bytes() >= 5); // 1 + 4
684    }
685
686    // --- WidgetTextEditCmd ---
687
688    #[test]
689    fn cmd_execute_without_callbacks_succeeds() {
690        let mut cmd = WidgetTextEditCmd::new(
691            UndoWidgetId::from_raw(1),
692            TextEditOperation::Insert {
693                position: 0,
694                text: "hi".to_string(),
695            },
696        );
697        assert!(cmd.execute().is_ok());
698    }
699
700    #[test]
701    fn cmd_undo_without_callbacks_succeeds() {
702        let mut cmd = WidgetTextEditCmd::new(
703            UndoWidgetId::from_raw(1),
704            TextEditOperation::Delete {
705                position: 0,
706                deleted_text: "x".to_string(),
707            },
708        );
709        assert!(cmd.undo().is_ok());
710    }
711
712    #[test]
713    fn cmd_redo_calls_execute() {
714        use std::sync::Arc;
715        use std::sync::atomic::{AtomicUsize, Ordering};
716
717        let count = Arc::new(AtomicUsize::new(0));
718        let count_clone = count.clone();
719
720        let mut cmd = WidgetTextEditCmd::new(
721            UndoWidgetId::from_raw(1),
722            TextEditOperation::Insert {
723                position: 0,
724                text: "t".to_string(),
725            },
726        )
727        .with_apply(move |_, _| {
728            count_clone.fetch_add(1, Ordering::SeqCst);
729            Ok(())
730        });
731
732        cmd.execute().unwrap();
733        assert_eq!(count.load(Ordering::SeqCst), 1);
734
735        cmd.redo().unwrap();
736        assert_eq!(count.load(Ordering::SeqCst), 2);
737    }
738
739    #[test]
740    fn cmd_debug_format() {
741        let cmd = WidgetTextEditCmd::new(
742            UndoWidgetId::from_raw(5),
743            TextEditOperation::Insert {
744                position: 0,
745                text: "abc".to_string(),
746            },
747        );
748        let dbg = format!("{cmd:?}");
749        assert!(dbg.contains("WidgetTextEditCmd"));
750        assert!(dbg.contains("Insert"));
751    }
752
753    #[test]
754    fn cmd_size_bytes_nonzero() {
755        let cmd = WidgetTextEditCmd::new(
756            UndoWidgetId::from_raw(1),
757            TextEditOperation::Insert {
758                position: 0,
759                text: "hello world".to_string(),
760            },
761        );
762        assert!(cmd.size_bytes() > 11);
763    }
764
765    // --- TreeOperation ---
766
767    #[test]
768    fn tree_toggle_batch_description() {
769        let op = TreeOperation::ToggleBatch {
770            expanded: vec![vec![0, 1]],
771            collapsed: vec![vec![2]],
772        };
773        assert_eq!(op.description(), "Toggle nodes");
774    }
775
776    // --- ListOperation ---
777
778    #[test]
779    fn list_multi_select_description() {
780        let op = ListOperation::MultiSelect {
781            old_selections: vec![0, 1],
782            new_selections: vec![2, 3],
783        };
784        assert_eq!(op.description(), "Change selections");
785    }
786
787    // --- TableOperation ---
788
789    #[test]
790    fn table_filter_description() {
791        let op = TableOperation::Filter {
792            old_filter: "".to_string(),
793            new_filter: "test".to_string(),
794        };
795        assert_eq!(op.description(), "Apply filter");
796    }
797
798    #[test]
799    fn table_select_row_description() {
800        let op = TableOperation::SelectRow {
801            old_row: Some(0),
802            new_row: Some(5),
803        };
804        assert_eq!(op.description(), "Select row");
805    }
806
807    // --- SelectionOperation ---
808
809    #[test]
810    fn selection_operation_fields() {
811        let op = SelectionOperation::Changed {
812            old_anchor: Some(0),
813            old_cursor: 5,
814            new_anchor: None,
815            new_cursor: 10,
816        };
817        let dbg = format!("{op:?}");
818        assert!(dbg.contains("Changed"));
819    }
820}