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, [`publish_keymap`](crate::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::TestBackend;
436 use crate::event::Event;
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!(
502 km.matched(&key_event(KeyCode::Char('z'), KeyModifiers::NONE))
503 .is_none()
504 );
505 }
506
507 #[test]
508 fn matched_first_registration_wins_on_overlap() {
509 // Two bindings claim the same chord; the earlier registration wins.
510 let km = KeyMap::new().bind('a', "First").bind('a', "Second");
511 let hit = km
512 .matched(&key_event(KeyCode::Char('a'), KeyModifiers::NONE))
513 .expect("'a' should match");
514 assert_eq!(hit.description, "First");
515 }
516
517 #[test]
518 fn context_keymap_match_returns_binding_for_frame_press() {
519 let km = KeyMap::new()
520 .bind('q', "Quit")
521 .bind_code(KeyCode::Up, "Move up");
522
523 let mut tb = TestBackend::new(20, 3);
524 tb.run_with_events(vec![Event::key(KeyCode::Up)], |ui| {
525 let hit = ui.keymap_match(&km);
526 ui.text(hit.map(|b| b.description.as_str()).unwrap_or("none"));
527 });
528 tb.assert_contains("Move up");
529 }
530
531 #[test]
532 fn context_keymap_match_none_when_no_press_matches() {
533 let km = KeyMap::new().bind('q', "Quit");
534
535 let mut tb = TestBackend::new(20, 3);
536 // A press the map does not bind yields no match.
537 tb.run_with_events(vec![Event::key_char('z')], |ui| {
538 let hit = ui.keymap_match(&km);
539 ui.text(hit.map(|b| b.description.as_str()).unwrap_or("none"));
540 });
541 tb.assert_contains("none");
542 }
543}