Skip to main content

kimun_notes/components/text_editor/
typing_run.rs

1//! When one keystroke continues the last one's **undo group**.
2//!
3//! The engine holds no clock and no policy: it offers a group that can
4//! span keystrokes, and the backend says where one ends. This is the **plain**
5//! backend's answer. The **vim** backend has a different one — a group is an
6//! Insert session — which is why the rule lives here rather than in the buffer.
7//!
8//! Two rules, because each covers the other's blind spot:
9//!
10//! - **A word boundary ends a run.** Undo then takes back a word, which is how
11//!   people describe what they typed. Whitespace attaches to the word it follows,
12//!   so typing `hello world` leaves `hello ` and `world` — undo removes `world`,
13//!   then `hello `.
14//! - **A pause ends a run.** A gap mid-word is a new thought, and resuming should
15//!   not undo back through it.
16//!
17//! Insert runs and delete runs never merge. Typing a word and then backspacing to
18//! fix it is two actions, and one undo should not take back both.
19//!
20//! No timer is needed. Time only has to be read when a key arrives, and every
21//! non-typing action — a motion, a click, a save, an undo — ends the run
22//! explicitly. Undo is itself one of those, so by the time anyone can observe the
23//! grouping, the pause has already been accounted for.
24
25use std::time::{Duration, Instant};
26
27/// How long a gap has to be before the next keystroke starts a new group.
28///
29/// Below about half a second this fires mid-word for ordinary typing; much above
30/// a second and a genuine pause stops registering as one.
31pub const IDLE: Duration = Duration::from_millis(750);
32
33/// What a keystroke did to the text, for deciding whether the next one belongs
34/// with it.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Stroke {
37    /// A character was typed.
38    Insert(char),
39    /// Text was removed — backspace, or a forward delete.
40    Delete,
41}
42
43/// The run of keystrokes currently sharing an undo group.
44#[derive(Debug, Default)]
45pub struct TypingRun {
46    last: Option<(Stroke, Instant)>,
47}
48
49impl TypingRun {
50    /// Whether `stroke` at `now` continues the run, and record it either way.
51    ///
52    /// The caller passes the time rather than this reading a clock, so a test can
53    /// describe a pause instead of sleeping through one.
54    pub fn continues(&mut self, stroke: Stroke, now: Instant) -> bool {
55        let carries_on = match self.last {
56            Some((previous, at)) => now.duration_since(at) < IDLE && follows(previous, stroke),
57            None => false,
58        };
59        self.last = Some((stroke, now));
60        carries_on
61    }
62
63    /// Whether `stroke` continues a run that neither a word boundary nor a pause
64    /// may break — vim's Insert session, which `u` undoes whole.
65    ///
66    /// Still not the *first* stroke of one: the session's start is marked by
67    /// [`Self::end`], called when the mode changes.
68    pub fn continues_session(&mut self, stroke: Stroke, now: Instant) -> bool {
69        let carries_on = self.last.is_some();
70        self.last = Some((stroke, now));
71        carries_on
72    }
73
74    /// End the run: the next keystroke starts a new group.
75    ///
76    /// Called for everything that is not typing — a cursor move, a click, a
77    /// paste, a save, an undo.
78    pub fn end(&mut self) {
79        self.last = None;
80    }
81}
82
83/// Whether `next` belongs with `previous`.
84fn follows(previous: Stroke, next: Stroke) -> bool {
85    match (previous, next) {
86        (Stroke::Delete, Stroke::Delete) => true,
87        (Stroke::Insert(before), Stroke::Insert(now)) => {
88            // A new word begins a new group; whitespace stays with the word it
89            // follows, so the break lands between `hello ` and `world`.
90            !(before.is_whitespace() && !now.is_whitespace())
91        }
92        // Typing and deleting are different actions.
93        _ => false,
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn at(millis: u64) -> Instant {
102        // A fixed origin so the tests describe gaps rather than wall-clock time.
103        static ORIGIN: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
104        *ORIGIN.get_or_init(Instant::now) + Duration::from_millis(millis)
105    }
106
107    #[test]
108    fn the_first_keystroke_starts_a_group() {
109        let mut run = TypingRun::default();
110        assert!(!run.continues(Stroke::Insert('a'), at(0)));
111    }
112
113    #[test]
114    fn typing_on_continues() {
115        let mut run = TypingRun::default();
116        run.continues(Stroke::Insert('h'), at(0));
117        assert!(run.continues(Stroke::Insert('e'), at(50)));
118        assert!(run.continues(Stroke::Insert('y'), at(100)));
119    }
120
121    #[test]
122    fn a_new_word_starts_a_new_group() {
123        let mut run = TypingRun::default();
124        for (index, c) in "hello".chars().enumerate() {
125            run.continues(Stroke::Insert(c), at(index as u64 * 50));
126        }
127        assert!(
128            run.continues(Stroke::Insert(' '), at(300)),
129            "the space belongs with the word it follows"
130        );
131        assert!(
132            !run.continues(Stroke::Insert('w'), at(350)),
133            "the next word is a new group"
134        );
135    }
136
137    #[test]
138    fn a_pause_ends_a_run() {
139        let mut run = TypingRun::default();
140        run.continues(Stroke::Insert('a'), at(0));
141        assert!(run.continues(Stroke::Insert('b'), at(700)));
142        assert!(
143            !run.continues(Stroke::Insert('c'), at(700 + IDLE.as_millis() as u64)),
144            "a gap of exactly the threshold is already too long"
145        );
146    }
147
148    #[test]
149    fn deletes_coalesce_among_themselves() {
150        let mut run = TypingRun::default();
151        run.continues(Stroke::Delete, at(0));
152        assert!(run.continues(Stroke::Delete, at(50)));
153    }
154
155    #[test]
156    fn typing_and_deleting_never_merge() {
157        let mut run = TypingRun::default();
158        run.continues(Stroke::Insert('a'), at(0));
159        assert!(
160            !run.continues(Stroke::Delete, at(50)),
161            "fixing what you typed is a second action"
162        );
163        assert!(
164            !run.continues(Stroke::Insert('b'), at(100)),
165            "and typing again is a third"
166        );
167    }
168
169    #[test]
170    fn a_session_ignores_boundaries_and_pauses() {
171        // Vim's `u` takes back a whole Insert session, spaces and thinking time
172        // included.
173        let mut run = TypingRun::default();
174        assert!(
175            !run.continues_session(Stroke::Insert('h'), at(0)),
176            "the first"
177        );
178        assert!(run.continues_session(Stroke::Insert('i'), at(50)));
179        assert!(
180            run.continues_session(Stroke::Insert(' '), at(10_000)),
181            "a long pause does not break a session"
182        );
183        assert!(
184            run.continues_session(Stroke::Insert('t'), at(10_050)),
185            "nor does a word boundary"
186        );
187        assert!(
188            run.continues_session(Stroke::Delete, at(10_100)),
189            "nor does backspacing to fix a typo mid-session"
190        );
191    }
192
193    #[test]
194    fn anything_else_ends_the_run() {
195        let mut run = TypingRun::default();
196        run.continues(Stroke::Insert('a'), at(0));
197        run.end();
198        assert!(
199            !run.continues(Stroke::Insert('b'), at(50)),
200            "a motion between two keystrokes separates them"
201        );
202    }
203}