Skip to main content

frus_widgets/
undo.rs

1//! **Undo and redo for a text field**: the history, and the rule that decides what one
2//! step is.
3//!
4//! Ctrl+Z is the shortcut people press without deciding to, and its absence is felt as the
5//! field being broken rather than as a feature being missing. What makes it useful rather
6//! than merely present is not the stack — that part is fifteen lines — but the answer to
7//! *what counts as one step*, because a history that steps back one character at a time is
8//! nobody's idea of undo.
9//!
10//! ## What one step is
11//!
12//! A **run** of the same kind of change, ended by anything that is not more of the same:
13//!
14//! - **Typing** joins the run in progress, so a word is one step. A character that is not
15//!   a word character — a space, a full stop — joins the run *and closes it behind
16//!   itself*, which is what makes undo step back word by word rather than sentence by
17//!   sentence.
18//! - **Deleting** is a run of its own. A held backspace is one step, and it does not join
19//!   the typing it is undoing by hand.
20//! - **A pause** of half a second breaks a run, whatever it was. That number is the
21//!   reference's, chosen there as the best fit for what Windows, macOS and Linux each do
22//!   on their own; there is no better argument for it than that, and no worse one.
23//! - **A chunk** — a paste, a cut, a selection replaced by one keystroke, a line break —
24//!   is always its own step, and joins nothing on either side. This is the issue's own
25//!   list, and they have one thing in common: each is a single deliberate act whose result
26//!   the user can see all of.
27//! - **A composition** — the provisional text an input method underlines while a word is
28//!   being chosen — is one step for the whole word. The clock does not apply to it: the
29//!   rhythm of an IME's revisions is the IME's, not the typist's, and a slow suggestion
30//!   list is not a pause in the writing.
31//! - **Moving the caret closes the run.** Clicking somewhere else, an arrow key, a
32//!   select-all: nothing is recorded, because nothing changed, but what is typed next is a
33//!   new step. Typing here, then there, then Ctrl+Z should not undo both at once.
34//!
35//! ## What a step is made of
36//!
37//! The value **and** the caret, because an undo that restores the text and leaves the
38//! caret at the end has only done half the job — the point of undoing is to carry on from
39//! where you were.
40//!
41//! ## What is recorded, and when
42//!
43//! Entries are the states the field was in **before** each step, and one is pushed only
44//! once the value has actually changed. Not when a key that usually changes it arrives:
45//! a filter may refuse it, a length limit may swallow it, a read-only field may ignore it,
46//! and an Enter may submit instead of typing. The framework records on the evidence of a
47//! changed value, which is also how the reference does it — its history listens to the
48//! value rather than to the keyboard.
49
50use crate::interaction::Key;
51use crate::runtime::Edit;
52
53/// How long a run of typing survives without a keystroke, in seconds.
54///
55/// The reference's `_kThrottleDuration`, with the reference's own reasoning: a best fit
56/// for three platforms that each do something slightly different, and perfect for none.
57pub const RUN_PAUSE: f32 = 0.5;
58
59/// The most steps one field keeps.
60///
61/// The reference's stack is unbounded, which for whole copies of a document is unbounded
62/// memory. This is a depth nobody reaches by hand — a hundred and twenty-eight separate
63/// deliberate acts in one field — and the oldest entry falls off the bottom when it is.
64const DEPTH: usize = 128;
65
66/// A field's value and where the caret was in it: what one step of undo restores.
67#[derive(Clone, Debug, Default, PartialEq, Eq)]
68pub struct EditSnapshot {
69    /// The whole value.
70    pub value: String,
71    /// The caret, the selection anchor, and any composing region.
72    pub edit: Edit,
73}
74
75impl EditSnapshot {
76    /// A snapshot of `value` with the caret state `edit`.
77    pub fn new(value: impl Into<String>, edit: Edit) -> Self {
78        Self {
79            value: value.into(),
80            edit,
81        }
82    }
83}
84
85/// What sort of change a keystroke made — the unit undo steps back by.
86#[derive(Copy, Clone, Debug, PartialEq, Eq)]
87pub enum EditKind {
88    /// One character typed. `boundary` is a character that ends a word, which joins the
89    /// run it is the end of and closes it.
90    Typing {
91        /// Whether this character ends a word.
92        boundary: bool,
93    },
94    /// A backspace or a forward delete, with nothing selected.
95    Deleting,
96    /// A revision of the text an input method is still composing.
97    Composing,
98    /// A paste, a cut, a selection replaced, a line break: one deliberate act, one step.
99    Chunk,
100}
101
102impl EditKind {
103    /// Reads a keystroke as one of the four, given what the field was in when it arrived:
104    /// whether something was **selected**, and whether an input method was **composing**.
105    ///
106    /// The two conditions are the reason this is not a match on the key alone. A backspace
107    /// with a selection removes a passage and is a chunk; the same backspace with no
108    /// selection removes a letter and joins the run. Typing with a selection replaces it,
109    /// which is the case the issue names — a whole selection replaced by one character is
110    /// one step, and the step has to include what was replaced.
111    pub fn of(key: &Key, selected: bool, composing: bool) -> Self {
112        if composing {
113            return EditKind::Composing;
114        }
115        match key {
116            Key::Text(text) if !selected && text.chars().count() == 1 => {
117                let c = text.chars().next().expect("one character");
118                EditKind::Typing {
119                    boundary: !c.is_alphanumeric(),
120                }
121            }
122            Key::Backspace | Key::Delete if !selected => EditKind::Deleting,
123            _ => EditKind::Chunk,
124        }
125    }
126
127    /// Whether a change of this kind may join a run of `previous`, `since` seconds after
128    /// the last one.
129    fn joins(self, previous: EditKind, since: f32) -> bool {
130        match (previous, self) {
131            // A composition is one act however long the input method takes over it.
132            (EditKind::Composing, EditKind::Composing) => true,
133            (EditKind::Typing { .. }, EditKind::Typing { .. })
134            | (EditKind::Deleting, EditKind::Deleting) => since <= RUN_PAUSE,
135            _ => false,
136        }
137    }
138
139    /// Whether a run may carry on **after** a change of this kind. A word boundary and a
140    /// chunk both close the run they end.
141    fn leaves_run_open(self) -> bool {
142        !matches!(self, EditKind::Typing { boundary: true } | EditKind::Chunk)
143    }
144}
145
146/// One field's undo history: what it was, and what it was about to be.
147#[derive(Clone, Debug, Default, PartialEq, Eq)]
148pub struct UndoHistory {
149    /// The states before each step, oldest first.
150    past: Vec<EditSnapshot>,
151    /// The states undone out of, most recent last.
152    future: Vec<EditSnapshot>,
153    /// The run in progress, if anything may still join it.
154    run: Option<EditKind>,
155}
156
157impl UndoHistory {
158    /// Records that the field, which **was** `before`, has just been changed by a change
159    /// of kind `kind`, `since` seconds after the last one.
160    ///
161    /// A change that joins the run in progress records nothing: the run's first entry
162    /// already holds the state the whole run will be undone to.
163    pub fn record(&mut self, before: EditSnapshot, kind: EditKind, since: f32) {
164        let joins = self.run.is_some_and(|run| kind.joins(run, since));
165        // A new change makes anything that was undone unreachable — it is the branch that
166        // was not taken, and there is no way back to it that is not a second history.
167        self.future.clear();
168        if !joins && self.past.last() != Some(&before) {
169            self.past.push(before);
170            if self.past.len() > DEPTH {
171                self.past.remove(0);
172            }
173        }
174        self.run = kind.leaves_run_open().then_some(kind);
175    }
176
177    /// Ends the run in progress without recording anything: the caret moved, so what is
178    /// typed next is a step of its own.
179    pub fn close_run(&mut self) {
180        self.run = None;
181    }
182
183    /// Steps back: `current` is where the field is now, and the answer is where it should
184    /// go. `None` when there is nothing to undo.
185    pub fn undo(&mut self, current: EditSnapshot) -> Option<EditSnapshot> {
186        let previous = self.past.pop()?;
187        self.future.push(current);
188        // An undo ends the run it stepped out of, or the next keystroke would be recorded
189        // as more of a step that is no longer there.
190        self.run = None;
191        Some(previous)
192    }
193
194    /// Steps forward again, undoing an undo. `None` when nothing was undone.
195    pub fn redo(&mut self, current: EditSnapshot) -> Option<EditSnapshot> {
196        let next = self.future.pop()?;
197        self.past.push(current);
198        self.run = None;
199        Some(next)
200    }
201
202    /// Whether there is a step to go back to.
203    pub fn can_undo(&self) -> bool {
204        !self.past.is_empty()
205    }
206
207    /// Whether there is a step to go forward to.
208    pub fn can_redo(&self) -> bool {
209        !self.future.is_empty()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    /// A caret at `cursor`, nothing selected.
218    fn at(cursor: usize) -> Edit {
219        Edit {
220            cursor,
221            anchor: None,
222            composing: None,
223        }
224    }
225
226    /// Types `text` into `history` one character at a time, the way the shell does:
227    /// snapshot what the field was, then record the kind the key reads as.
228    fn typed(history: &mut UndoHistory, value: &mut String, text: &str, pause: f32) {
229        for c in text.chars() {
230            let before = EditSnapshot::new(value.clone(), at(value.chars().count()));
231            let key = Key::Text(c.to_string());
232            value.push(c);
233            history.record(before, EditKind::of(&key, false, false), pause);
234        }
235    }
236
237    /// The issue's own condition for done: a word, then a second, then Ctrl+Z leaves the
238    /// first — **and puts the caret at its end**, which is the half that makes it useful.
239    #[test]
240    fn a_word_is_one_step_and_undo_brings_the_caret_with_it() {
241        let mut history = UndoHistory::default();
242        let mut value = String::new();
243        typed(&mut history, &mut value, "hello world", 0.05);
244        assert_eq!(value, "hello world");
245
246        let now = EditSnapshot::new(value.clone(), at(11));
247        let back = history.undo(now.clone()).expect("a step to go back to");
248        assert_eq!(
249            back.value, "hello ",
250            "the second word goes, the first stays"
251        );
252        assert_eq!(back.edit.cursor, 6, "and the caret is where it was");
253
254        // And back again: the space closed the first run, so "hello " is a step too.
255        let back2 = history.undo(back.clone()).expect("the first word");
256        assert_eq!(back2.value, "");
257
258        // Redo returns them in order.
259        assert_eq!(history.redo(back2).expect("forward").value, "hello ");
260        assert_eq!(history.redo(back).expect("forward").value, now.value);
261        assert!(!history.can_redo(), "and then there is nothing ahead");
262    }
263
264    /// A pause breaks a run even in the middle of a word, because somebody who stopped
265    /// typing for half a second and carried on was doing two things.
266    #[test]
267    fn a_pause_breaks_a_run() {
268        let mut history = UndoHistory::default();
269        let mut value = String::new();
270        typed(&mut history, &mut value, "abc", 0.05);
271        // The pause is measured before the first of the second run; the rest is quick.
272        let before = EditSnapshot::new(value.clone(), at(3));
273        value.push('d');
274        history.record(
275            before,
276            EditKind::Typing { boundary: false },
277            RUN_PAUSE + 0.01,
278        );
279        typed(&mut history, &mut value, "ef", 0.05);
280        let now = EditSnapshot::new(value.clone(), at(6));
281        assert_eq!(
282            history.undo(now).expect("a step").value,
283            "abc",
284            "the run started again after the pause"
285        );
286    }
287
288    /// The three the issue names, each one step: a paste, a cut, and a selection replaced
289    /// by a keystroke.
290    #[test]
291    fn a_paste_a_cut_and_a_replacement_are_one_step_each() {
292        let paste = Key::Text("a whole clipboard".to_string());
293        assert_eq!(EditKind::of(&paste, false, false), EditKind::Chunk);
294        // A cut reaches the field as a backspace over a selection.
295        assert_eq!(
296            EditKind::of(&Key::Backspace, true, false),
297            EditKind::Chunk,
298            "a deletion with something selected is not a run of backspaces"
299        );
300        // One character over a selection replaces the lot.
301        assert_eq!(
302            EditKind::of(&Key::Text("x".to_string()), true, false),
303            EditKind::Chunk
304        );
305        // The same character with nothing selected is ordinary typing.
306        assert_eq!(
307            EditKind::of(&Key::Text("x".to_string()), false, false),
308            EditKind::Typing { boundary: false }
309        );
310        // And they do not join each other, however fast they arrive.
311        let mut history = UndoHistory::default();
312        history.record(EditSnapshot::new("one", at(3)), EditKind::Chunk, 0.0);
313        history.record(EditSnapshot::new("two", at(3)), EditKind::Chunk, 0.0);
314        assert_eq!(history.undo(EditSnapshot::default()).unwrap().value, "two");
315        assert_eq!(history.undo(EditSnapshot::default()).unwrap().value, "one");
316    }
317
318    /// Deleting is its own run, and does not join the typing it is taking back.
319    #[test]
320    fn deleting_does_not_join_typing() {
321        let mut history = UndoHistory::default();
322        let mut value = String::new();
323        typed(&mut history, &mut value, "abcd", 0.05);
324        // Two backspaces, quickly: one step between them, not two.
325        for _ in 0..2 {
326            let before = EditSnapshot::new(value.clone(), at(value.chars().count()));
327            value.pop();
328            history.record(before, EditKind::Deleting, 0.05);
329        }
330        assert_eq!(value, "ab");
331        let back = history
332            .undo(EditSnapshot::new(value, at(2)))
333            .expect("a step");
334        assert_eq!(back.value, "abcd", "the whole run of backspaces comes back");
335    }
336
337    /// Moving the caret records nothing and ends the run: typing here, then there, then
338    /// undo, takes back only what was typed there.
339    #[test]
340    fn a_caret_move_closes_the_run() {
341        let mut history = UndoHistory::default();
342        let mut value = String::from("ab");
343        typed(&mut history, &mut value, "cd", 0.05);
344        history.close_run(); // the caret moved
345        let before = EditSnapshot::new(value.clone(), at(4));
346        value.push('e');
347        history.record(before, EditKind::Typing { boundary: false }, 0.05);
348        let back = history
349            .undo(EditSnapshot::new(value, at(5)))
350            .expect("a step");
351        assert_eq!(back.value, "abcd", "only what was typed after the move");
352    }
353
354    /// An input method's revisions are one step for the whole word, and the clock does not
355    /// apply to them: a slow suggestion list is not a pause in the writing.
356    #[test]
357    fn a_composition_is_one_step_however_slow() {
358        let mut history = UndoHistory::default();
359        history.record(
360            EditSnapshot::new("", at(0)),
361            EditKind::of(&Key::Text("n".into()), false, true),
362            0.0,
363        );
364        history.record(
365            EditSnapshot::new("n", at(1)),
366            EditKind::Composing,
367            RUN_PAUSE * 10.0,
368        );
369        history.record(EditSnapshot::new("ni", at(2)), EditKind::Composing, 3.0);
370        let back = history
371            .undo(EditSnapshot::new("\u{4f60}", at(1)))
372            .expect("a step");
373        assert_eq!(back.value, "", "the whole composed word");
374        assert!(!history.can_undo(), "and it was one entry, not three");
375    }
376
377    /// A new change makes what was undone unreachable — the branch not taken.
378    #[test]
379    fn typing_after_an_undo_forgets_what_was_ahead() {
380        let mut history = UndoHistory::default();
381        let mut value = String::new();
382        typed(&mut history, &mut value, "one ", 0.05);
383        typed(&mut history, &mut value, "two", 0.05);
384        let back = history
385            .undo(EditSnapshot::new(value, at(7)))
386            .expect("a step");
387        assert!(history.can_redo());
388        let mut value = back.value.clone();
389        typed(&mut history, &mut value, "six", 0.05);
390        assert!(!history.can_redo(), "'two' is not reachable any more");
391    }
392
393    /// The same state twice in a row is one entry: an input method that clears its own
394    /// provisional text before committing passes back through the value it started from,
395    /// and an undo that lands where it already is looks broken.
396    #[test]
397    fn the_same_state_is_not_recorded_twice() {
398        let mut history = UndoHistory::default();
399        let state = EditSnapshot::new("hello", at(5));
400        history.record(state.clone(), EditKind::Chunk, 0.0);
401        history.record(state.clone(), EditKind::Chunk, 0.0);
402        assert_eq!(history.undo(EditSnapshot::default()).unwrap(), state);
403        assert!(!history.can_undo());
404    }
405
406    /// The oldest step falls off the bottom rather than the history growing without end.
407    #[test]
408    fn the_history_has_a_floor() {
409        let mut history = UndoHistory::default();
410        for i in 0..DEPTH + 10 {
411            history.record(
412                EditSnapshot::new(i.to_string(), at(0)),
413                EditKind::Chunk,
414                0.0,
415            );
416        }
417        let mut seen = 0;
418        while history.undo(EditSnapshot::default()).is_some() {
419            seen += 1;
420        }
421        assert_eq!(seen, DEPTH);
422    }
423}
424
425/// The rule, driven through a **real field** the way the shell drives it.
426///
427/// The tests above exercise the history on its own; these put a `TextField` in front of it
428/// and go through the same four steps `App::apply_key` goes through — read the value, hand
429/// the key to the field, keep the caret it hands back, record on the evidence of a changed
430/// value — because everything that decides whether undo is right happens in the seam
431/// between those two, and a rule tested only on its own side of the seam is a rule that
432/// works in a test.
433///
434/// What is left uncovered is the shell's own plumbing: that Ctrl+Z reaches this at all,
435/// and that the restored value is dispatched. Nothing in this repository can drive a
436/// frame, which is the roadmap entry this keeps running into.
437#[cfg(test)]
438mod field_tests {
439    use super::*;
440    use crate::interaction::WidgetId;
441    use crate::runtime::Runtime;
442    use crate::textinput::TextField;
443    use crate::widget::Widget;
444
445    #[derive(Clone, Debug, PartialEq)]
446    enum Msg {
447        Changed(String),
448    }
449
450    /// An application: the value lives here, as it does in a real one, and a message is
451    /// the only way it changes.
452    struct App {
453        value: String,
454        runtime: Runtime,
455        id: WidgetId,
456        /// Whether the field takes digits only — a stand-in for any field that refuses
457        /// some of what is typed at it.
458        digits: bool,
459    }
460
461    impl App {
462        fn new(value: &str) -> Self {
463            Self {
464                value: value.to_string(),
465                runtime: Runtime::default(),
466                id: WidgetId::from_u64(1),
467                digits: false,
468            }
469        }
470
471        /// The same application, with a field that only takes digits.
472        fn digits_only(mut self) -> Self {
473            self.digits = true;
474            self
475        }
476
477        fn field(&self) -> TextField<Msg> {
478            let field = TextField::new(self.value.as_str()).on_input(Msg::Changed);
479            if self.digits {
480                field.input_filter(|c| c.is_ascii_digit().then_some(c))
481            } else {
482                field
483            }
484        }
485
486        fn caret(&self) -> Edit {
487            self.runtime
488                .edits
489                .get(&self.id)
490                .copied()
491                .unwrap_or_default()
492        }
493
494        /// The shell's `apply_key`, with the application's own update in the middle.
495        fn key(&mut self, key: Key, since: f32) {
496            let field = self.field();
497            let was = self.caret();
498            let mut edit = was;
499            let before = Widget::<Msg>::text_value(&field)
500                .map(str::to_owned)
501                .expect("a field");
502            let message = field.on_edit(&mut edit, &key);
503            self.runtime.edits.insert(self.id, edit);
504            if let Some(Msg::Changed(value)) = message {
505                self.value = value;
506            }
507            if before != self.value {
508                let kind = EditKind::of(&key, was.selection_range().is_some(), false);
509                self.runtime
510                    .record_edit(self.id, EditSnapshot::new(before, was), kind, since);
511            } else {
512                self.runtime.close_edit_run(self.id);
513            }
514        }
515
516        /// Types a word, quickly.
517        fn typing(&mut self, text: &str) {
518            for c in text.chars() {
519                self.key(Key::Text(c.to_string()), 0.05);
520            }
521        }
522
523        /// The shell's `step_history`.
524        fn step(&mut self, forward: bool) -> bool {
525            let current = EditSnapshot::new(self.value.clone(), self.caret());
526            let target = if forward {
527                self.runtime.redo_edit(self.id, current)
528            } else {
529                self.runtime.undo_edit(self.id, current)
530            };
531            let Some(target) = target else {
532                return false;
533            };
534            let field = self.field();
535            if let Some(Msg::Changed(value)) = field.replace_value(target.value.clone()) {
536                self.value = value;
537            }
538            self.runtime.edits.insert(self.id, target.edit);
539            true
540        }
541    }
542
543    /// The issue's own condition for done, end to end: two words, Ctrl+Z, then redo.
544    #[test]
545    fn two_words_one_undo_and_the_caret_comes_with_it() {
546        let mut app = App::new("");
547        app.typing("hello world");
548        assert_eq!(app.value, "hello world");
549
550        assert!(app.step(false));
551        assert_eq!(app.value, "hello ");
552        assert_eq!(app.caret().cursor, 6);
553
554        assert!(app.step(true), "and forward again");
555        assert_eq!(app.value, "hello world");
556        assert_eq!(app.caret().cursor, 11);
557        assert!(!app.step(true), "there is nothing beyond it");
558    }
559
560    /// A paste is one step, and so is the selection it replaces.
561    #[test]
562    fn a_paste_over_a_selection_is_one_step() {
563        let mut app = App::new("");
564        app.typing("keep");
565        // Select the lot, then paste over it.
566        app.runtime.edits.insert(
567            app.id,
568            Edit {
569                cursor: 4,
570                anchor: Some(0),
571                composing: None,
572            },
573        );
574        app.key(Key::Text("a whole clipboard".to_string()), 0.05);
575        assert_eq!(app.value, "a whole clipboard");
576        assert!(app.step(false));
577        assert_eq!(
578            app.value, "keep",
579            "the paste and what it replaced, together"
580        );
581        assert_eq!(app.caret().anchor, Some(0), "with the selection it had");
582    }
583
584    /// A cut — which reaches the field as a backspace over a selection — is one step, and
585    /// does not join the backspaces around it.
586    #[test]
587    fn a_cut_is_its_own_step() {
588        let mut app = App::new("");
589        app.typing("one two");
590        app.key(Key::Backspace, 0.05); // "one tw"
591        app.runtime.edits.insert(
592            app.id,
593            Edit {
594                cursor: 6,
595                anchor: Some(4),
596                composing: None,
597            },
598        );
599        app.key(Key::Backspace, 0.05); // the cut: "one "
600        assert_eq!(app.value, "one ");
601        assert!(app.step(false));
602        assert_eq!(app.value, "one tw", "the cut alone comes back");
603        assert!(app.step(false));
604        assert_eq!(app.value, "one two", "and then the backspace before it");
605    }
606
607    /// A character the field **refuses** is not a step. This is the one that says why the
608    /// history is recorded on the evidence of a changed value rather than on a key that
609    /// usually changes one: a filter, a length limit, a read-only field and an Enter that
610    /// submits all produce keystrokes that change nothing, and a history that records them
611    /// has entries that undo to the state they are already in — which from the outside is
612    /// indistinguishable from Ctrl+Z being broken.
613    #[test]
614    fn a_refused_character_is_not_a_step() {
615        let mut app = App::new("").digits_only();
616        app.typing("12");
617        app.key(Key::Text("x".to_string()), 0.05);
618        assert_eq!(app.value, "12", "the letter never lands");
619        // Enter is the other half of the same point: with nothing to submit to, it is a
620        // keystroke in a field that changes nothing at all.
621        app.key(Key::Enter, 0.05);
622        assert_eq!(app.value, "12");
623
624        // One step back is the run of digits, and there is nothing behind it: neither the
625        // refused letter nor the Enter left an entry of its own.
626        assert!(app.step(false));
627        assert_eq!(app.value, "");
628        assert!(
629            !app.step(false),
630            "no entry for a keystroke that did nothing"
631        );
632    }
633
634    /// A read-only field has no history at all: nothing it holds ever came from a
635    /// keystroke, and `replace_value` refuses for the same reason `on_edit` does.
636    #[test]
637    fn a_read_only_field_has_nothing_to_undo() {
638        let field: TextField<Msg> = TextField::new("fixed").on_input(Msg::Changed).read_only();
639        let mut edit = Edit::default();
640        assert!(field
641            .on_edit(&mut edit, &Key::Text("x".to_string()))
642            .is_none());
643        assert!(field.replace_value("something else".to_string()).is_none());
644    }
645}