keymap_seq/timed.rs
1//! Time-windowed sequence resolution.
2//!
3//! [`TimedPending`] wraps [`PendingSequence`] and bundles the expiry check,
4//! buffer flush, and new-key processing into a single [`feed`](TimedPending::feed)
5//! call — absorbing the ~30-line event-loop pattern that every `jj`-style caller
6//! previously hand-wrote.
7//!
8//! **The library never calls `Instant::now()`** — the current instant is
9//! caller-supplied data, exactly as the active-layer chain and the pending key
10//! buffer are caller-owned state. Providing a wrapper type is within the
11//! state-free contract because the *decision* (is the prefix stale?) is still
12//! the caller's clock datum, not a hidden wall-clock read.
13
14use std::time::{Duration, Instant};
15
16use keymap_core::KeyInput;
17
18use crate::{PendingSequence, SequenceKeymap, Step};
19
20/// A [`PendingSequence`] with a time-window expiry gate.
21///
22/// Each call to [`feed`](Self::feed) performs three things in order:
23/// 1. If the pending buffer is non-empty and the gap since the last accepted key
24/// exceeds `window`, the old buffer is drained, and the result's
25/// [`expired`](TimedStep::expired) field carries those keys (guaranteed
26/// non-empty when `Some`).
27/// 2. The buffer (now empty if it just expired) accepts the new `key`.
28/// 3. The new-key [`Step`] is returned in [`TimedStep::step`].
29///
30/// **The library does not call `Instant::now()`**; `now` is caller data
31/// (see the crate docs and [`PendingSequence`]).
32///
33/// ## Layout
34///
35/// `TimedPending` is designed to live beside the map it reads:
36///
37/// ```
38/// use keymap_seq::{SequenceKeymap, TimedPending};
39///
40/// struct App<A> {
41/// map: SequenceKeymap<A>,
42/// pending: TimedPending,
43/// }
44/// ```
45///
46/// ## Example
47///
48/// ```
49/// use std::time::{Duration, Instant};
50/// use keymap_core::{Key, KeyInput, Modifiers};
51/// use keymap_seq::{SequenceKeymap, Step, TimedPending, TimedStep};
52///
53/// #[derive(Debug, PartialEq)]
54/// enum Action { NormalMode }
55///
56/// let j = KeyInput::new(Key::Char('j'), Modifiers::NONE);
57/// let mut map = SequenceKeymap::new();
58/// map.bind([j, j], Action::NormalMode).unwrap();
59///
60/// const WINDOW: Duration = Duration::from_millis(500);
61/// let base = Instant::now();
62/// let mut pending = TimedPending::new();
63///
64/// // First j: still a prefix, no expiry yet.
65/// let r1 = pending.feed(&map, j, base, WINDOW);
66/// assert!(r1.expired.is_none());
67/// assert!(matches!(r1.step, Step::Pending));
68///
69/// // Second j arrives within the window: fires.
70/// let r2 = pending.feed(&map, j, base + Duration::from_millis(100), WINDOW);
71/// assert!(r2.expired.is_none());
72/// assert!(matches!(r2.step, Step::Fired(&Action::NormalMode)));
73/// ```
74#[derive(Debug, Clone, Default)]
75pub struct TimedPending {
76 inner: PendingSequence,
77 /// The instant of the last key that extended the live prefix.
78 /// `None` when the buffer is empty.
79 last_key_at: Option<Instant>,
80}
81
82impl TimedPending {
83 /// Creates an empty timed pending buffer.
84 #[must_use]
85 pub fn new() -> Self {
86 TimedPending::default()
87 }
88
89 /// Processes one key with expiry checking, returning the combined result.
90 ///
91 /// The call sequence is:
92 /// 1. **Expiry check**: if the pending buffer is non-empty and
93 /// `now - last_key_at > window`, the buffer is flushed. The flushed keys
94 /// appear in [`TimedStep::expired`] (always non-empty when `Some`).
95 /// 2. **Key processing**: `key` is fed into the (possibly just-reset) buffer
96 /// via [`PendingSequence::feed`], and the result becomes
97 /// [`TimedStep::step`].
98 /// 3. **Clock update**: `last_key_at` is set to `now` when the step is
99 /// [`Step::Pending`] (the buffer grew), or cleared otherwise.
100 ///
101 /// The library never calls `Instant::now()`. Pass the event timestamp from
102 /// your event loop.
103 pub fn feed<'a, A>(
104 &mut self,
105 map: &'a SequenceKeymap<A>,
106 key: KeyInput,
107 now: Instant,
108 window: Duration,
109 ) -> TimedStep<'a, A> {
110 // Step 1: expiry check.
111 let expired = if let Some(last) = self.last_key_at {
112 if now.duration_since(last) > window && !self.inner.is_empty() {
113 let keys = self.inner.flush();
114 self.last_key_at = None;
115 // keys is always non-empty here: we checked `!self.inner.is_empty()`
116 // above and `flush` drains exactly what was pending.
117 debug_assert!(!keys.is_empty(), "flushed buffer must be non-empty");
118 Some(keys)
119 } else {
120 None
121 }
122 } else {
123 None
124 };
125
126 // Step 2: feed the new key.
127 let step = self.inner.feed(map, key);
128
129 // Step 3: update the clock.
130 match &step {
131 Step::Pending => {
132 self.last_key_at = Some(now);
133 }
134 Step::Fired(_) | Step::PassThrough(_) => {
135 self.last_key_at = None;
136 }
137 }
138
139 TimedStep { expired, step }
140 }
141
142 /// The deadline at which a pending prefix expires, or `None` if no keys
143 /// are buffered.
144 ///
145 /// Use this as your event loop's `poll` timeout: if no key arrives before
146 /// `deadline`, call [`flush`](Self::flush) and treat the result as
147 /// pass-through literals.
148 ///
149 /// ```
150 /// use std::time::{Duration, Instant};
151 /// use keymap_core::{Key, KeyInput, Modifiers};
152 /// use keymap_seq::{SequenceKeymap, TimedPending};
153 ///
154 /// #[derive(Debug)] enum Action { Jump }
155 /// let j = KeyInput::new(Key::Char('j'), Modifiers::NONE);
156 /// let mut map = SequenceKeymap::new();
157 /// map.bind([j, j], Action::Jump).unwrap();
158 ///
159 /// const WINDOW: Duration = Duration::from_millis(500);
160 /// let mut pending = TimedPending::new();
161 ///
162 /// // Empty buffer → no deadline.
163 /// assert!(pending.deadline(WINDOW).is_none());
164 ///
165 /// let base = Instant::now();
166 /// pending.feed(&map, j, base, WINDOW);
167 /// // After accepting a prefix key, deadline = base + window.
168 /// assert_eq!(pending.deadline(WINDOW), Some(base + WINDOW));
169 /// ```
170 #[must_use]
171 pub fn deadline(&self, window: Duration) -> Option<Instant> {
172 self.last_key_at.map(|t| t + window)
173 }
174
175 /// Drains the pending buffer, returning the keys held in it, and leaves
176 /// the buffer empty.
177 ///
178 /// This is the idle flush: call it when `deadline` has passed with no
179 /// further key. The returned keys are literals the caller now owns (the
180 /// dangling `j` of a half-typed `jj`). Flushing an empty buffer returns
181 /// an empty `Vec`.
182 pub fn flush(&mut self) -> Vec<KeyInput> {
183 self.last_key_at = None;
184 self.inner.flush()
185 }
186
187 /// The keys buffered so far, in press order.
188 #[must_use]
189 pub fn pending(&self) -> &[KeyInput] {
190 self.inner.pending()
191 }
192
193 /// Returns `true` if no keys are buffered.
194 #[must_use]
195 pub fn is_empty(&self) -> bool {
196 self.inner.is_empty()
197 }
198}
199
200/// The result of a single [`TimedPending::feed`] call.
201///
202/// This is an **exhaustive struct** — adding a field is a breaking change,
203/// chosen deliberately so the compiler catches unhandled cases (the same
204/// design principle behind `Match` and `Step` being exhaustive enums). The
205/// `#[must_use]` attribute ensures the caller cannot silently discard it.
206///
207/// ## Exhaustiveness regression test
208///
209/// The following doctest destructures `TimedStep` without `..` so that adding
210/// a new field causes a compile error — making any future field addition a
211/// self-aware, visible breaking change:
212///
213/// ```
214/// use keymap_seq::TimedStep;
215/// use keymap_seq::Step;
216///
217/// fn handle<A>(ts: TimedStep<'_, A>) {
218/// // Destructure every field — no `..` — so a future field addition
219/// // is a compile error (exhaustiveness regression test).
220/// let TimedStep { expired, step } = ts;
221/// let _ = expired;
222/// let _ = step;
223/// }
224/// ```
225#[must_use]
226#[derive(Debug)]
227pub struct TimedStep<'a, A> {
228 /// Keys from the buffer that was discarded because the inter-key gap
229 /// exceeded the window. **When `Some`, the `Vec` is guaranteed non-empty.**
230 /// `None` means the previous key (if any) arrived within the window.
231 pub expired: Option<Vec<KeyInput>>,
232 /// The outcome of processing the new key after the expiry check.
233 pub step: Step<'a, A>,
234}
235
236#[cfg(test)]
237mod tests {
238 use std::time::{Duration, Instant};
239
240 use keymap_core::{Key, KeyInput, Modifiers};
241
242 use super::*;
243 use crate::{SequenceKeymap, Step};
244
245 fn plain(c: char) -> KeyInput {
246 KeyInput::new(Key::Char(c), Modifiers::NONE)
247 }
248
249 /// A deterministic clock: a fixed base Instant plus Duration offsets.
250 /// No `sleep`, no `Instant::now()` in tests.
251 fn t(base: Instant, ms: u64) -> Instant {
252 base + Duration::from_millis(ms)
253 }
254
255 const WINDOW: Duration = Duration::from_millis(500);
256
257 /// `[j, j]` → `NormalMode`
258 fn jj_map() -> SequenceKeymap<&'static str> {
259 let j = plain('j');
260 let mut map = SequenceKeymap::new();
261 map.bind([j, j], "normal").unwrap();
262 map
263 }
264
265 // ---- ① within-window continuation ---------------------------------
266
267 #[test]
268 fn within_window_step_is_pending_no_expiry() {
269 let map = jj_map();
270 let base = Instant::now();
271 let mut tp = TimedPending::new();
272
273 let r = tp.feed(&map, plain('j'), t(base, 0), WINDOW);
274 assert!(r.expired.is_none(), "no expiry on first key");
275 assert!(matches!(r.step, Step::Pending));
276 assert_eq!(tp.pending(), &[plain('j')]);
277 }
278
279 #[test]
280 fn quick_second_key_fires_with_no_expiry() {
281 let map = jj_map();
282 let base = Instant::now();
283 let mut tp = TimedPending::new();
284
285 let _ = tp.feed(&map, plain('j'), t(base, 0), WINDOW);
286 let r = tp.feed(&map, plain('j'), t(base, 100), WINDOW); // 100ms < 500ms
287 assert!(r.expired.is_none());
288 assert!(matches!(r.step, Step::Fired(&"normal")));
289 assert!(tp.is_empty());
290 }
291
292 // ---- ② expiry → expired is Some(non-empty) + new key starts fresh --
293
294 #[test]
295 fn slow_second_key_produces_expired_and_new_step() {
296 let map = jj_map();
297 let base = Instant::now();
298 let mut tp = TimedPending::new();
299
300 let _ = tp.feed(&map, plain('j'), t(base, 0), WINDOW);
301 // 600ms gap > 500ms window → expiry
302 let r = tp.feed(&map, plain('j'), t(base, 600), WINDOW);
303
304 // expired carries the old buffer (the first 'j')
305 let expired = r.expired.expect("must have expired keys");
306 assert!(!expired.is_empty(), "Some(v) must be non-empty");
307 assert_eq!(expired, vec![plain('j')]);
308
309 // The new 'j' started a fresh prefix.
310 assert!(matches!(r.step, Step::Pending));
311 assert_eq!(tp.pending(), &[plain('j')]);
312 }
313
314 #[test]
315 fn expired_vec_is_never_empty_some() {
316 // Invariant: if expired is Some, the Vec is non-empty.
317 let map = jj_map();
318 let base = Instant::now();
319 let mut tp = TimedPending::new();
320
321 let _ = tp.feed(&map, plain('j'), t(base, 0), WINDOW);
322 let r = tp.feed(&map, plain('j'), t(base, 600), WINDOW);
323 if let Some(v) = r.expired {
324 assert!(!v.is_empty(), "Some(expired) must never be empty");
325 }
326 }
327
328 // ---- ③ deadline calculation ----------------------------------------
329
330 #[test]
331 fn deadline_after_prefix_equals_last_key_plus_window() {
332 let map = jj_map();
333 let base = Instant::now();
334 let mut tp = TimedPending::new();
335
336 let _ = tp.feed(&map, plain('j'), t(base, 0), WINDOW);
337 assert_eq!(tp.deadline(WINDOW), Some(t(base, 0) + WINDOW));
338 }
339
340 #[test]
341 fn deadline_advances_on_each_pending_key() {
342 // With a length-3 sequence: each Pending key updates last_key_at, so
343 // deadline reflects the *latest* pending key's timestamp.
344 let mut map = SequenceKeymap::new();
345 map.bind([plain('a'), plain('b'), plain('c')], "abc")
346 .unwrap();
347 let base = Instant::now();
348 let mut tp = TimedPending::new();
349
350 let _ = tp.feed(&map, plain('a'), t(base, 0), WINDOW);
351 assert_eq!(tp.deadline(WINDOW), Some(t(base, 0) + WINDOW));
352
353 let _ = tp.feed(&map, plain('b'), t(base, 200), WINDOW);
354 assert_eq!(tp.deadline(WINDOW), Some(t(base, 200) + WINDOW));
355 }
356
357 // ---- ④ empty buffer deadline = None --------------------------------
358
359 #[test]
360 fn deadline_is_none_when_buffer_is_empty() {
361 let tp = TimedPending::new();
362 assert!(tp.deadline(WINDOW).is_none());
363 }
364
365 #[test]
366 fn deadline_is_none_after_fired() {
367 let map = jj_map();
368 let base = Instant::now();
369 let mut tp = TimedPending::new();
370 let _ = tp.feed(&map, plain('j'), t(base, 0), WINDOW);
371 let _ = tp.feed(&map, plain('j'), t(base, 100), WINDOW); // fires
372 assert!(tp.deadline(WINDOW).is_none());
373 }
374
375 #[test]
376 fn deadline_is_none_after_passthrough() {
377 let map = jj_map();
378 let base = Instant::now();
379 let mut tp = TimedPending::new();
380 let _ = tp.feed(&map, plain('j'), t(base, 0), WINDOW);
381 let _ = tp.feed(&map, plain('x'), t(base, 100), WINDOW); // PassThrough
382 assert!(tp.deadline(WINDOW).is_none());
383 }
384
385 // ---- ⑤ Some ⇒ non-empty invariant (stress) ------------------------
386
387 #[test]
388 fn some_expired_is_always_non_empty_across_a_stream() {
389 let map = jj_map();
390 let base = Instant::now();
391 let mut tp = TimedPending::new();
392
393 let stream = [
394 (plain('j'), 0u64),
395 (plain('j'), 600), // expiry: first j, then second j is Prefix
396 (plain('j'), 1200), // expiry again: third j expired, fourth starts
397 (plain('j'), 1300), // quick: fires
398 ];
399 for (key, ms) in stream {
400 let r = tp.feed(&map, key, t(base, ms), WINDOW);
401 if let Some(v) = r.expired {
402 assert!(
403 !v.is_empty(),
404 "Some(expired) must never be empty at ms={ms}"
405 );
406 }
407 }
408 }
409
410 // ---- ⑥ conservation: no key lost or duplicated ---------------------
411
412 #[test]
413 fn no_key_lost_or_duplicated_across_mixed_stream() {
414 // A stream with one quick pair (fires), one slow pair (expiry + fresh
415 // start), and a trailing flush. Every input key must appear exactly once
416 // in the outputs.
417 let map = jj_map();
418 let base = Instant::now();
419 let mut tp = TimedPending::new();
420
421 // j@0, j@100 → fires NormalMode (2 keys consumed)
422 // j@700, j@1400 → expiry at second j (j@700 in expired), j@1400 is Prefix
423 // trailing flush → j@1400 as literal
424 let stream = [
425 (plain('j'), 0u64),
426 (plain('j'), 100),
427 (plain('j'), 700),
428 (plain('j'), 1400),
429 ];
430
431 let mut output: Vec<KeyInput> = Vec::new();
432 for (key, ms) in stream {
433 let r = tp.feed(&map, key, t(base, ms), WINDOW);
434 if let Some(expired) = r.expired {
435 output.extend(expired);
436 }
437 match r.step {
438 Step::Fired(_) => {
439 // The two keys that fired are "consumed" — track them.
440 // We already know which stream position this was; for
441 // conservation we just note the fire consumed 2 keys.
442 // Simpler: add j+j to output to represent them.
443 output.push(plain('j'));
444 output.push(plain('j'));
445 }
446 Step::PassThrough(keys) => output.extend(keys),
447 Step::Pending => {}
448 }
449 }
450 // trailing flush
451 output.extend(tp.flush());
452
453 // Every input key must appear exactly once in output: total count matches.
454 assert_eq!(output.len(), stream.len());
455 }
456
457 // ---- flush ----------------------------------------------------------
458
459 #[test]
460 fn flush_drains_pending_and_clears_clock() {
461 let map = jj_map();
462 let base = Instant::now();
463 let mut tp = TimedPending::new();
464 let _ = tp.feed(&map, plain('j'), t(base, 0), WINDOW);
465 assert!(!tp.is_empty());
466 assert!(tp.deadline(WINDOW).is_some());
467 let flushed = tp.flush();
468 assert_eq!(flushed, vec![plain('j')]);
469 assert!(tp.is_empty());
470 assert!(tp.deadline(WINDOW).is_none());
471 }
472
473 #[test]
474 fn flush_empty_is_no_op() {
475 let mut tp = TimedPending::new();
476 assert_eq!(tp.flush(), Vec::<KeyInput>::new());
477 assert!(tp.is_empty());
478 }
479
480 // ---- ⑦ gap == WINDOW boundary (strict >, not >=) -------------------
481
482 /// A gap of exactly `WINDOW` (500ms) must NOT trigger expiry — only a gap
483 /// strictly greater than `WINDOW` does. This pins the `> window` boundary
484 /// so that a future change to `>= window` would be caught.
485 #[test]
486 fn gap_exactly_equal_to_window_does_not_expire() {
487 let map = jj_map();
488 let base = Instant::now();
489 let mut tp = TimedPending::new();
490
491 // First key at t=0 enters a pending prefix.
492 let r1 = tp.feed(&map, plain('j'), t(base, 0), WINDOW);
493 assert!(r1.expired.is_none(), "no expiry on first key");
494 assert!(matches!(r1.step, Step::Pending));
495
496 // Second key at t=500ms (exactly WINDOW). The gap is 500ms which is NOT
497 // strictly greater than WINDOW (500ms), so expiry must not fire.
498 let r2 = tp.feed(&map, plain('j'), t(base, 500), WINDOW);
499 assert!(
500 r2.expired.is_none(),
501 "gap == WINDOW must not expire (boundary is strict >): expired={:?}",
502 r2.expired
503 );
504 // The two j's form the complete sequence → fires.
505 assert!(
506 matches!(r2.step, Step::Fired(&"normal")),
507 "j j with gap==WINDOW should fire: {:?}",
508 r2.step
509 );
510 }
511}