Skip to main content

agg_gui/undo/
undoer.rs

1//! Time-coalescing undo/redo history — a faithful port of egui's
2//! `egui::util::undoer::Undoer<State>` (see
3//! `egui-reference/crates/egui/src/util/undoer.rs`).
4//!
5//! A single [`Undoer`] snapshots a *whole* state value `State` with the
6//! time-based coalescing egui demonstrates: an undo point is created once the
7//! state has been stable for `stable_time`, or forcibly every
8//! `auto_save_interval` if the user never stops changing it.
9//!
10//! Lives in the [`crate::undo`] module alongside the command-history
11//! [`UndoBuffer`](super::UndoBuffer): they are complementary models (snapshot
12//! vs. command). The RichTextEdit widget snapshots `{RichDoc, caret}` through
13//! it, and the dialogs demo snapshots its `{toggle, text}`.
14
15use std::collections::VecDeque;
16
17/// Tuning knobs for [`Undoer`]. Mirrors egui's `Settings`.
18#[derive(Clone, Debug, PartialEq)]
19pub struct Settings {
20    /// Maximum number of undo points retained.
21    pub max_undos: usize,
22    /// Once the state has been unchanged for this many seconds, create a new
23    /// undo point (if one is needed).
24    pub stable_time: f32,
25    /// If the state keeps changing and never stabilises, still create a save
26    /// point every this many seconds so there is always something to undo to.
27    pub auto_save_interval: f32,
28}
29
30impl Default for Settings {
31    fn default() -> Self {
32        Self {
33            max_undos: 100,
34            stable_time: 1.0,
35            auto_save_interval: 30.0,
36        }
37    }
38}
39
40/// Represents how the current state is changing between feeds.
41#[derive(Clone)]
42struct Flux<State> {
43    start_time: f64,
44    latest_change_time: f64,
45    latest_state: State,
46}
47
48/// Automatic undo system. Feed it the latest state every frame; it decides
49/// when to snapshot a new undo point.
50#[derive(Clone)]
51pub struct Undoer<State> {
52    settings: Settings,
53    /// New undo points are pushed to the back. Two adjacent points are never
54    /// equal; the latest may (often) equal the current state.
55    undos: VecDeque<State>,
56    /// Redos, populated by `undo` and cleared whenever the state changes.
57    redos: Vec<State>,
58    flux: Option<Flux<State>>,
59}
60
61impl<State> Default for Undoer<State>
62where
63    State: Clone + PartialEq,
64{
65    fn default() -> Self {
66        Self {
67            settings: Settings::default(),
68            undos: VecDeque::new(),
69            redos: Vec::new(),
70            flux: None,
71        }
72    }
73}
74
75impl<State> Undoer<State>
76where
77    State: Clone + PartialEq,
78{
79    /// Create an [`Undoer`] with custom [`Settings`] (used by tests to shrink
80    /// the coalescing windows).
81    #[allow(dead_code)]
82    pub fn with_settings(settings: Settings) -> Self {
83        Self {
84            settings,
85            ..Default::default()
86        }
87    }
88
89    /// Do we have an undo point different from the given state?
90    pub fn has_undo(&self, current_state: &State) -> bool {
91        match self.undos.len() {
92            0 => false,
93            1 => self.undos.back() != Some(current_state),
94            _ => true,
95        }
96    }
97
98    /// Do we have a redo point available for the given state?
99    pub fn has_redo(&self, current_state: &State) -> bool {
100        !self.redos.is_empty() && self.undos.back() == Some(current_state)
101    }
102
103    /// Is the state currently mid-change (a flux is being tracked)?
104    pub fn is_in_flux(&self) -> bool {
105        self.flux.is_some()
106    }
107
108    /// Undo one step, returning the state to revert to (if any).
109    pub fn undo(&mut self, current_state: &State) -> Option<&State> {
110        if self.has_undo(current_state) {
111            self.flux = None;
112
113            if self.undos.back() == Some(current_state) {
114                // The latest undo point IS the current state — move it to redos.
115                if let Some(popped) = self.undos.pop_back() {
116                    self.redos.push(popped);
117                }
118            } else {
119                self.redos.push(current_state.clone());
120            }
121
122            // Keep the (new) latest undo point intact.
123            self.undos.back()
124        } else {
125            None
126        }
127    }
128
129    /// Redo one step, returning the state to advance to (if any).
130    pub fn redo(&mut self, current_state: &State) -> Option<&State> {
131        if !self.undos.is_empty() && self.undos.back() != Some(current_state) {
132            // State changed since the last undo — redos are stale, clear them.
133            self.redos.clear();
134            None
135        } else if let Some(state) = self.redos.pop() {
136            self.undos.push_back(state);
137            self.undos.back()
138        } else {
139            None
140        }
141    }
142
143    /// Add an undo point iff the state differs from the latest point.
144    pub fn add_undo(&mut self, current_state: &State) {
145        if self.undos.back() != Some(current_state) {
146            self.undos.push_back(current_state.clone());
147        }
148        while self.undos.len() > self.settings.max_undos {
149            self.undos.pop_front();
150        }
151        self.flux = None;
152    }
153
154    /// Feed the most recent state (call every frame). `current_time` is in
155    /// seconds. Implements the two coalescing rules described on egui's
156    /// `Undoer`.
157    pub fn feed_state(&mut self, current_time: f64, current_state: &State) {
158        match self.undos.back() {
159            None => {
160                // First feed ever — always snapshot.
161                self.add_undo(current_state);
162            }
163            Some(latest_undo) => {
164                if latest_undo == current_state {
165                    self.flux = None;
166                } else {
167                    self.redos.clear();
168
169                    match self.flux.as_mut() {
170                        None => {
171                            self.flux = Some(Flux {
172                                start_time: current_time,
173                                latest_change_time: current_time,
174                                latest_state: current_state.clone(),
175                            });
176                        }
177                        Some(flux) => {
178                            if &flux.latest_state == current_state {
179                                let time_since_latest_change =
180                                    (current_time - flux.latest_change_time) as f32;
181                                if time_since_latest_change >= self.settings.stable_time {
182                                    self.add_undo(current_state);
183                                }
184                            } else {
185                                let time_since_flux_start =
186                                    (current_time - flux.start_time) as f32;
187                                if time_since_flux_start >= self.settings.auto_save_interval {
188                                    self.add_undo(current_state);
189                                } else {
190                                    flux.latest_change_time = current_time;
191                                    flux.latest_state = current_state.clone();
192                                }
193                            }
194                        }
195                    }
196                }
197            }
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[derive(Clone, PartialEq, Debug)]
207    struct S(i32);
208
209    fn undoer() -> Undoer<S> {
210        Undoer::with_settings(Settings {
211            max_undos: 100,
212            stable_time: 1.0,
213            auto_save_interval: 30.0,
214        })
215    }
216
217    #[test]
218    fn first_feed_creates_baseline_and_no_undo() {
219        let mut u = undoer();
220        u.feed_state(0.0, &S(0));
221        assert!(!u.has_undo(&S(0)));
222        assert!(!u.has_redo(&S(0)));
223    }
224
225    #[test]
226    fn stable_time_coalesces_rapid_changes_into_one_point() {
227        let mut u = undoer();
228        u.feed_state(0.0, &S(0)); // baseline S(0)
229
230        // Rapid changes within stable_time keep the state "in flux" — the
231        // intermediate values S(1), S(2) must NOT each become undo points.
232        u.feed_state(0.1, &S(1));
233        assert!(u.is_in_flux());
234        u.feed_state(0.2, &S(2));
235        u.feed_state(0.3, &S(3));
236
237        // State holds steady at S(3); once stable_time elapses, ONE point forms.
238        u.feed_state(0.4, &S(3)); // arms the stability timer at 0.4
239        u.feed_state(1.5, &S(3)); // 1.1s later ≥ stable_time → snapshot S(3)
240        assert!(u.has_undo(&S(3)));
241        assert!(!u.is_in_flux());
242
243        // Coalescing proof: a single undo jumps straight back to the baseline
244        // S(0) (the intermediates never became their own points), after which
245        // there is nothing left to undo.
246        assert_eq!(u.undo(&S(3)).cloned(), Some(S(0)));
247        assert!(!u.has_undo(&S(0)));
248    }
249
250    #[test]
251    fn undo_then_redo_round_trips() {
252        let mut u = undoer();
253        u.feed_state(0.0, &S(0)); // baseline S(0)
254
255        // Change to S(5) and let it stabilise into an undo point.
256        u.feed_state(0.1, &S(5));
257        u.feed_state(0.2, &S(5));
258        u.feed_state(2.0, &S(5)); // stable ≥ 1s → snapshot S(5)
259        assert!(u.has_undo(&S(5)));
260
261        // Undo back to S(0).
262        let reverted = u.undo(&S(5)).cloned();
263        assert_eq!(reverted, Some(S(0)));
264        assert!(u.has_redo(&S(0)));
265
266        // Redo forward to S(5).
267        let advanced = u.redo(&S(0)).cloned();
268        assert_eq!(advanced, Some(S(5)));
269        assert!(!u.has_redo(&S(5)));
270    }
271
272    #[test]
273    fn auto_save_interval_forces_point_during_continuous_change() {
274        let mut u = undoer();
275        u.feed_state(0.0, &S(0));
276        // Never stabilise: every feed is a new value.
277        for i in 1..=40 {
278            u.feed_state(i as f64, &S(i)); // 1s apart, always changing
279        }
280        // By 30s of continuous flux, at least one point must have been forced.
281        assert!(u.has_undo(&S(40)));
282    }
283
284    #[test]
285    fn changing_state_clears_redos() {
286        let mut u = undoer();
287        u.feed_state(0.0, &S(0));
288        u.feed_state(0.1, &S(1));
289        u.feed_state(2.0, &S(1)); // snapshot S(1)
290        u.undo(&S(1)); // now redo available
291        assert!(u.has_redo(&S(0)));
292
293        // Diverge to a brand-new value: redo history must be discarded.
294        u.feed_state(2.1, &S(9));
295        assert!(!u.has_redo(&S(9)));
296    }
297}