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;