Skip to main content

crustkit/
keys.rs

1use crossterm::event::KeyCode;
2use ratatui::{
3    style::Style,
4    text::{Line, Span},
5};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct KeyHint {
9    pub key: &'static str,
10    pub action: &'static str,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum NavigationFocus {
15    Header,
16    Body,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum NavigationCommand {
21    None,
22    PreviousTab,
23    NextTab,
24    EnterBody,
25    ExitBody,
26    PreviousItem,
27    NextItem,
28    Activate,
29}
30
31impl KeyHint {
32    pub const fn new(key: &'static str, action: &'static str) -> Self {
33        Self { key, action }
34    }
35}
36
37pub fn key_hints_line<'a, I>(hints: I) -> Line<'a>
38where
39    I: IntoIterator<Item = KeyHint>,
40{
41    let mut spans = Vec::new();
42    for hint in hints {
43        if !spans.is_empty() {
44            spans.push(Span::raw("  "));
45        }
46        spans.push(Span::styled(hint.key, Style::new().bold()));
47        spans.push(Span::raw(" "));
48        spans.push(Span::raw(hint.action));
49    }
50    Line::from(spans)
51}
52
53/// Map a number key to a tab index, Chrome-style: `1`–`8` select the first
54/// eight tabs and `9` always jumps to the last tab, regardless of how many tabs
55/// there are. Returns `None` for any other key, when `tab_count` is zero, or
56/// when the requested digit exceeds the available tabs.
57///
58/// This is opt-in and intentionally modifier-agnostic (terminals rarely deliver
59/// Cmd/Super). Call it from your key handler **only when a text input is not
60/// focused** — otherwise digits typed into a field would switch tabs:
61///
62/// ```
63/// # use crossterm::event::KeyCode;
64/// # use crustkit::tab_hotkey;
65/// # let editing = false;
66/// # let tab_count = 4;
67/// # let code = KeyCode::Char('9');
68/// if !editing {
69///     if let Some(index) = tab_hotkey(code, tab_count) {
70///         // switch to `index`
71///         # assert_eq!(index, 3);
72///     }
73/// }
74/// ```
75pub fn tab_hotkey(code: KeyCode, tab_count: usize) -> Option<usize> {
76    if tab_count == 0 {
77        return None;
78    }
79    match code {
80        KeyCode::Char('9') => Some(tab_count - 1),
81        KeyCode::Char(c @ '1'..='8') => {
82            let index = (c as usize) - ('1' as usize);
83            (index < tab_count).then_some(index)
84        }
85        _ => None,
86    }
87}
88
89/// Convert common terminal navigation keys into a header/body focus command.
90///
91/// This matches a common header/body interaction pattern:
92/// horizontal navigation switches tabs, Down/Enter drops from the header into
93/// the body, Up on the first body item returns to the header, and Esc walks
94/// focus back to the header before the app decides whether to quit.
95pub fn navigation_command(
96    code: KeyCode,
97    focus: NavigationFocus,
98    at_first_item: bool,
99) -> NavigationCommand {
100    match code {
101        KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => NavigationCommand::NextTab,
102        KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => NavigationCommand::PreviousTab,
103        KeyCode::Down | KeyCode::Char('j') => match focus {
104            NavigationFocus::Header => NavigationCommand::EnterBody,
105            NavigationFocus::Body => NavigationCommand::NextItem,
106        },
107        KeyCode::Up | KeyCode::Char('k') => {
108            if focus == NavigationFocus::Body && at_first_item {
109                NavigationCommand::ExitBody
110            } else if focus == NavigationFocus::Body {
111                NavigationCommand::PreviousItem
112            } else {
113                NavigationCommand::None
114            }
115        }
116        KeyCode::Enter => match focus {
117            NavigationFocus::Header => NavigationCommand::EnterBody,
118            NavigationFocus::Body => NavigationCommand::Activate,
119        },
120        KeyCode::Esc => match focus {
121            NavigationFocus::Header => NavigationCommand::None,
122            NavigationFocus::Body => NavigationCommand::ExitBody,
123        },
124        _ => NavigationCommand::None,
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn builds_key_hint_line() {
134        let line = key_hints_line([KeyHint::new("q", "quit"), KeyHint::new("Enter", "open")]);
135
136        assert_eq!(line.width(), "q quit  Enter open".len());
137    }
138
139    #[test]
140    fn tab_hotkey_maps_digits_chrome_style() {
141        // 1-based digits select the matching tab.
142        assert_eq!(tab_hotkey(KeyCode::Char('1'), 4), Some(0));
143        assert_eq!(tab_hotkey(KeyCode::Char('4'), 4), Some(3));
144        // 9 always jumps to the last tab, even with fewer than nine tabs.
145        assert_eq!(tab_hotkey(KeyCode::Char('9'), 4), Some(3));
146        // Digits past the end and non-digits are ignored.
147        assert_eq!(tab_hotkey(KeyCode::Char('5'), 4), None);
148        assert_eq!(tab_hotkey(KeyCode::Char('a'), 4), None);
149        assert_eq!(tab_hotkey(KeyCode::Char('1'), 0), None);
150    }
151
152    #[test]
153    fn navigation_enters_and_exits_body_from_header() {
154        assert_eq!(
155            navigation_command(KeyCode::Down, NavigationFocus::Header, true),
156            NavigationCommand::EnterBody
157        );
158        assert_eq!(
159            navigation_command(KeyCode::Up, NavigationFocus::Body, true),
160            NavigationCommand::ExitBody
161        );
162        assert_eq!(
163            navigation_command(KeyCode::Esc, NavigationFocus::Body, false),
164            NavigationCommand::ExitBody
165        );
166        assert_eq!(
167            navigation_command(KeyCode::Esc, NavigationFocus::Header, false),
168            NavigationCommand::None
169        );
170    }
171
172    #[test]
173    fn navigation_moves_inside_body_after_first_item() {
174        assert_eq!(
175            navigation_command(KeyCode::Down, NavigationFocus::Body, false),
176            NavigationCommand::NextItem
177        );
178        assert_eq!(
179            navigation_command(KeyCode::Up, NavigationFocus::Body, false),
180            NavigationCommand::PreviousItem
181        );
182    }
183}