Skip to main content

frankensearch_tui/
input.rs

1//! Unified input model: keymap, bindings, mouse support.
2//!
3//! Provides a configurable keymap that maps terminal events to semantic
4//! [`KeyAction`] values. Product crates extend the action set; the shell
5//! handles navigation-level actions (quit, tab switch, palette toggle).
6
7use std::collections::HashMap;
8
9use ftui_core::event::{KeyCode, Modifiers, MouseEventKind};
10use serde::{Deserialize, Serialize};
11
12// ─── Input Event Abstraction ────────────────────────────────────────────────
13
14/// High-level input event consumed by screens and the shell.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum InputEvent {
17    /// A key press with modifiers.
18    Key(KeyCode, Modifiers),
19    /// A mouse event at a position.
20    Mouse(MouseEventKind, u16, u16),
21    /// Terminal resize.
22    Resize(u16, u16),
23    /// A resolved semantic action (after keymap lookup).
24    Action(KeyAction),
25}
26
27// ─── Semantic Key Actions ───────────────────────────────────────────────────
28
29/// Semantic action resolved from key bindings.
30///
31/// Shell-level actions are handled by the app shell. Screen-level actions
32/// are forwarded to the active screen. Product crates can define custom
33/// actions using the `Custom` variant.
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum KeyAction {
37    // ── Shell-level ─────────────────────────────────────────────────
38    /// Quit the application.
39    Quit,
40    /// Toggle the command palette.
41    TogglePalette,
42    /// Navigate to the next screen (tab).
43    NextScreen,
44    /// Navigate to the previous screen (shift-tab).
45    PrevScreen,
46    /// Toggle help overlay.
47    ToggleHelp,
48    /// Cycle to the next theme preset.
49    CycleTheme,
50    /// Dismiss current overlay / cancel.
51    Dismiss,
52
53    // ── Navigation ──────────────────────────────────────────────────
54    /// Move focus up.
55    Up,
56    /// Move focus down.
57    Down,
58    /// Move focus left.
59    Left,
60    /// Move focus right.
61    Right,
62    /// Page up.
63    PageUp,
64    /// Page down.
65    PageDown,
66    /// Go to first item.
67    Home,
68    /// Go to last item.
69    End,
70
71    // ── Interaction ─────────────────────────────────────────────────
72    /// Confirm / select / enter.
73    Confirm,
74    /// Delete / backspace.
75    Delete,
76    /// Copy to clipboard.
77    Copy,
78
79    // ── Product-specific ────────────────────────────────────────────
80    /// Custom action defined by product crates.
81    Custom(String),
82}
83
84// ─── Key Binding ────────────────────────────────────────────────────────────
85
86/// A key binding maps a key+modifier combination to a semantic action.
87#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
88pub struct KeyBinding {
89    /// The key code.
90    pub key: String,
91    /// Modifier keys (ctrl, alt, shift).
92    pub modifiers: Vec<String>,
93    /// The action this binding triggers.
94    pub action: KeyAction,
95}
96
97// ─── Keymap ─────────────────────────────────────────────────────────────────
98
99/// Configurable keymap that resolves key events to semantic actions.
100pub struct Keymap {
101    bindings: HashMap<(KeyCode, Modifiers), KeyAction>,
102}
103
104impl Keymap {
105    /// Create a keymap with the default bindings.
106    #[must_use]
107    pub fn default_bindings() -> Self {
108        let mut bindings = HashMap::new();
109
110        // Quit
111        bindings.insert((KeyCode::Char('q'), Modifiers::NONE), KeyAction::Quit);
112        bindings.insert((KeyCode::Char('c'), Modifiers::CTRL), KeyAction::Quit);
113
114        // Command palette
115        bindings.insert(
116            (KeyCode::Char('p'), Modifiers::CTRL),
117            KeyAction::TogglePalette,
118        );
119        bindings.insert(
120            (KeyCode::Char(':'), Modifiers::NONE),
121            KeyAction::TogglePalette,
122        );
123
124        // Navigation
125        bindings.insert((KeyCode::Tab, Modifiers::NONE), KeyAction::NextScreen);
126        bindings.insert((KeyCode::BackTab, Modifiers::SHIFT), KeyAction::PrevScreen);
127
128        // Help
129        bindings.insert((KeyCode::Char('?'), Modifiers::NONE), KeyAction::ToggleHelp);
130        bindings.insert((KeyCode::F(1), Modifiers::NONE), KeyAction::ToggleHelp);
131
132        // Dismiss
133        bindings.insert((KeyCode::Escape, Modifiers::NONE), KeyAction::Dismiss);
134
135        // Movement
136        bindings.insert((KeyCode::Up, Modifiers::NONE), KeyAction::Up);
137        bindings.insert((KeyCode::Down, Modifiers::NONE), KeyAction::Down);
138        bindings.insert((KeyCode::Left, Modifiers::NONE), KeyAction::Left);
139        bindings.insert((KeyCode::Right, Modifiers::NONE), KeyAction::Right);
140        bindings.insert((KeyCode::Char('k'), Modifiers::NONE), KeyAction::Up);
141        bindings.insert((KeyCode::Char('j'), Modifiers::NONE), KeyAction::Down);
142        bindings.insert((KeyCode::Char('h'), Modifiers::NONE), KeyAction::Left);
143        bindings.insert((KeyCode::Char('l'), Modifiers::NONE), KeyAction::Right);
144
145        // Page navigation
146        bindings.insert((KeyCode::PageUp, Modifiers::NONE), KeyAction::PageUp);
147        bindings.insert((KeyCode::PageDown, Modifiers::NONE), KeyAction::PageDown);
148        bindings.insert((KeyCode::Home, Modifiers::NONE), KeyAction::Home);
149        bindings.insert((KeyCode::End, Modifiers::NONE), KeyAction::End);
150
151        // Theme cycling
152        bindings.insert((KeyCode::Char('t'), Modifiers::CTRL), KeyAction::CycleTheme);
153
154        // Interaction
155        bindings.insert((KeyCode::Enter, Modifiers::NONE), KeyAction::Confirm);
156        bindings.insert((KeyCode::Backspace, Modifiers::NONE), KeyAction::Delete);
157        bindings.insert((KeyCode::Char('y'), Modifiers::CTRL), KeyAction::Copy);
158
159        Self { bindings }
160    }
161
162    /// Resolve a key event to a semantic action.
163    #[must_use]
164    pub fn resolve(&self, key: KeyCode, modifiers: Modifiers) -> Option<&KeyAction> {
165        self.bindings.get(&(key, modifiers))
166    }
167
168    /// Add or override a binding.
169    pub fn bind(&mut self, key: KeyCode, modifiers: Modifiers, action: KeyAction) {
170        self.bindings.insert((key, modifiers), action);
171    }
172
173    /// Remove a binding.
174    pub fn unbind(&mut self, key: KeyCode, modifiers: Modifiers) {
175        self.bindings.remove(&(key, modifiers));
176    }
177
178    /// Number of active bindings.
179    #[must_use]
180    pub fn len(&self) -> usize {
181        self.bindings.len()
182    }
183
184    /// Whether the keymap is empty.
185    #[must_use]
186    pub fn is_empty(&self) -> bool {
187        self.bindings.is_empty()
188    }
189}
190
191impl Default for Keymap {
192    fn default() -> Self {
193        Self::default_bindings()
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use ftui_core::event::{KeyCode, Modifiers};
200
201    use super::*;
202
203    #[test]
204    fn default_keymap_has_bindings() {
205        let keymap = Keymap::default_bindings();
206        assert!(!keymap.is_empty());
207        assert!(keymap.len() > 15);
208    }
209
210    #[test]
211    fn resolve_quit_q() {
212        let keymap = Keymap::default_bindings();
213        let action = keymap.resolve(KeyCode::Char('q'), Modifiers::NONE);
214        assert_eq!(action, Some(&KeyAction::Quit));
215    }
216
217    #[test]
218    fn resolve_quit_ctrl_c() {
219        let keymap = Keymap::default_bindings();
220        let action = keymap.resolve(KeyCode::Char('c'), Modifiers::CTRL);
221        assert_eq!(action, Some(&KeyAction::Quit));
222    }
223
224    #[test]
225    fn resolve_palette_ctrl_p() {
226        let keymap = Keymap::default_bindings();
227        let action = keymap.resolve(KeyCode::Char('p'), Modifiers::CTRL);
228        assert_eq!(action, Some(&KeyAction::TogglePalette));
229    }
230
231    #[test]
232    fn resolve_vim_movement() {
233        let keymap = Keymap::default_bindings();
234        assert_eq!(
235            keymap.resolve(KeyCode::Char('j'), Modifiers::NONE),
236            Some(&KeyAction::Down)
237        );
238        assert_eq!(
239            keymap.resolve(KeyCode::Char('k'), Modifiers::NONE),
240            Some(&KeyAction::Up)
241        );
242    }
243
244    #[test]
245    fn resolve_unknown_returns_none() {
246        let keymap = Keymap::default_bindings();
247        assert!(
248            keymap
249                .resolve(KeyCode::Char('z'), Modifiers::NONE)
250                .is_none()
251        );
252    }
253
254    #[test]
255    fn custom_binding() {
256        let mut keymap = Keymap::default_bindings();
257        keymap.bind(
258            KeyCode::Char('s'),
259            Modifiers::CTRL,
260            KeyAction::Custom("save".to_string()),
261        );
262        let action = keymap.resolve(KeyCode::Char('s'), Modifiers::CTRL);
263        assert_eq!(action, Some(&KeyAction::Custom("save".to_string())));
264    }
265
266    #[test]
267    fn unbind_removes_binding() {
268        let mut keymap = Keymap::default_bindings();
269        assert!(
270            keymap
271                .resolve(KeyCode::Char('q'), Modifiers::NONE)
272                .is_some()
273        );
274        keymap.unbind(KeyCode::Char('q'), Modifiers::NONE);
275        assert!(
276            keymap
277                .resolve(KeyCode::Char('q'), Modifiers::NONE)
278                .is_none()
279        );
280    }
281
282    #[test]
283    fn key_action_serde_roundtrip() {
284        for action in [
285            KeyAction::Quit,
286            KeyAction::TogglePalette,
287            KeyAction::CycleTheme,
288            KeyAction::Up,
289            KeyAction::Custom("test".to_string()),
290        ] {
291            let json = serde_json::to_string(&action).unwrap();
292            let decoded: KeyAction = serde_json::from_str(&json).unwrap();
293            assert_eq!(decoded, action);
294        }
295    }
296}