Skip to main content

keymap_core/
passthrough.rs

1//! PTY raw-byte passthrough resolution.
2//!
3//! [`resolve_layered`](crate::resolve_layered) answers "what action, if any, is
4//! bound?" and collapses a miss to [`None`]. That is the whole story for a
5//! same-process app. A terminal *multiplexer* needs one thing more: when no layer
6//! binds a key, the original bytes must reach the child process (the PTY) exactly
7//! as they arrived. This module adds that path without disturbing the pure
8//! resolver.
9//!
10//! [`resolve_passthrough`] is the raw-byte-carrying sibling of `resolve_layered`:
11//! same first-hit-wins layer resolution, but a miss carries the original bytes out
12//! as [`Resolution::Passthrough`] instead of vanishing into `None`. The PTY is a
13//! **sink past the end of the scope chain**, never a layer in it — a key bound in
14//! any layer wins, and only genuinely unbound keys fall through to it, so the PTY
15//! can never swallow the app's reserved keys.
16//!
17//! # `Consume` is the caller's to assign
18//!
19//! The third disposition, [`Resolution::Consume`] (swallow a key with no action —
20//! a modal/input grab that neither runs an action nor forwards bytes), is a
21//! function of caller *mode* state (am I grabbing right now?), which this
22//! state-free library does not hold. So `resolve_passthrough` never returns it: it
23//! returns only [`Action`](Resolution::Action) and [`Passthrough`](Resolution::Passthrough),
24//! and a grabbing caller maps a `Passthrough` to `Consume` itself. `Consume` lives
25//! in the enum so the caller's `match` is exhaustive (the compiler forces a
26//! decision for every key), not because the resolver manufactures it.
27
28use crate::input::KeyInput;
29use crate::keymap::{Keymap, resolve_layered};
30
31/// The original, pre-decode bytes of a key press, borrowed from the caller's read
32/// buffer, to be forwarded verbatim when no layer binds the input.
33///
34/// `RawInput` is a *view*, not a copy: it borrows the exact `bytes[..consumed]`
35/// span the decoder reported (see `keymap_term::Decoded`), so forwarding it to the
36/// sink is a byte-identity copy with no re-encoding. It is constructed only from
37/// real bytes via [`RawInput::from_bytes`]; there is deliberately **no**
38/// `From<KeyInput>`. Decode is lossy (SHIFT folding, `BackTab` ≡ `shift+tab`,
39/// `ctrl+i` ≡ `tab`, and some keys produce no `KeyInput` at all), so re-encoding a
40/// decoded key would corrupt or inject bytes. The borrow representation makes that
41/// footgun *structurally impossible* — there is no owned buffer for a `From` impl
42/// to return a reference into:
43///
44/// ```compile_fail
45/// use keymap_core::{Key, KeyInput, Modifiers, RawInput};
46///
47/// let decoded = KeyInput::new(Key::Char('a'), Modifiers::NONE);
48/// // There is no way back from a decoded key to its raw bytes — decode is lossy.
49/// let _raw: RawInput = RawInput::from(decoded); // ERROR: `From<KeyInput>` does not exist
50/// ```
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct RawInput<'a>(&'a [u8]);
53
54impl<'a> RawInput<'a> {
55    /// Wraps the verbatim bytes the caller read for this press (the
56    /// `bytes[..consumed]` span the decoder reported).
57    #[must_use]
58    pub fn from_bytes(bytes: &'a [u8]) -> Self {
59        RawInput(bytes)
60    }
61
62    /// The borrowed bytes, for forwarding verbatim to the sink.
63    #[must_use]
64    pub fn as_bytes(&self) -> &'a [u8] {
65        self.0
66    }
67}
68
69/// What should happen to a key press, for a caller that forwards unbound input to
70/// a terminal sink (a PTY). The disposition counterpart to
71/// [`resolve_layered`](crate::resolve_layered)'s bare `Option<&A>`.
72///
73/// The three dispositions — run an action, forward the bytes verbatim, swallow the
74/// key — are the complete set, so this enum is **exhaustive on purpose** (the
75/// deliberate twin of `keymap_seq::Match`). [`Consume`](Resolution::Consume) ships
76/// from day one rather than hiding behind `#[non_exhaustive]`, whose "compiles but
77/// a key is silently misrouted" trap is exactly the failure to avoid when routing
78/// input. Callers `match` all three arms with the compiler checking coverage.
79///
80/// The `'a` lifetime is shared by the borrowed action and the borrowed bytes: a
81/// `Resolution` is meant to be consumed within the event-loop turn that produced
82/// it (run the action, or write the bytes to the sink), so tying both borrows to
83/// one region is sufficient. A caller that needs to outlive the read buffer copies
84/// the bytes out at that boundary (`raw.as_bytes().to_vec()`).
85///
86/// Because the enum is exhaustive, a caller can `match` every arm with no wildcard
87/// — and this stays a compile-time guarantee. The day someone makes `Resolution`
88/// `#[non_exhaustive]`, this very example stops compiling:
89///
90/// ```
91/// use keymap_core::Resolution;
92///
93/// fn describe(r: Resolution<'_, u8>) -> &'static str {
94///     match r {
95///         Resolution::Action(_) => "action",
96///         Resolution::Passthrough(_) => "passthrough",
97///         Resolution::Consume => "consume",
98///     }
99/// }
100/// assert_eq!(describe(Resolution::Consume), "consume");
101/// ```
102#[derive(Debug, PartialEq, Eq)]
103#[must_use]
104pub enum Resolution<'a, A> {
105    /// A layer bound the input; run this action.
106    Action(&'a A),
107    /// No layer bound the input; forward these original bytes to the sink
108    /// verbatim.
109    Passthrough(RawInput<'a>),
110    /// Swallow the key with no action and do not forward it — a modal/input grab.
111    /// [`resolve_passthrough`] never returns this; a grabbing caller assigns it
112    /// from its own mode state (mapping what would be a `Passthrough`).
113    Consume,
114}
115
116/// Resolves `input` against an ordered list of layers for a caller that forwards
117/// unbound keys to a terminal sink (a PTY), carrying the original `raw` bytes so a
118/// miss can be forwarded verbatim.
119///
120/// This is the raw-byte-carrying sibling of
121/// [`resolve_layered`](crate::resolve_layered): it runs the same first-hit-wins
122/// resolution, returning [`Resolution::Action`] on a hit and
123/// [`Resolution::Passthrough`] (wrapping `raw`) on a miss in every layer. It
124/// **never** returns [`Resolution::Consume`] — see the [module docs](self): a
125/// modal grab is caller mode state, so the caller maps a `Passthrough` to
126/// `Consume` when it is grabbing.
127///
128/// ```
129/// use keymap_core::{
130///     resolve_passthrough, Key, KeyInput, Keymap, Modifiers, RawInput, Resolution,
131/// };
132///
133/// #[derive(PartialEq, Debug)]
134/// enum Action { Quit }
135///
136/// let ctrl_q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
137/// let mut base = Keymap::new();
138/// base.bind(ctrl_q.clone(), Action::Quit);
139///
140/// // A bound key resolves to its action.
141/// let raw = RawInput::from_bytes(&[0x11]); // ctrl+q as a C0 byte
142/// assert_eq!(
143///     resolve_passthrough([&base], &ctrl_q, raw),
144///     Resolution::Action(&Action::Quit),
145/// );
146///
147/// // An unbound key carries its bytes out for verbatim forwarding.
148/// let letter = KeyInput::new(Key::Char('a'), Modifiers::NONE);
149/// let raw = RawInput::from_bytes(b"a");
150/// match resolve_passthrough([&base], &letter, raw) {
151///     Resolution::Passthrough(bytes) => assert_eq!(bytes.as_bytes(), b"a"),
152///     other => panic!("expected passthrough, got {other:?}"),
153/// }
154/// ```
155pub fn resolve_passthrough<'a, A, L>(
156    layers: L,
157    input: &KeyInput,
158    raw: RawInput<'a>,
159) -> Resolution<'a, A>
160where
161    L: IntoIterator<Item = &'a Keymap<A>>,
162    A: 'a,
163{
164    match resolve_layered(layers, input) {
165        Some(action) => Resolution::Action(action),
166        None => Resolution::Passthrough(raw),
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::input::{Key, Modifiers};
174
175    #[derive(Debug, Clone, PartialEq)]
176    enum Action {
177        Quit,
178        Save,
179        Split,
180    }
181
182    fn ctrl(c: char) -> KeyInput {
183        KeyInput::new(Key::Char(c), Modifiers::CTRL)
184    }
185
186    #[test]
187    fn raw_input_round_trips_its_bytes() {
188        let raw = RawInput::from_bytes(&[0x1b, 0x5b, b'A']);
189        assert_eq!(raw.as_bytes(), &[0x1b, 0x5b, b'A']);
190    }
191
192    #[test]
193    fn hit_resolves_to_action() {
194        let mut base = Keymap::new();
195        base.bind(ctrl('q'), Action::Quit);
196        let raw = RawInput::from_bytes(&[0x11]);
197        assert_eq!(
198            resolve_passthrough([&base], &ctrl('q'), raw),
199            Resolution::Action(&Action::Quit),
200        );
201    }
202
203    #[test]
204    fn miss_carries_the_raw_bytes_for_passthrough() {
205        let base: Keymap<Action> = Keymap::new();
206        let raw = RawInput::from_bytes(b"a");
207        match resolve_passthrough(
208            [&base],
209            &KeyInput::new(Key::Char('a'), Modifiers::NONE),
210            raw,
211        ) {
212            Resolution::Passthrough(bytes) => assert_eq!(bytes.as_bytes(), b"a"),
213            other => panic!("expected passthrough, got {other:?}"),
214        }
215    }
216
217    #[test]
218    fn empty_raw_passes_through_as_empty() {
219        // A zero-length span (decoder reporting consumed == 0, or a caller's
220        // empty slice) must still preserve "bytes unchanged on miss": forwarding
221        // zero bytes is a legitimate no-op, not a bug.
222        let base: Keymap<Action> = Keymap::new();
223        let raw = RawInput::from_bytes(&[]);
224        match resolve_passthrough(
225            [&base],
226            &KeyInput::new(Key::Char('a'), Modifiers::NONE),
227            raw,
228        ) {
229            Resolution::Passthrough(bytes) => assert_eq!(bytes.as_bytes(), b""),
230            other => panic!("expected passthrough, got {other:?}"),
231        }
232    }
233
234    #[test]
235    fn miss_preserves_multibyte_sequence_verbatim() {
236        // The module's reason to exist: an unbound escape sequence (here the Up
237        // arrow, ESC [ A) must reach the sink byte-identical. Round-trip through
238        // RawInput and the resolver-miss path are otherwise tested separately.
239        let base: Keymap<Action> = Keymap::new();
240        let csi_up: &[u8] = &[0x1b, 0x5b, b'A'];
241        let raw = RawInput::from_bytes(csi_up);
242        match resolve_passthrough([&base], &KeyInput::new(Key::Up, Modifiers::NONE), raw) {
243            Resolution::Passthrough(bytes) => assert_eq!(bytes.as_bytes(), csi_up),
244            other => panic!("expected passthrough, got {other:?}"),
245        }
246    }
247
248    #[test]
249    fn first_hit_wins_across_layers_just_like_resolve_layered() {
250        let mut base = Keymap::new();
251        base.bind(ctrl('s'), Action::Save);
252        let mut overlay = Keymap::new();
253        overlay.bind(ctrl('s'), Action::Split);
254        let raw = RawInput::from_bytes(&[0x13]);
255        // The overlay wins, exactly as resolve_layered would.
256        assert_eq!(
257            resolve_passthrough([&overlay, &base], &ctrl('s'), raw),
258            Resolution::Action(&Action::Split),
259        );
260    }
261
262    #[test]
263    fn earlier_miss_falls_through_to_a_later_layer_hit() {
264        // An empty outer layer must not steal the hit from an inner layer that
265        // binds the key — the result is the action, not a passthrough.
266        let overlay: Keymap<Action> = Keymap::new(); // binds nothing
267        let mut base = Keymap::new();
268        base.bind(ctrl('q'), Action::Quit);
269        let raw = RawInput::from_bytes(&[0x11]);
270        assert_eq!(
271            resolve_passthrough([&overlay, &base], &ctrl('q'), raw),
272            Resolution::Action(&Action::Quit),
273        );
274    }
275
276    #[test]
277    fn hit_ignores_raw_bytes_entirely() {
278        // On a hit, a bound key must NOT also reach the PTY (or the app's reserved
279        // keys would leak through the sink). The Action variant structurally has
280        // no bytes; deliberately mismatched raw bytes prove they are dropped.
281        let mut base = Keymap::new();
282        base.bind(ctrl('q'), Action::Quit);
283        let raw = RawInput::from_bytes(b"\xff\xff\xff");
284        assert_eq!(
285            resolve_passthrough([&base], &ctrl('q'), raw),
286            Resolution::Action(&Action::Quit),
287        );
288    }
289
290    #[test]
291    fn no_layers_is_a_passthrough_not_a_panic() {
292        let raw = RawInput::from_bytes(b"z");
293        match resolve_passthrough(
294            std::iter::empty::<&Keymap<Action>>(),
295            &KeyInput::new(Key::Char('z'), Modifiers::NONE),
296            raw,
297        ) {
298            Resolution::Passthrough(bytes) => assert_eq!(bytes.as_bytes(), b"z"),
299            other => panic!("expected passthrough, got {other:?}"),
300        }
301    }
302
303    #[test]
304    fn resolver_never_returns_consume() {
305        // Verifies the runtime fact: a miss yields Passthrough, never Consume.
306        // It also demonstrates the grab-mode mapping a caller performs to assign
307        // Consume itself. (The compiler-enforced exhaustiveness of `Resolution` is
308        // a separate, cross-crate property pinned by the doctest on the type.)
309        let base: Keymap<Action> = Keymap::new();
310        let raw = RawInput::from_bytes(b"a");
311        let grabbing = true;
312        let disposition = match resolve_passthrough(
313            [&base],
314            &KeyInput::new(Key::Char('a'), Modifiers::NONE),
315            raw,
316        ) {
317            Resolution::Action(a) => Resolution::Action(a),
318            // A grabbing caller swallows an otherwise-passed-through key.
319            Resolution::Passthrough(_) if grabbing => Resolution::Consume,
320            Resolution::Passthrough(bytes) => Resolution::Passthrough(bytes),
321            Resolution::Consume => Resolution::Consume,
322        };
323        assert_eq!(disposition, Resolution::<Action>::Consume);
324    }
325}