use std::time::{Duration, Instant};
pub const IDLE: Duration = Duration::from_millis(750);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stroke {
Insert(char),
Delete,
}
#[derive(Debug, Default)]
pub struct TypingRun {
last: Option<(Stroke, Instant)>,
}
impl TypingRun {
pub fn continues(&mut self, stroke: Stroke, now: Instant) -> bool {
let carries_on = match self.last {
Some((previous, at)) => now.duration_since(at) < IDLE && follows(previous, stroke),
None => false,
};
self.last = Some((stroke, now));
carries_on
}
pub fn continues_session(&mut self, stroke: Stroke, now: Instant) -> bool {
let carries_on = self.last.is_some();
self.last = Some((stroke, now));
carries_on
}
pub fn end(&mut self) {
self.last = None;
}
}
fn follows(previous: Stroke, next: Stroke) -> bool {
match (previous, next) {
(Stroke::Delete, Stroke::Delete) => true,
(Stroke::Insert(before), Stroke::Insert(now)) => {
!(before.is_whitespace() && !now.is_whitespace())
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(millis: u64) -> Instant {
static ORIGIN: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
*ORIGIN.get_or_init(Instant::now) + Duration::from_millis(millis)
}
#[test]
fn the_first_keystroke_starts_a_group() {
let mut run = TypingRun::default();
assert!(!run.continues(Stroke::Insert('a'), at(0)));
}
#[test]
fn typing_on_continues() {
let mut run = TypingRun::default();
run.continues(Stroke::Insert('h'), at(0));
assert!(run.continues(Stroke::Insert('e'), at(50)));
assert!(run.continues(Stroke::Insert('y'), at(100)));
}
#[test]
fn a_new_word_starts_a_new_group() {
let mut run = TypingRun::default();
for (index, c) in "hello".chars().enumerate() {
run.continues(Stroke::Insert(c), at(index as u64 * 50));
}
assert!(
run.continues(Stroke::Insert(' '), at(300)),
"the space belongs with the word it follows"
);
assert!(
!run.continues(Stroke::Insert('w'), at(350)),
"the next word is a new group"
);
}
#[test]
fn a_pause_ends_a_run() {
let mut run = TypingRun::default();
run.continues(Stroke::Insert('a'), at(0));
assert!(run.continues(Stroke::Insert('b'), at(700)));
assert!(
!run.continues(Stroke::Insert('c'), at(700 + IDLE.as_millis() as u64)),
"a gap of exactly the threshold is already too long"
);
}
#[test]
fn deletes_coalesce_among_themselves() {
let mut run = TypingRun::default();
run.continues(Stroke::Delete, at(0));
assert!(run.continues(Stroke::Delete, at(50)));
}
#[test]
fn typing_and_deleting_never_merge() {
let mut run = TypingRun::default();
run.continues(Stroke::Insert('a'), at(0));
assert!(
!run.continues(Stroke::Delete, at(50)),
"fixing what you typed is a second action"
);
assert!(
!run.continues(Stroke::Insert('b'), at(100)),
"and typing again is a third"
);
}
#[test]
fn a_session_ignores_boundaries_and_pauses() {
let mut run = TypingRun::default();
assert!(
!run.continues_session(Stroke::Insert('h'), at(0)),
"the first"
);
assert!(run.continues_session(Stroke::Insert('i'), at(50)));
assert!(
run.continues_session(Stroke::Insert(' '), at(10_000)),
"a long pause does not break a session"
);
assert!(
run.continues_session(Stroke::Insert('t'), at(10_050)),
"nor does a word boundary"
);
assert!(
run.continues_session(Stroke::Delete, at(10_100)),
"nor does backspacing to fix a typo mid-session"
);
}
#[test]
fn anything_else_ends_the_run() {
let mut run = TypingRun::default();
run.continues(Stroke::Insert('a'), at(0));
run.end();
assert!(
!run.continues(Stroke::Insert('b'), at(50)),
"a motion between two keystrokes separates them"
);
}
}