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
53pub 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
89pub 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 assert_eq!(tab_hotkey(KeyCode::Char('1'), 4), Some(0));
143 assert_eq!(tab_hotkey(KeyCode::Char('4'), 4), Some(3));
144 assert_eq!(tab_hotkey(KeyCode::Char('9'), 4), Some(3));
146 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}