Skip to main content

slt/
keymap.rs

1use crate::event::{KeyEvent, KeyEventKind};
2use crate::{KeyCode, KeyModifiers, ModifierKey};
3
4/// A single key binding with display text and description.
5#[derive(Debug, Clone)]
6pub struct Binding {
7    /// The key code for matching.
8    pub key: KeyCode,
9    /// Optional modifier (Ctrl, Alt, Shift).
10    pub modifiers: Option<KeyModifiers>,
11    /// Display text shown in help bar (e.g., "q", "Ctrl+S", "↑").
12    pub display: String,
13    /// Description of what this binding does.
14    pub description: String,
15    /// Whether to show in help bar.
16    pub visible: bool,
17}
18
19impl Binding {
20    /// Returns `true` if `key` is a press of this binding's registered chord.
21    ///
22    /// This is the dispatch primitive that mirrors bubbletea's
23    /// `key.Matches`: a [`KeyEvent`] matches when it is a **press** (releases
24    /// and repeats never match), its [`KeyCode`] equals [`Binding::key`], and
25    /// its modifiers satisfy [`Binding::modifiers`]:
26    ///
27    /// - `modifiers: None` requires that **no** modifiers are held (so a plain
28    ///   `q` binding does not fire on `Ctrl+q`).
29    /// - `modifiers: Some(mods)` requires that *at least* every modifier in
30    ///   `mods` is held, matching the lenient semantics of
31    ///   [`Context::key_mod`](crate::Context::key_mod).
32    ///
33    /// # Examples
34    /// ```
35    /// use slt::{Event, KeyCode, KeyMap, KeyModifiers};
36    ///
37    /// let km = KeyMap::new().bind('q', "Quit");
38    /// let binding = &km.bindings[0];
39    ///
40    /// let Event::Key(quit) = Event::key_char('q') else { unreachable!() };
41    /// assert!(binding.matches(&quit));
42    ///
43    /// // A plain binding ignores modified presses of the same key.
44    /// let Event::Key(ctrl_q) = Event::key_ctrl('q') else { unreachable!() };
45    /// assert!(!binding.matches(&ctrl_q));
46    ///
47    /// // A different key never matches.
48    /// let Event::Key(other) = Event::key_char('x') else { unreachable!() };
49    /// assert!(!binding.matches(&other));
50    ///
51    /// // Modifier bindings use `contains` semantics.
52    /// let save = KeyMap::new().bind_mod('s', KeyModifiers::CONTROL, "Save");
53    /// let Event::Key(ctrl_s) =
54    ///     Event::key_mod(KeyCode::Char('s'), KeyModifiers::CONTROL)
55    /// else {
56    ///     unreachable!()
57    /// };
58    /// assert!(save.bindings[0].matches(&ctrl_s));
59    /// ```
60    pub fn matches(&self, key: &KeyEvent) -> bool {
61        if key.kind != KeyEventKind::Press {
62            return false;
63        }
64        if key.code != self.key {
65            return false;
66        }
67        match self.modifiers {
68            None => key.modifiers == KeyModifiers::NONE,
69            Some(mods) => key.modifiers.contains(mods),
70        }
71    }
72}
73
74/// Declarative key binding map.
75///
76/// # Examples
77/// ```
78/// use slt::KeyMap;
79///
80/// let km = KeyMap::new()
81///     .bind('q', "Quit")
82///     .bind_code(slt::KeyCode::Up, "Move up")
83///     .bind_mod('s', slt::KeyModifiers::CONTROL, "Save")
84///     .bind_hidden('?', "Toggle help");
85/// ```
86#[derive(Debug, Clone, Default)]
87pub struct KeyMap {
88    /// Registered key bindings.
89    pub bindings: Vec<Binding>,
90}
91
92impl KeyMap {
93    /// Create an empty key map.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Bind a character key.
99    pub fn bind(mut self, key: char, description: &str) -> Self {
100        self.bindings.push(Binding {
101            key: KeyCode::Char(key),
102            modifiers: None,
103            display: key.to_string(),
104            description: description.to_string(),
105            visible: true,
106        });
107        self
108    }
109
110    /// Bind a special key (Enter, Esc, Up, Down, etc.).
111    pub fn bind_code(mut self, key: KeyCode, description: &str) -> Self {
112        self.bindings.push(Binding {
113            display: display_for_key_code(&key),
114            key,
115            modifiers: None,
116            description: description.to_string(),
117            visible: true,
118        });
119        self
120    }
121
122    /// Bind a key with modifier (Ctrl+S, etc.).
123    pub fn bind_mod(mut self, key: char, mods: KeyModifiers, description: &str) -> Self {
124        self.bindings.push(Binding {
125            key: KeyCode::Char(key),
126            modifiers: Some(mods),
127            display: display_for_mod_char(mods, key),
128            description: description.to_string(),
129            visible: true,
130        });
131        self
132    }
133
134    /// Bind a special key with modifier keys (Ctrl+Enter, Alt+Up, Shift+F5, etc.).
135    ///
136    /// Unlike [`KeyMap::bind_mod`], which is restricted to character keys, this
137    /// accepts any [`KeyCode`] together with optional modifier keys.
138    ///
139    /// # Example
140    /// ```
141    /// use slt::{KeyMap, KeyCode, KeyModifiers};
142    ///
143    /// let km = KeyMap::new()
144    ///     .bind_code_mod(KeyCode::Enter, KeyModifiers::CONTROL, "Submit")
145    ///     .bind_code_mod(KeyCode::Up, KeyModifiers::ALT, "Jump to top");
146    /// ```
147    pub fn bind_code_mod(mut self, key: KeyCode, mods: KeyModifiers, description: &str) -> Self {
148        let display = display_for_code_mod(&key, mods);
149        self.bindings.push(Binding {
150            key,
151            modifiers: Some(mods),
152            display,
153            description: description.to_string(),
154            visible: true,
155        });
156        self
157    }
158
159    /// Bind but hide from help bar display.
160    pub fn bind_hidden(mut self, key: char, description: &str) -> Self {
161        self.bindings.push(Binding {
162            key: KeyCode::Char(key),
163            modifiers: None,
164            display: key.to_string(),
165            description: description.to_string(),
166            visible: false,
167        });
168        self
169    }
170
171    /// Get visible bindings for help bar rendering.
172    pub fn visible_bindings(&self) -> impl Iterator<Item = &Binding> {
173        self.bindings.iter().filter(|binding| binding.visible)
174    }
175
176    /// Return the first registered binding whose chord matches `key`.
177    ///
178    /// Bindings are checked in registration order, so if two bindings could
179    /// match the same key the earlier `.bind*()` call wins. Returns `None`
180    /// when no binding matches (including for release / repeat events, which
181    /// [`Binding::matches`] never accepts).
182    ///
183    /// # Examples
184    /// ```
185    /// use slt::{Event, KeyCode, KeyMap};
186    ///
187    /// let km = KeyMap::new()
188    ///     .bind('q', "Quit")
189    ///     .bind_code(KeyCode::Up, "Move up");
190    ///
191    /// let Event::Key(up) = Event::key(KeyCode::Up) else { unreachable!() };
192    /// assert_eq!(km.matched(&up).unwrap().description, "Move up");
193    ///
194    /// let Event::Key(unbound) = Event::key_char('z') else { unreachable!() };
195    /// assert!(km.matched(&unbound).is_none());
196    /// ```
197    pub fn matched(&self, key: &KeyEvent) -> Option<&Binding> {
198        self.bindings.iter().find(|binding| binding.matches(key))
199    }
200}
201
202fn display_for_key_code(key: &KeyCode) -> String {
203    match key {
204        KeyCode::Char(c) => c.to_string(),
205        KeyCode::Enter => "Enter".to_string(),
206        KeyCode::Backspace => "Backspace".to_string(),
207        KeyCode::Tab => "Tab".to_string(),
208        KeyCode::BackTab => "Shift+Tab".to_string(),
209        KeyCode::Esc => "Esc".to_string(),
210        KeyCode::Up => "↑".to_string(),
211        KeyCode::Down => "↓".to_string(),
212        KeyCode::Left => "←".to_string(),
213        KeyCode::Right => "→".to_string(),
214        KeyCode::Home => "Home".to_string(),
215        KeyCode::End => "End".to_string(),
216        KeyCode::PageUp => "PgUp".to_string(),
217        KeyCode::PageDown => "PgDn".to_string(),
218        KeyCode::Delete => "Del".to_string(),
219        KeyCode::Insert => "Ins".to_string(),
220        KeyCode::Null => "Null".to_string(),
221        KeyCode::CapsLock => "CapsLock".to_string(),
222        KeyCode::ScrollLock => "ScrollLock".to_string(),
223        KeyCode::NumLock => "NumLock".to_string(),
224        KeyCode::PrintScreen => "PrtSc".to_string(),
225        KeyCode::Pause => "Pause".to_string(),
226        KeyCode::Menu => "Menu".to_string(),
227        KeyCode::KeypadBegin => "KP5".to_string(),
228        KeyCode::F(n) => format!("F{n}"),
229        KeyCode::Modifier(m) => display_for_modifier_key(*m).to_string(),
230    }
231}
232
233fn display_for_modifier_key(m: ModifierKey) -> &'static str {
234    match m {
235        ModifierKey::LeftShift => "LShift",
236        ModifierKey::LeftCtrl => "LCtrl",
237        ModifierKey::LeftAlt => "LAlt",
238        ModifierKey::LeftSuper => "LSuper",
239        ModifierKey::RightShift => "RShift",
240        ModifierKey::RightCtrl => "RCtrl",
241        ModifierKey::RightAlt => "RAlt",
242        ModifierKey::RightSuper => "RSuper",
243        ModifierKey::LeftHyper => "LHyper",
244        ModifierKey::LeftMeta => "LMeta",
245        ModifierKey::RightHyper => "RHyper",
246        ModifierKey::RightMeta => "RMeta",
247        ModifierKey::IsoLevel3Shift => "ISO3",
248        ModifierKey::IsoLevel5Shift => "ISO5",
249    }
250}
251
252fn display_for_code_mod(key: &KeyCode, mods: KeyModifiers) -> String {
253    let mut parts: Vec<&str> = Vec::new();
254    if mods.contains(KeyModifiers::CONTROL) {
255        parts.push("Ctrl");
256    }
257    if mods.contains(KeyModifiers::ALT) {
258        parts.push("Alt");
259    }
260    if mods.contains(KeyModifiers::SHIFT) {
261        parts.push("Shift");
262    }
263    if mods.contains(KeyModifiers::SUPER) {
264        parts.push("Super");
265    }
266    if mods.contains(KeyModifiers::HYPER) {
267        parts.push("Hyper");
268    }
269    if mods.contains(KeyModifiers::META) {
270        parts.push("Meta");
271    }
272
273    let key_label = display_for_key_code(key);
274    if parts.is_empty() {
275        key_label
276    } else {
277        format!("{}+{}", parts.join("+"), key_label)
278    }
279}
280
281fn display_for_mod_char(mods: KeyModifiers, key: char) -> String {
282    let mut parts: Vec<&str> = Vec::new();
283    if mods.contains(KeyModifiers::CONTROL) {
284        parts.push("Ctrl");
285    }
286    if mods.contains(KeyModifiers::ALT) {
287        parts.push("Alt");
288    }
289    if mods.contains(KeyModifiers::SHIFT) {
290        parts.push("Shift");
291    }
292    if mods.contains(KeyModifiers::SUPER) {
293        parts.push("Super");
294    }
295    if mods.contains(KeyModifiers::HYPER) {
296        parts.push("Hyper");
297    }
298    if mods.contains(KeyModifiers::META) {
299        parts.push("Meta");
300    }
301
302    if parts.is_empty() {
303        key.to_string()
304    } else {
305        format!("{}+{}", parts.join("+"), key.to_ascii_uppercase())
306    }
307}
308
309/// Opt-in trait for users to publish a widget's keymap to the framework so
310/// `Context::keymap_help_overlay` can list it on `?` press (issue #236).
311///
312/// # Scope
313///
314/// SLT does **not** implement this on built-in widgets — it is a user-facing
315/// extension point. Built-in widgets register their bindings directly inside
316/// their `impl Context::*` methods. To publish the keymap of your *own*
317/// custom widget, implement this trait, then call
318/// [`Context::publish_keymap`](crate::Context::publish_keymap) in your render
319/// method:
320///
321/// ```ignore
322/// ctx.publish_keymap("my_widget", MyState::key_help(&state));
323/// ```
324///
325/// In other words, this trait is the same shape as `std::fmt::Display`: zero
326/// blanket / built-in impls; you opt in by implementing it on your own type.
327/// If you prefer a free-function call, [`Context::publish_keymap`] takes the
328/// same `(name, &'static [...])` signature without the trait.
329///
330/// # Format
331///
332/// The returned slice must be `'static` — hardcode a `const` array per
333/// widget so the registration is allocation-free. Each tuple is
334/// `(key_combo, description)` using the same display style as
335/// [`Binding::display`] (e.g. `"↑/k"`, `"Ctrl+S"`, `"PgDn"`).
336///
337/// # Example
338///
339/// ```
340/// use slt::keymap::WidgetKeyHelp;
341///
342/// struct Counter;
343///
344/// impl WidgetKeyHelp for Counter {
345///     fn key_help(&self) -> &'static [(&'static str, &'static str)] {
346///         const HELP: &[(&str, &str)] = &[
347///             ("↑/k", "increment"),
348///             ("↓/j", "decrement"),
349///             ("r", "reset"),
350///         ];
351///         HELP
352///     }
353/// }
354///
355/// let counter = Counter;
356/// assert_eq!(counter.key_help().len(), 3);
357/// ```
358pub trait WidgetKeyHelp {
359    /// Return the keyboard shortcuts for this widget.
360    ///
361    /// Format: `(key_combo, description)`.
362    fn key_help(&self) -> &'static [(&'static str, &'static str)];
363}
364
365/// A single published keymap entry collected via
366/// [`Context::publish_keymap`](crate::Context::publish_keymap) (issue #236).
367///
368/// Once registered for the current frame, every entry is queryable through
369/// [`Context::published_keymaps`](crate::Context::published_keymaps) and is
370/// rendered automatically by the command-palette help overlay.
371#[derive(Debug, Clone)]
372pub struct PublishedKeymap {
373    /// Optional widget / scope name (e.g. `"rich_log"`, `"table"`).
374    pub name: &'static str,
375    /// `(key_combo, description)` pairs. Always `'static` — no per-frame
376    /// allocation.
377    pub bindings: &'static [(&'static str, &'static str)],
378}
379
380impl PublishedKeymap {
381    /// Construct a [`PublishedKeymap`] from a static slice of bindings.
382    pub const fn new(
383        name: &'static str,
384        bindings: &'static [(&'static str, &'static str)],
385    ) -> Self {
386        Self { name, bindings }
387    }
388}
389
390impl crate::Context {
391    /// Match the current frame's unconsumed key presses against `map` and
392    /// return the first [`Binding`] that fires.
393    ///
394    /// This is the [`KeyMap`] counterpart to the per-key peek helpers like
395    /// [`key`](crate::Context::key) and [`key_code`](crate::Context::key_code):
396    /// it scans every unconsumed key-**press** event for this frame, in arrival
397    /// order, and returns the first binding in `map` whose chord matches (see
398    /// [`Binding::matches`]). The event is **not** consumed — callers can react
399    /// to the returned binding and, if desired, still let other handlers see
400    /// the key. Returns `None` when no press matches any binding.
401    ///
402    /// Like the other peek helpers, this respects the modal/overlay guard: when
403    /// a modal is active and the caller is outside an overlay, no presses are
404    /// visible and the method returns `None`.
405    ///
406    /// # Examples
407    /// ```
408    /// use slt::{KeyCode, KeyMap, TestBackend};
409    ///
410    /// let km = KeyMap::new()
411    ///     .bind('q', "Quit")
412    ///     .bind_code(KeyCode::Up, "Move up");
413    ///
414    /// let mut tb = TestBackend::new(20, 3);
415    /// tb.run_with_events(vec![slt::Event::key_char('q')], |ui| {
416    ///     let hit = ui.keymap_match(&km);
417    ///     ui.text(hit.map(|b| b.description.as_str()).unwrap_or("none"));
418    /// });
419    /// tb.assert_contains("Quit");
420    /// ```
421    pub fn keymap_match<'m>(&self, map: &'m KeyMap) -> Option<&'m Binding> {
422        if (self.rollback.modal_active || self.prev_modal_active)
423            && self.rollback.overlay_depth == 0
424        {
425            return None;
426        }
427        self.available_key_presses()
428            .find_map(|(_, key)| map.matched(key))
429    }
430}
431
432#[cfg(test)]
433mod dispatch_tests {
434    use super::*;
435    use crate::event::Event;
436    use crate::TestBackend;
437
438    fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
439        match Event::key_mod(code, modifiers) {
440            Event::Key(k) => k,
441            _ => unreachable!("key_mod always builds a Key event"),
442        }
443    }
444
445    fn release_event(c: char) -> KeyEvent {
446        match Event::key_release(c) {
447            Event::Key(k) => k,
448            _ => unreachable!("key_release always builds a Key event"),
449        }
450    }
451
452    #[test]
453    fn binding_matches_plain_char() {
454        let km = KeyMap::new().bind('q', "Quit");
455        let binding = &km.bindings[0];
456        assert!(binding.matches(&key_event(KeyCode::Char('q'), KeyModifiers::NONE)));
457        // Different char does not match.
458        assert!(!binding.matches(&key_event(KeyCode::Char('x'), KeyModifiers::NONE)));
459        // Plain binding rejects a modified press of the same key.
460        assert!(!binding.matches(&key_event(KeyCode::Char('q'), KeyModifiers::CONTROL)));
461    }
462
463    #[test]
464    fn binding_matches_modifier_chord_contains() {
465        let km = KeyMap::new().bind_mod('s', KeyModifiers::CONTROL, "Save");
466        let binding = &km.bindings[0];
467        // Exact modifier matches.
468        assert!(binding.matches(&key_event(KeyCode::Char('s'), KeyModifiers::CONTROL)));
469        // Extra modifiers still satisfy `contains` semantics.
470        let ctrl_shift = KeyModifiers(KeyModifiers::CONTROL.0 | KeyModifiers::SHIFT.0);
471        assert!(binding.matches(&key_event(KeyCode::Char('s'), ctrl_shift)));
472        // Missing the required modifier does not match.
473        assert!(!binding.matches(&key_event(KeyCode::Char('s'), KeyModifiers::NONE)));
474    }
475
476    #[test]
477    fn binding_rejects_release_events() {
478        let km = KeyMap::new().bind('q', "Quit");
479        // Edge case: a release of the bound key must never match.
480        assert!(!km.bindings[0].matches(&release_event('q')));
481    }
482
483    #[test]
484    fn matched_returns_first_registered_binding() {
485        let km = KeyMap::new()
486            .bind('q', "Quit")
487            .bind_code(KeyCode::Up, "Move up")
488            .bind_mod('s', KeyModifiers::CONTROL, "Save");
489
490        let up = km
491            .matched(&key_event(KeyCode::Up, KeyModifiers::NONE))
492            .expect("Up should match");
493        assert_eq!(up.description, "Move up");
494
495        let save = km
496            .matched(&key_event(KeyCode::Char('s'), KeyModifiers::CONTROL))
497            .expect("Ctrl+S should match");
498        assert_eq!(save.description, "Save");
499
500        // Non-matching key returns None.
501        assert!(km
502            .matched(&key_event(KeyCode::Char('z'), KeyModifiers::NONE))
503            .is_none());
504    }
505
506    #[test]
507    fn matched_first_registration_wins_on_overlap() {
508        // Two bindings claim the same chord; the earlier registration wins.
509        let km = KeyMap::new().bind('a', "First").bind('a', "Second");
510        let hit = km
511            .matched(&key_event(KeyCode::Char('a'), KeyModifiers::NONE))
512            .expect("'a' should match");
513        assert_eq!(hit.description, "First");
514    }
515
516    #[test]
517    fn context_keymap_match_returns_binding_for_frame_press() {
518        let km = KeyMap::new()
519            .bind('q', "Quit")
520            .bind_code(KeyCode::Up, "Move up");
521
522        let mut tb = TestBackend::new(20, 3);
523        tb.run_with_events(vec![Event::key(KeyCode::Up)], |ui| {
524            let hit = ui.keymap_match(&km);
525            ui.text(hit.map(|b| b.description.as_str()).unwrap_or("none"));
526        });
527        tb.assert_contains("Move up");
528    }
529
530    #[test]
531    fn context_keymap_match_none_when_no_press_matches() {
532        let km = KeyMap::new().bind('q', "Quit");
533
534        let mut tb = TestBackend::new(20, 3);
535        // A press the map does not bind yields no match.
536        tb.run_with_events(vec![Event::key_char('z')], |ui| {
537            let hit = ui.keymap_match(&km);
538            ui.text(hit.map(|b| b.description.as_str()).unwrap_or("none"));
539        });
540        tb.assert_contains("none");
541    }
542}