Skip to main content

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}