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}