Skip to main content

supercode_harness/tui/
keymap.rs

1//! P5-4 (§3.1 `capabilities.tui.keymap.<action> = "<key>"`, "configurable
2//! keybindings"): a name→[`KeyEvent`] table for the small set of GLOBAL
3//! actions a user can rebind, layered over sensible defaults. Modal-local
4//! navigation (arrow keys, `Enter`/`Esc` to confirm/cancel a prompt) is
5//! deliberately NOT part of this table — those are fixed, universal
6//! conventions, not a rebind surface — only the actions listed in
7//! [`KeymapAction::ALL`] are.
8
9use std::collections::HashMap;
10
11use super::key::{Key, KeyEvent};
12
13/// A rebindable global TUI action.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum KeymapAction {
16    /// Send the current input buffer as a turn.
17    Submit,
18    /// Insert a newline without submitting (multi-line composing).
19    Newline,
20    /// Quit the TUI (falls back to the REPL's own double-press-to-exit
21    /// convention at the state-machine level — see
22    /// [`crate::tui::state::TuiState::handle_key`]'s doc comment).
23    Quit,
24    /// Open cross-session prompt-history search (D5, "Ctrl+R-style
25    /// search").
26    HistorySearch,
27    /// Toggle the dark/light [`crate::tui::Theme`].
28    ToggleTheme,
29    /// Scroll the transcript up one page.
30    ScrollUp,
31    /// Scroll the transcript down one page.
32    ScrollDown,
33    /// Open `$EDITOR` on the current input buffer.
34    ExternalEditor,
35}
36
37impl KeymapAction {
38    /// Every action, for iterating a whole keymap (defaults, config
39    /// parsing).
40    pub const ALL: &'static [KeymapAction] = &[
41        KeymapAction::Submit,
42        KeymapAction::Newline,
43        KeymapAction::Quit,
44        KeymapAction::HistorySearch,
45        KeymapAction::ToggleTheme,
46        KeymapAction::ScrollUp,
47        KeymapAction::ScrollDown,
48        KeymapAction::ExternalEditor,
49    ];
50
51    /// The `capabilities.tui.keymap.<name>` config key this action reads
52    /// from.
53    pub fn name(self) -> &'static str {
54        match self {
55            KeymapAction::Submit => "submit",
56            KeymapAction::Newline => "newline",
57            KeymapAction::Quit => "quit",
58            KeymapAction::HistorySearch => "history_search",
59            KeymapAction::ToggleTheme => "toggle_theme",
60            KeymapAction::ScrollUp => "scroll_up",
61            KeymapAction::ScrollDown => "scroll_down",
62            KeymapAction::ExternalEditor => "external_editor",
63        }
64    }
65
66    /// This action's built-in default binding.
67    pub fn default_key(self) -> KeyEvent {
68        match self {
69            KeymapAction::Submit => KeyEvent::plain(Key::Enter),
70            KeymapAction::Newline => KeyEvent {
71                key: Key::Enter,
72                ctrl: false,
73                alt: true,
74                shift: false,
75            },
76            KeymapAction::Quit => KeyEvent::ctrl(Key::Char('c')),
77            KeymapAction::HistorySearch => KeyEvent::ctrl(Key::Char('r')),
78            KeymapAction::ToggleTheme => KeyEvent::ctrl(Key::Char('t')),
79            KeymapAction::ScrollUp => KeyEvent::plain(Key::PageUp),
80            KeymapAction::ScrollDown => KeyEvent::plain(Key::PageDown),
81            KeymapAction::ExternalEditor => KeyEvent::ctrl(Key::Char('e')),
82        }
83    }
84}
85
86/// The resolved action→key table — [`KeymapAction::default_key`] for every
87/// action, with any [`Self::with_overrides`] substitutions applied.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Keymap {
90    bindings: HashMap<KeymapAction, KeyEvent>,
91}
92
93impl Default for Keymap {
94    fn default() -> Self {
95        let bindings = KeymapAction::ALL
96            .iter()
97            .map(|&a| (a, a.default_key()))
98            .collect();
99        Keymap { bindings }
100    }
101}
102
103impl Keymap {
104    /// Build a keymap from [`Config::tui_keymap`](crate::Config)-shaped
105    /// overrides (`action name` → key spec string, [`KeyEvent::parse`]
106    /// syntax). An unknown action name, or a key spec that fails to parse,
107    /// is silently skipped (keeps the default for that action) — a typo in
108    /// a user's keymap table degrades to "unchanged", never a startup
109    /// failure or a panic.
110    pub fn with_overrides(overrides: &HashMap<String, String>) -> Self {
111        let mut km = Keymap::default();
112        for action in KeymapAction::ALL {
113            if let Some(spec) = overrides.get(action.name()) {
114                if let Some(key) = KeyEvent::parse(spec) {
115                    km.bindings.insert(*action, key);
116                }
117            }
118        }
119        km
120    }
121
122    /// The key currently bound to `action`.
123    pub fn key_for(&self, action: KeymapAction) -> KeyEvent {
124        self.bindings
125            .get(&action)
126            .copied()
127            .unwrap_or_else(|| action.default_key())
128    }
129
130    /// Which action (if any) `key` is bound to.
131    pub fn action_for(&self, key: KeyEvent) -> Option<KeymapAction> {
132        self.bindings
133            .iter()
134            .find(|(_, &bound)| bound == key)
135            .map(|(&a, _)| a)
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn default_keymap_round_trips_every_action() {
145        let km = Keymap::default();
146        for &action in KeymapAction::ALL {
147            assert_eq!(km.key_for(action), action.default_key());
148            assert_eq!(km.action_for(action.default_key()), Some(action));
149        }
150    }
151
152    #[test]
153    fn override_replaces_one_binding_leaves_others_default() {
154        let mut overrides = HashMap::new();
155        overrides.insert("history_search".to_string(), "ctrl+h".to_string());
156        let km = Keymap::with_overrides(&overrides);
157        assert_eq!(
158            km.key_for(KeymapAction::HistorySearch),
159            KeyEvent::ctrl(Key::Char('h'))
160        );
161        // Unrelated action keeps its default.
162        assert_eq!(
163            km.key_for(KeymapAction::Submit),
164            KeymapAction::Submit.default_key()
165        );
166    }
167
168    #[test]
169    fn unknown_action_name_is_ignored() {
170        let mut overrides = HashMap::new();
171        overrides.insert("not_a_real_action".to_string(), "ctrl+z".to_string());
172        let km = Keymap::with_overrides(&overrides);
173        assert_eq!(km, Keymap::default());
174    }
175
176    #[test]
177    fn unparseable_key_spec_keeps_default() {
178        let mut overrides = HashMap::new();
179        overrides.insert("submit".to_string(), "not a key".to_string());
180        let km = Keymap::with_overrides(&overrides);
181        assert_eq!(
182            km.key_for(KeymapAction::Submit),
183            KeymapAction::Submit.default_key()
184        );
185    }
186
187    #[test]
188    fn action_for_unbound_key_is_none() {
189        let km = Keymap::default();
190        assert_eq!(km.action_for(KeyEvent::ch('q')), None);
191    }
192}