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 = (current_time - flux.start_time) as f32;
186 if time_since_flux_start >= self.settings.auto_save_interval {
187 self.add_undo(current_state);
188 } else {
189 flux.latest_change_time = current_time;
190 flux.latest_state = current_state.clone();
191 }
192 }
193 }
194 }
195 }
196 }
197 }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[derive(Clone, PartialEq, Debug)]
206 struct S(i32);
207
208 fn undoer() -> Undoer<S> {
209 Undoer::with_settings(Settings {
210 max_undos: 100,
211 stable_time: 1.0,
212 auto_save_interval: 30.0,
213 })
214 }
215
216 #[test]
217 fn first_feed_creates_baseline_and_no_undo() {
218 let mut u = undoer();
219 u.feed_state(0.0, &S(0));
220 assert!(!u.has_undo(&S(0)));
221 assert!(!u.has_redo(&S(0)));
222 }
223
224 #[test]
225 fn stable_time_coalesces_rapid_changes_into_one_point() {
226 let mut u = undoer();
227 u.feed_state(0.0, &S(0)); // baseline S(0)
228
229 // Rapid changes within stable_time keep the state "in flux" — the
230 // intermediate values S(1), S(2) must NOT each become undo points.
231 u.feed_state(0.1, &S(1));
232 assert!(u.is_in_flux());
233 u.feed_state(0.2, &S(2));
234 u.feed_state(0.3, &S(3));
235
236 // State holds steady at S(3); once stable_time elapses, ONE point forms.
237 u.feed_state(0.4, &S(3)); // arms the stability timer at 0.4
238 u.feed_state(1.5, &S(3)); // 1.1s later ≥ stable_time → snapshot S(3)
239 assert!(u.has_undo(&S(3)));
240 assert!(!u.is_in_flux());
241
242 // Coalescing proof: a single undo jumps straight back to the baseline
243 // S(0) (the intermediates never became their own points), after which
244 // there is nothing left to undo.
245 assert_eq!(u.undo(&S(3)).cloned(), Some(S(0)));
246 assert!(!u.has_undo(&S(0)));
247 }
248
249 #[test]
250 fn undo_then_redo_round_trips() {
251 let mut u = undoer();
252 u.feed_state(0.0, &S(0)); // baseline S(0)
253
254 // Change to S(5) and let it stabilise into an undo point.
255 u.feed_state(0.1, &S(5));
256 u.feed_state(0.2, &S(5));
257 u.feed_state(2.0, &S(5)); // stable ≥ 1s → snapshot S(5)
258 assert!(u.has_undo(&S(5)));
259
260 // Undo back to S(0).
261 let reverted = u.undo(&S(5)).cloned();
262 assert_eq!(reverted, Some(S(0)));
263 assert!(u.has_redo(&S(0)));
264
265 // Redo forward to S(5).
266 let advanced = u.redo(&S(0)).cloned();
267 assert_eq!(advanced, Some(S(5)));
268 assert!(!u.has_redo(&S(5)));
269 }
270
271 #[test]
272 fn auto_save_interval_forces_point_during_continuous_change() {
273 let mut u = undoer();
274 u.feed_state(0.0, &S(0));
275 // Never stabilise: every feed is a new value.
276 for i in 1..=40 {
277 u.feed_state(i as f64, &S(i)); // 1s apart, always changing
278 }
279 // By 30s of continuous flux, at least one point must have been forced.
280 assert!(u.has_undo(&S(40)));
281 }
282
283 #[test]
284 fn changing_state_clears_redos() {
285 let mut u = undoer();
286 u.feed_state(0.0, &S(0));
287 u.feed_state(0.1, &S(1));
288 u.feed_state(2.0, &S(1)); // snapshot S(1)
289 u.undo(&S(1)); // now redo available
290 assert!(u.has_redo(&S(0)));
291
292 // Diverge to a brand-new value: redo history must be discarded.
293 u.feed_state(2.1, &S(9));
294 assert!(!u.has_redo(&S(9)));
295 }
296}