Skip to main content

rmux_sdk/
input.rs

1//! Inert input vocabulary for SDK consumers.
2//!
3//! This module is the public SDK home for the structured key event DTOs
4//! that callers exchange with `rmux-client`/`rmux-server` style attach
5//! integrations. The types here are deliberately framework-agnostic value
6//! objects: they do not own a terminal, do not subscribe to keyboard
7//! sources, and never sleep on a clock.
8//!
9//! `rmux-sdk` users obtain the entire input vocabulary through the
10//! `rmux_sdk` re-exports without depending on `rmux-core`,
11//! `rmux-server`, `rmux-client`, or `rmux-pty`. The detach chord detector
12//! is deterministic by construction: every state transition is driven by
13//! caller-supplied [`std::time::Instant`] timestamps, so unit tests can
14//! exercise prefix-held, mismatch-forward, chord-success, and timeout
15//! behaviour without sleeping or touching a real keyboard.
16//!
17//! When the optional `crossterm` SDK feature is enabled, the module
18//! gains lossless conversions from `crossterm::event::KeyEvent` /
19//! `KeyCode` / `KeyModifiers` so SDK consumers can adapt a
20//! crossterm-driven input loop without leaking that dependency through
21//! the default workspace build.
22
23use std::time::{Duration, Instant};
24
25use serde::{Deserialize, Deserializer, Serialize};
26
27/// Modifier flags that may accompany an SDK [`KeyEvent`].
28///
29/// The bitfield mirrors the modifiers that survive tmux-compatible attach
30/// translation (Shift, Control, Alt, Super, Hyper, Meta) without adopting
31/// any single host library's encoding. Unknown bits are rejected at
32/// construction time so deserialized values cannot smuggle reserved bits
33/// through the SDK boundary.
34#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
35#[serde(transparent)]
36pub struct KeyModifiers {
37    bits: u8,
38}
39
40impl<'de> Deserialize<'de> for KeyModifiers {
41    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42    where
43        D: Deserializer<'de>,
44    {
45        let bits = u8::deserialize(deserializer)?;
46        Self::from_bits(bits).ok_or_else(|| {
47            serde::de::Error::custom(format_args!(
48                "KeyModifiers value {bits:#010b} sets bits outside the valid mask {:#010b}",
49                Self::VALID_MASK
50            ))
51        })
52    }
53}
54
55impl KeyModifiers {
56    /// No modifiers held.
57    pub const NONE: Self = Self { bits: 0 };
58    /// Shift modifier flag.
59    pub const SHIFT: Self = Self { bits: 0b0000_0001 };
60    /// Control modifier flag.
61    pub const CONTROL: Self = Self { bits: 0b0000_0010 };
62    /// Alt (Option on macOS) modifier flag.
63    pub const ALT: Self = Self { bits: 0b0000_0100 };
64    /// Super (Command/Windows) modifier flag.
65    pub const SUPER: Self = Self { bits: 0b0000_1000 };
66    /// Hyper modifier flag.
67    pub const HYPER: Self = Self { bits: 0b0001_0000 };
68    /// Meta modifier flag.
69    pub const META: Self = Self { bits: 0b0010_0000 };
70
71    const VALID_MASK: u8 = 0b0011_1111;
72
73    /// Returns the empty modifier set.
74    #[must_use]
75    pub const fn empty() -> Self {
76        Self::NONE
77    }
78
79    /// Returns the raw bitfield representation.
80    #[must_use]
81    pub const fn bits(self) -> u8 {
82        self.bits
83    }
84
85    /// Constructs modifiers from a bitfield, rejecting reserved bits.
86    #[must_use]
87    pub const fn from_bits(bits: u8) -> Option<Self> {
88        if (bits & !Self::VALID_MASK) == 0 {
89            Some(Self { bits })
90        } else {
91            None
92        }
93    }
94
95    /// Constructs modifiers from a bitfield, dropping any reserved bits.
96    #[must_use]
97    pub const fn from_bits_truncate(bits: u8) -> Self {
98        Self {
99            bits: bits & Self::VALID_MASK,
100        }
101    }
102
103    /// Returns `true` when this set is empty.
104    #[must_use]
105    pub const fn is_empty(self) -> bool {
106        self.bits == 0
107    }
108
109    /// Returns `true` when every bit in `other` is also set in `self`.
110    #[must_use]
111    pub const fn contains(self, other: Self) -> bool {
112        (self.bits & other.bits) == other.bits
113    }
114
115    /// Returns the union of `self` and `other`.
116    #[must_use]
117    pub const fn union(self, other: Self) -> Self {
118        Self {
119            bits: self.bits | other.bits,
120        }
121    }
122
123    /// Returns the intersection of `self` and `other`.
124    #[must_use]
125    pub const fn intersection(self, other: Self) -> Self {
126        Self {
127            bits: self.bits & other.bits,
128        }
129    }
130
131    /// Returns the symmetric difference of `self` and `other`.
132    #[must_use]
133    pub const fn symmetric_difference(self, other: Self) -> Self {
134        Self {
135            bits: self.bits ^ other.bits,
136        }
137    }
138}
139
140impl std::ops::BitOr for KeyModifiers {
141    type Output = Self;
142
143    fn bitor(self, rhs: Self) -> Self {
144        self.union(rhs)
145    }
146}
147
148impl std::ops::BitAnd for KeyModifiers {
149    type Output = Self;
150
151    fn bitand(self, rhs: Self) -> Self {
152        self.intersection(rhs)
153    }
154}
155
156impl std::ops::BitXor for KeyModifiers {
157    type Output = Self;
158
159    fn bitxor(self, rhs: Self) -> Self {
160        self.symmetric_difference(rhs)
161    }
162}
163
164/// Structured key code carried by an SDK [`KeyEvent`].
165///
166/// The variants cover the keys the SDK promises to forward across the
167/// attach boundary. Variants that depend on platform-specific keyboard
168/// enhancements (media keys, scroll lock, lock-state reporting) are
169/// intentionally collapsed into the generic [`KeyCode::Char`] /
170/// [`KeyCode::F`] surface so SDK users do not branch on host
171/// idiosyncrasies.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
173#[non_exhaustive]
174pub enum KeyCode {
175    /// Unicode character key (lower-case form unless Shift is set).
176    Char(char),
177    /// Function key, F1..=F35.
178    F(u8),
179    /// Backspace key.
180    Backspace,
181    /// Enter / Return key.
182    Enter,
183    /// Left arrow key.
184    Left,
185    /// Right arrow key.
186    Right,
187    /// Up arrow key.
188    Up,
189    /// Down arrow key.
190    Down,
191    /// Home key.
192    Home,
193    /// End key.
194    End,
195    /// Page up key.
196    PageUp,
197    /// Page down key.
198    PageDown,
199    /// Tab key.
200    Tab,
201    /// Shift+Tab / back-tab key.
202    BackTab,
203    /// Delete key.
204    Delete,
205    /// Insert key.
206    Insert,
207    /// Escape key.
208    Esc,
209}
210
211/// SDK-facing key event DTO.
212///
213/// `KeyEvent` is intentionally inert: constructing one does not arm a
214/// detector, push a frame, or open a daemon connection. SDK consumers
215/// build these from their own keyboard source (or via the optional
216/// `crossterm` feature) and feed them into helpers like
217/// [`DetachDetector::feed`].
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
219pub struct KeyEvent {
220    /// Logical key code.
221    pub code: KeyCode,
222    /// Active modifier flags when the key was reported.
223    pub modifiers: KeyModifiers,
224}
225
226impl KeyEvent {
227    /// Constructs an event from a code and modifier set.
228    #[must_use]
229    pub const fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
230        Self { code, modifiers }
231    }
232
233    /// Constructs a modifier-free event from a code.
234    #[must_use]
235    pub const fn bare(code: KeyCode) -> Self {
236        Self::new(code, KeyModifiers::NONE)
237    }
238
239    /// Constructs a `Ctrl+`-modified character event.
240    #[must_use]
241    pub const fn ctrl(ch: char) -> Self {
242        Self::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
243    }
244}
245
246/// Two-key sequence that requests a client detach.
247///
248/// `prefix` is the leader key (typically `Ctrl+B`) and `detach` is the
249/// follow-up key (typically `d`). Equality semantics for both fields are
250/// the SDK [`KeyEvent`] equality, so an event matches a slot only when
251/// both code and modifier set agree.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
253pub struct DetachChord {
254    /// Leader key event that arms the detector.
255    pub prefix: KeyEvent,
256    /// Follow-up key event that triggers detach when seen after the prefix.
257    pub detach: KeyEvent,
258}
259
260impl DetachChord {
261    /// The tmux-default `Ctrl+B`, `d` chord.
262    #[must_use]
263    pub const fn tmux_default() -> Self {
264        Self {
265            prefix: KeyEvent::ctrl('b'),
266            detach: KeyEvent::bare(KeyCode::Char('d')),
267        }
268    }
269
270    /// Constructs a chord from explicit prefix/detach events.
271    #[must_use]
272    pub const fn new(prefix: KeyEvent, detach: KeyEvent) -> Self {
273        Self { prefix, detach }
274    }
275}
276
277/// Outcome of a single [`DetachDetector::feed`] or
278/// [`DetachDetector::tick`] call.
279///
280/// `Forward` carries the events the host should forward to the attached
281/// pane. `Armed` indicates the detector has consumed the prefix and is
282/// waiting for the follow-up key inside the timeout window.
283/// `DetachRequested` indicates the chord matched and the host should
284/// invoke its own explicit detach action; the detector itself never
285/// performs side effects on the host's behalf.
286///
287/// `DetachRequested` is purely a signal. The detector returns the
288/// chord-completion verdict; the host owns whether (and how) to actually
289/// detach the attached client. A host that ignores `DetachRequested`
290/// observes no further state from the detector — the detector has
291/// already returned to idle and is ready for a fresh chord cycle.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum DetachOutcome {
294    /// Forward this exact list of events to the attached pane.
295    Forward(Vec<KeyEvent>),
296    /// Detector swallowed the prefix and is waiting for the follow-up.
297    Armed,
298    /// Chord matched; host should perform the detach action.
299    DetachRequested,
300}
301
302/// Internal detector state, kept private so the only mutation paths are
303/// the public `feed`/`tick`/`reset` methods.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305enum DetectorState {
306    Idle,
307    PrefixHeld { since: Instant },
308}
309
310/// Deterministic detach-chord detector.
311///
312/// `feed` and `tick` accept caller-supplied timestamps so unit tests can
313/// drive every state transition without sleeping. The detector is purely
314/// a state machine: it never spawns threads, never reads from a terminal,
315/// and never owns a clock of its own.
316///
317/// # Contract
318///
319/// The detector's behaviour is fully specified by the following rules:
320///
321/// 1. **Strict code+modifier equality.** A key matches the chord's
322///    `prefix` (or `detach`) slot only when both [`KeyCode`] and the
323///    full [`KeyModifiers`] bitfield are byte-for-byte equal to the
324///    configured event. `Ctrl+B` does not match `Ctrl+Shift+B`.
325/// 2. **Prefix swallowing.** While idle, observing the prefix transitions
326///    the detector to `PrefixHeld` and returns
327///    [`DetachOutcome::Armed`]; the prefix is consumed and is *not*
328///    forwarded to the pane until the timeout lapses or a mismatch is
329///    seen.
330/// 3. **Chord completion.** While `PrefixHeld`, observing the detach
331///    follow-up returns [`DetachOutcome::DetachRequested`] and the
332///    detector returns to idle without forwarding anything.
333/// 4. **Mismatch forwarding.** While `PrefixHeld`, observing any other
334///    event (including the prefix again) returns
335///    `DetachOutcome::Forward(vec![prefix, event])` in that order, and
336///    the detector returns to idle.
337/// 5. **Timeout flushing.** A `feed` or [`tick`](Self::tick) call where
338///    `now.saturating_duration_since(prefix_arrival) >= timeout` flushes
339///    the held prefix as `Forward(vec![prefix])` and returns the
340///    detector to idle. For [`feed`](Self::feed), the new event is then
341///    processed against the now-idle detector and any extra forwarded
342///    events are appended after the flushed prefix.
343/// 6. **Zero-timeout edge case.** A `Duration::ZERO` timeout means any
344///    observation strictly after the prefix is treated as expired
345///    (`>=` is the comparison): the detector flushes the prefix and
346///    forwards the new event without ever firing the chord. Hosts that
347///    want chord behaviour must configure a non-zero timeout.
348/// 7. **Equal prefix/detach edge case.** If a chord is configured with
349///    `prefix == detach`, the detach branch is checked first while
350///    `PrefixHeld`, so pressing the shared key twice quickly enough
351///    returns `DetachRequested`.
352/// 8. **Reusability.** The detector is fully reusable after every
353///    terminal outcome: hosts may keep a single detector across
354///    sessions or runs. After `DetachRequested`, the detector is idle
355///    and a subsequent `tick` returns `Forward(vec![])`.
356#[derive(Debug, Clone)]
357pub struct DetachDetector {
358    chord: DetachChord,
359    timeout: Duration,
360    state: DetectorState,
361}
362
363impl DetachDetector {
364    /// Default chord-completion window matching tmux's interactive feel.
365    pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(1_000);
366
367    /// Constructs a detector for the given chord with [`Self::DEFAULT_TIMEOUT`].
368    #[must_use]
369    pub const fn new(chord: DetachChord) -> Self {
370        Self::with_timeout(chord, Self::DEFAULT_TIMEOUT)
371    }
372
373    /// Constructs a detector with an explicit timeout window.
374    #[must_use]
375    pub const fn with_timeout(chord: DetachChord, timeout: Duration) -> Self {
376        Self {
377            chord,
378            timeout,
379            state: DetectorState::Idle,
380        }
381    }
382
383    /// Returns the chord this detector matches.
384    #[must_use]
385    pub const fn chord(&self) -> &DetachChord {
386        &self.chord
387    }
388
389    /// Returns the configured chord-completion timeout.
390    #[must_use]
391    pub const fn timeout(&self) -> Duration {
392        self.timeout
393    }
394
395    /// Returns `true` while the detector has consumed the prefix and is
396    /// waiting for the follow-up key.
397    #[must_use]
398    pub const fn is_prefix_armed(&self) -> bool {
399        matches!(self.state, DetectorState::PrefixHeld { .. })
400    }
401
402    /// Resets the detector back to idle without forwarding anything.
403    pub fn reset(&mut self) {
404        self.state = DetectorState::Idle;
405    }
406
407    /// Feeds an event into the detector and returns the outcome.
408    ///
409    /// `now` is the timestamp at which the event is observed. Tests pass
410    /// a deterministic `Instant` so timeout edges can be exercised
411    /// precisely. The detector never blocks and never reads `Instant::now()`
412    /// internally.
413    #[must_use]
414    pub fn feed(&mut self, event: KeyEvent, now: Instant) -> DetachOutcome {
415        if let DetectorState::PrefixHeld { since } = self.state {
416            if now.saturating_duration_since(since) >= self.timeout {
417                self.state = DetectorState::Idle;
418                let mut forwarded = vec![self.chord.prefix];
419                match self.process_idle(event, now) {
420                    DetachOutcome::Forward(extra) => forwarded.extend(extra),
421                    // `process_idle` re-armed on the new prefix and produced
422                    // no extra output; the caller still observes the flushed
423                    // expired prefix.
424                    DetachOutcome::Armed => {}
425                    DetachOutcome::DetachRequested => {
426                        unreachable!("process_idle never returns DetachRequested from idle state",)
427                    }
428                }
429                return DetachOutcome::Forward(forwarded);
430            }
431        }
432
433        match self.state {
434            DetectorState::Idle => self.process_idle(event, now),
435            DetectorState::PrefixHeld { .. } => self.process_prefix_held(event),
436        }
437    }
438
439    /// Advances the detector's clock without consuming an input event.
440    ///
441    /// Hosts call this when they receive a non-key wakeup (poll loop tick,
442    /// resize event, etc.) so the detector can release a held prefix once
443    /// the timeout has lapsed. Returns `Forward(vec![prefix])` when the
444    /// timeout has elapsed; otherwise returns the current state.
445    #[must_use]
446    pub fn tick(&mut self, now: Instant) -> DetachOutcome {
447        match self.state {
448            DetectorState::Idle => DetachOutcome::Forward(Vec::new()),
449            DetectorState::PrefixHeld { since } => {
450                if now.saturating_duration_since(since) >= self.timeout {
451                    self.state = DetectorState::Idle;
452                    DetachOutcome::Forward(vec![self.chord.prefix])
453                } else {
454                    DetachOutcome::Armed
455                }
456            }
457        }
458    }
459
460    fn process_idle(&mut self, event: KeyEvent, now: Instant) -> DetachOutcome {
461        if event == self.chord.prefix {
462            self.state = DetectorState::PrefixHeld { since: now };
463            DetachOutcome::Armed
464        } else {
465            DetachOutcome::Forward(vec![event])
466        }
467    }
468
469    fn process_prefix_held(&mut self, event: KeyEvent) -> DetachOutcome {
470        if event == self.chord.detach {
471            self.state = DetectorState::Idle;
472            return DetachOutcome::DetachRequested;
473        }
474        self.state = DetectorState::Idle;
475        DetachOutcome::Forward(vec![self.chord.prefix, event])
476    }
477}
478
479/// Errors produced when converting a foreign key event into the SDK
480/// vocabulary.
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
482#[non_exhaustive]
483pub enum KeyConversionError {
484    /// The foreign event used a key code variant that the SDK does not
485    /// model (for example a media key when no enhancement flags were
486    /// negotiated).
487    UnsupportedKeyCode(&'static str),
488    /// The foreign event used modifier bits the SDK does not model.
489    UnsupportedModifier(&'static str),
490    /// The foreign event reported a key release/repeat the SDK ignores.
491    NonPressEvent,
492}
493
494impl std::fmt::Display for KeyConversionError {
495    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496        match self {
497            Self::UnsupportedKeyCode(name) => {
498                write!(f, "unsupported foreign key code: {name}")
499            }
500            Self::UnsupportedModifier(name) => {
501                write!(f, "unsupported foreign modifier: {name}")
502            }
503            Self::NonPressEvent => f.write_str("foreign event was not a key press"),
504        }
505    }
506}
507
508impl std::error::Error for KeyConversionError {}
509
510#[cfg(feature = "crossterm")]
511mod crossterm_compat;