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}