kimun_notes/components/text_editor/
typing_run.rs1use std::time::{Duration, Instant};
26
27pub const IDLE: Duration = Duration::from_millis(750);
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Stroke {
37 Insert(char),
39 Delete,
41}
42
43#[derive(Debug, Default)]
45pub struct TypingRun {
46 last: Option<(Stroke, Instant)>,
47}
48
49impl TypingRun {
50 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 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 pub fn end(&mut self) {
79 self.last = None;
80 }
81}
82
83fn follows(previous: Stroke, next: Stroke) -> bool {
85 match (previous, next) {
86 (Stroke::Delete, Stroke::Delete) => true,
87 (Stroke::Insert(before), Stroke::Insert(now)) => {
88 !(before.is_whitespace() && !now.is_whitespace())
91 }
92 _ => false,
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 fn at(millis: u64) -> Instant {
102 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 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}