Skip to main content

tui_canvas/editor/features/
history.rs

1// src/editor/features/history.rs
2//! Snapshot-based undo/redo history for [`EditorCore`].
3//!
4//! See `analysis/UNDO_REDO_DESIGN.md` for the rationale. In short: each
5//! checkpoint stores the full editable content (via
6//! [`DataProvider::capture_content`]) plus the caret, which is robust against
7//! the mask/validation/rope edit paths that an inverse-op log would have to
8//! track. Consecutive same-kind edits coalesce into one undo step.
9
10use crate::DataProvider;
11use crate::canvas::state::SelectionState;
12use crate::editor::EditorCore;
13
14/// Default number of undo steps retained before the oldest is dropped.
15pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 100;
16
17/// The kind of an edit, used to coalesce consecutive same-kind edits into a
18/// single undo step (e.g. a run of typed characters).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub(crate) enum EditKind {
21    Insert,
22    Delete,
23    /// Structural / bulk edits that never coalesce (paste, set-field, clear).
24    Other,
25}
26
27impl EditKind {
28    fn coalesces(self) -> bool {
29        matches!(self, EditKind::Insert | EditKind::Delete)
30    }
31}
32
33/// A point-in-time snapshot of the editable state.
34#[derive(Debug, Clone)]
35pub(crate) struct EditSnapshot {
36    content: Vec<String>,
37    current_field: usize,
38    cursor_pos: usize,
39}
40
41impl<D: DataProvider> EditorCore<D> {
42    fn snapshot_now(&self) -> EditSnapshot {
43        EditSnapshot {
44            content: self.data_provider.capture_content(),
45            current_field: self.ui_state.current_field,
46            cursor_pos: self.ui_state.cursor_pos,
47        }
48    }
49
50    /// Record a pre-mutation checkpoint. Call this immediately *before* applying
51    /// a content change so the snapshot captures the state to return to.
52    ///
53    /// Consecutive edits of the same coalescible kind extend the current run
54    /// instead of pushing a new undo step.
55    pub(crate) fn record_checkpoint(&mut self, kind: EditKind) {
56        if !self.history_enabled {
57            return;
58        }
59
60        // Continue an in-progress run of the same kind: the run's undo target
61        // was already captured by the first checkpoint in the run.
62        if kind.coalesces() && self.history_last_kind == Some(kind) {
63            return;
64        }
65
66        let snapshot = self.snapshot_now();
67        self.undo_stack.push(snapshot);
68        if self.undo_stack.len() > self.history_limit {
69            self.undo_stack.remove(0);
70        }
71        self.redo_stack.clear();
72        self.history_last_kind = Some(kind);
73    }
74
75    /// End the current coalescing run so the next edit starts a fresh undo step.
76    /// Called on navigation / mode changes.
77    pub(crate) fn break_undo_coalescing(&mut self) {
78        self.history_last_kind = None;
79    }
80
81    fn apply_snapshot(&mut self, snapshot: EditSnapshot) {
82        self.data_provider.restore_content(&snapshot.content);
83
84        let field_count = self.data_provider.field_count();
85        self.ui_state.current_field = if field_count == 0 {
86            0
87        } else {
88            snapshot.current_field.min(field_count - 1)
89        };
90
91        let len = self.current_text().chars().count();
92        self.set_cursor_raw(snapshot.cursor_pos.min(len));
93        self.ui_state.selection = SelectionState::None;
94
95        self.after_history_restore();
96    }
97
98    /// Re-sync derived state after a restore.
99    ///
100    /// Validation results are recomputed from the restored content and the
101    /// suggestions dropdown is dismissed. Computed fields are *not* recomputed
102    /// here: the editor does not retain the user's `ComputedProvider`, so a host
103    /// using computed fields should call its recompute path after `undo`/`redo`.
104    fn after_history_restore(&mut self) {
105        #[cfg(feature = "validation")]
106        {
107            let count = self.data_provider.field_count();
108            for i in 0..count {
109                let text = self.data_provider.field_value(i).to_string();
110                let _ = self.ui_state.validation.validate_field_content(i, &text);
111            }
112        }
113        #[cfg(feature = "suggestions")]
114        self.ui_state.close_suggestions();
115    }
116
117    /// Undo the most recent edit (or run). Returns `false` if there is nothing
118    /// to undo.
119    pub fn undo(&mut self) -> bool {
120        if let Some(previous) = self.undo_stack.pop() {
121            let current = self.snapshot_now();
122            self.redo_stack.push(current);
123            self.apply_snapshot(previous);
124            self.history_last_kind = None;
125            true
126        } else {
127            false
128        }
129    }
130
131    /// Redo the most recently undone edit. Returns `false` if there is nothing
132    /// to redo.
133    pub fn redo(&mut self) -> bool {
134        if let Some(next) = self.redo_stack.pop() {
135            let current = self.snapshot_now();
136            self.undo_stack.push(current);
137            self.apply_snapshot(next);
138            self.history_last_kind = None;
139            true
140        } else {
141            false
142        }
143    }
144
145    /// Whether there is any edit to undo.
146    pub fn can_undo(&self) -> bool {
147        !self.undo_stack.is_empty()
148    }
149
150    /// Whether there is any undone edit to redo.
151    pub fn can_redo(&self) -> bool {
152        !self.redo_stack.is_empty()
153    }
154
155    /// Clear all undo/redo history.
156    pub fn clear_history(&mut self) {
157        self.undo_stack.clear();
158        self.redo_stack.clear();
159        self.history_last_kind = None;
160    }
161
162    /// Set the maximum number of retained undo steps (oldest dropped first).
163    pub fn set_history_limit(&mut self, limit: usize) {
164        self.history_limit = limit.max(1);
165        while self.undo_stack.len() > self.history_limit {
166            self.undo_stack.remove(0);
167        }
168    }
169
170    /// Enable or disable undo/redo history capture (enabled by default).
171    ///
172    /// While disabled, edits do not record checkpoints, so apps that don't want
173    /// undo — or want to avoid the per-edit snapshot cost — can switch it off.
174    /// Existing history is cleared when disabling.
175    pub fn set_history_enabled(&mut self, enabled: bool) {
176        self.history_enabled = enabled;
177        if !enabled {
178            self.clear_history();
179        }
180    }
181
182    /// Whether undo/redo history capture is currently enabled.
183    pub fn is_history_enabled(&self) -> bool {
184        self.history_enabled
185    }
186}