1use std::borrow::Cow;
4use std::cmp::Ordering;
5use std::collections::HashMap;
6
7use crossterm::event::{KeyCode, KeyModifiers};
8use ratatui::layout::{Position, Rect};
9use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
10
11use crate::config::{Binding, BindingAction};
12use crate::keys::Key;
13
14pub const TOGGLE_KEY: Key = Key {
17 code: KeyCode::Char('?'),
18 mods: KeyModifiers::NONE,
19};
20pub const CLOSE_KEY: Key = Key {
22 code: KeyCode::Esc,
23 mods: KeyModifiers::NONE,
24};
25
26pub const GRID_GAP: usize = 2;
27const MIN_COLUMN_WIDTH: usize = 24;
28const MAX_KEY_WIDTH: usize = 12;
29pub const MAX_PANEL_ROWS: usize = 20;
30const SHELL_DESCRIPTION_WIDTH: usize = 12;
31
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct KeyLabel {
34 pub full: String,
35 pub base: String,
36 pub modified: bool,
37}
38
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct KeybindingEntry {
41 pub key: Key,
42 pub label: KeyLabel,
43 pub description: String,
44}
45
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub struct KeybindingGrid {
48 pub rows: Vec<Vec<usize>>,
51 pub column_width: usize,
52 pub key_width: usize,
53}
54
55#[derive(Clone, Debug, Default)]
56pub struct KeybindingPanelState {
57 open: bool,
58 scroll: usize,
59 area: Option<Rect>,
60 total_rows: usize,
61 viewport_rows: usize,
62}
63
64impl KeybindingPanelState {
65 pub fn is_open(&self) -> bool {
66 self.open
67 }
68
69 pub fn open(&mut self) {
70 self.open = true;
71 self.scroll = 0;
72 }
73
74 pub fn close(&mut self) {
75 self.open = false;
76 self.clear_layout();
77 }
78
79 pub fn toggle(&mut self) {
80 if self.open {
81 self.close();
82 } else {
83 self.open();
84 }
85 }
86
87 pub fn scroll(&self) -> usize {
88 self.scroll
89 }
90
91 pub fn scroll_by(&mut self, delta: isize) {
92 self.scroll = self
93 .scroll
94 .saturating_add_signed(delta)
95 .min(self.max_scroll());
96 }
97
98 pub fn area(&self) -> Option<Rect> {
99 self.area
100 }
101
102 pub fn contains(&self, column: u16, row: u16) -> bool {
103 self.area
104 .is_some_and(|area| area.contains(Position::new(column, row)))
105 }
106
107 pub fn record_layout(&mut self, area: Rect, total_rows: usize, viewport_rows: usize) {
108 self.area = Some(area);
109 self.total_rows = total_rows;
110 self.viewport_rows = viewport_rows;
111 self.scroll = self.scroll.min(self.max_scroll());
112 }
113
114 pub fn clear_layout(&mut self) {
115 self.area = None;
116 self.total_rows = 0;
117 self.viewport_rows = 0;
118 }
119
120 fn max_scroll(&self) -> usize {
121 self.total_rows.saturating_sub(self.viewport_rows)
122 }
123}
124
125fn format_key(key: Key) -> KeyLabel {
126 let base = match key.code {
127 KeyCode::Backspace => "bksp".to_owned(),
128 KeyCode::Enter => "ret".to_owned(),
129 KeyCode::Left => "←".to_owned(),
130 KeyCode::Right => "→".to_owned(),
131 KeyCode::Up => "↑".to_owned(),
132 KeyCode::Down => "↓".to_owned(),
133 KeyCode::Home => "home".to_owned(),
134 KeyCode::End => "end".to_owned(),
135 KeyCode::PageUp => "pgup".to_owned(),
136 KeyCode::PageDown => "pgdn".to_owned(),
137 KeyCode::Tab | KeyCode::BackTab => "tab".to_owned(),
138 KeyCode::Delete => "del".to_owned(),
139 KeyCode::Insert => "ins".to_owned(),
140 KeyCode::F(number) => format!("f{number}"),
141 KeyCode::Char(' ') => "⎵".to_owned(),
142 KeyCode::Char(chr) => chr.to_string(),
143 KeyCode::Null => "null".to_owned(),
144 KeyCode::Esc => "␛".to_owned(),
145 KeyCode::CapsLock => "caps".to_owned(),
146 KeyCode::ScrollLock => "scroll".to_owned(),
147 KeyCode::NumLock => "num".to_owned(),
148 KeyCode::PrintScreen => "prtsc".to_owned(),
149 KeyCode::Pause => "pause".to_owned(),
150 KeyCode::Menu => "menu".to_owned(),
151 KeyCode::KeypadBegin => "begin".to_owned(),
152 KeyCode::Media(code) => format!("{code:?}").to_ascii_lowercase(),
153 KeyCode::Modifier(code) => format!("{code:?}").to_ascii_lowercase(),
154 };
155 let mut modifiers = String::new();
156 if key.mods.contains(KeyModifiers::CONTROL) {
157 modifiers.push('⌃');
158 }
159 if key.mods.contains(KeyModifiers::ALT) {
160 modifiers.push('⌥');
161 }
162 if key.mods.contains(KeyModifiers::SHIFT) {
163 modifiers.push('⇧');
164 }
165 if key
166 .mods
167 .intersects(KeyModifiers::SUPER | KeyModifiers::META)
168 {
169 modifiers.push('⌘');
170 }
171 if key.mods.contains(KeyModifiers::HYPER) {
172 modifiers.push('◆');
173 }
174
175 KeyLabel {
176 full: format!("{modifiers}{base}"),
177 base,
178 modified: !modifiers.is_empty(),
179 }
180}
181
182pub fn build_entries(keymap: &HashMap<Key, Binding>) -> Vec<KeybindingEntry> {
183 let mut entries: Vec<_> = keymap
184 .iter()
185 .map(|(&key, binding)| {
186 let description = if key == CLOSE_KEY {
189 "Close".to_owned()
190 } else {
191 binding_description(binding)
192 };
193 KeybindingEntry {
194 key,
195 label: format_key(key),
196 description,
197 }
198 })
199 .collect();
200 entries.sort_by(compare_entries);
201 entries
202}
203
204fn clean_first_line(text: &str) -> Option<String> {
205 let line = text.split('\n').next().unwrap_or_default();
206 let clean: String = line
207 .chars()
208 .map(|chr| if chr.is_control() { ' ' } else { chr })
209 .collect();
210 let clean = clean.trim();
211 (!clean.is_empty()).then(|| clean.to_owned())
212}
213
214pub fn truncate_with_ellipsis(text: &str, max_width: usize) -> Cow<'_, str> {
215 if UnicodeWidthStr::width(text) <= max_width {
216 return Cow::Borrowed(text);
217 }
218 if max_width == 0 {
219 return Cow::Borrowed("");
220 }
221 if max_width == 1 {
222 return Cow::Borrowed("…");
223 }
224
225 let target = max_width - 1;
226 let mut width = 0;
227 let mut truncated = String::new();
228 for chr in text.chars() {
229 let chr_width = UnicodeWidthChar::width(chr).unwrap_or(0);
230 if width + chr_width > target {
231 break;
232 }
233 truncated.push(chr);
234 width += chr_width;
235 }
236 truncated.push('…');
237 Cow::Owned(truncated)
238}
239
240pub fn build_grid(entries: &[KeybindingEntry], available_width: usize) -> KeybindingGrid {
241 if entries.is_empty() {
242 return KeybindingGrid::default();
243 }
244
245 let max_columns = ((available_width + GRID_GAP) / (MIN_COLUMN_WIDTH + GRID_GAP)).max(1);
246 let column_count = entries.len().min(max_columns);
247 let gaps = GRID_GAP * (column_count - 1);
248 let column_width = available_width.saturating_sub(gaps) / column_count;
249 let key_width = entries
250 .iter()
251 .map(|entry| UnicodeWidthStr::width(entry.label.full.as_str()))
252 .max()
253 .unwrap_or(0)
254 .min(MAX_KEY_WIDTH)
255 .min(column_width);
256 let row_count = entries.len().div_ceil(column_count);
257 let mut rows = vec![Vec::new(); row_count];
258 for index in 0..entries.len() {
259 rows[index % row_count].push(index);
260 }
261
262 KeybindingGrid {
263 rows,
264 column_width,
265 key_width,
266 }
267}
268
269fn binding_description(binding: &Binding) -> String {
270 if let Some(help) = binding.help.as_deref().and_then(clean_first_line) {
271 return help;
272 }
273 match &binding.action {
274 BindingAction::Cmd(command) => command.description().to_owned(),
275 BindingAction::Sh(command) => {
276 let command = clean_first_line(command).unwrap_or_default();
277 format!(
278 "`{}`",
279 truncate_with_ellipsis(&command, SHELL_DESCRIPTION_WIDTH)
280 )
281 }
282 }
283}
284
285fn compare_entries(a: &KeybindingEntry, b: &KeybindingEntry) -> Ordering {
286 let a_alphanumeric = a.label.base.chars().all(char::is_alphanumeric);
287 let b_alphanumeric = b.label.base.chars().all(char::is_alphanumeric);
288 a_alphanumeric
289 .cmp(&b_alphanumeric)
290 .then_with(|| {
291 a.label
292 .base
293 .to_lowercase()
294 .cmp(&b.label.base.to_lowercase())
295 })
296 .then_with(|| a.label.modified.cmp(&b.label.modified))
297 .then_with(|| uppercase_weight(a).cmp(&uppercase_weight(b)))
298 .then_with(|| modifier_weight(a.key).cmp(&modifier_weight(b.key)))
299 .then_with(|| a.label.full.cmp(&b.label.full))
300 .then_with(|| a.description.cmp(&b.description))
301}
302
303fn uppercase_weight(entry: &KeybindingEntry) -> u8 {
304 match entry.key.code {
305 KeyCode::Char(chr) if chr.is_uppercase() => 1,
306 _ => 0,
307 }
308}
309
310fn modifier_weight(key: Key) -> u8 {
311 u8::from(key.mods.contains(KeyModifiers::CONTROL))
312 | (u8::from(key.mods.contains(KeyModifiers::ALT)) << 1)
313 | (u8::from(key.mods.contains(KeyModifiers::SHIFT)) << 2)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::config::Config;
320 use crate::keys::Key;
321 use ratatui::layout::Rect;
322
323 #[test]
324 fn key_labels_use_control_pictures_arrows_and_modifier_sigils() {
325 assert_eq!(format_key(Key::parse("esc").unwrap()).full, "␛");
326 assert_eq!(format_key(Key::parse("enter").unwrap()).full, "ret");
327 assert_eq!(format_key(Key::parse("space").unwrap()).full, "⎵");
328 assert_eq!(format_key(Key::parse("left").unwrap()).full, "←");
329 assert_eq!(
330 format_key(Key::parse("ctrl+alt+space").unwrap()).full,
331 "⌃⌥⎵"
332 );
333 assert_eq!(format_key(Key::parse("shift+right").unwrap()).full, "⇧→");
334 assert_eq!(format_key(Key::parse("shift+j").unwrap()).full, "J");
335 assert_eq!(format_key(Key::parse("pageup").unwrap()).full, "pgup");
336 assert_eq!(format_key(Key::parse("backspace").unwrap()).full, "bksp");
337 }
338
339 #[test]
340 fn entries_sort_non_alphanumeric_first_then_by_displayed_base_key() {
341 let config = Config::parse(
342 r#"
343[/]
344cmd = "jump"
345
346[?]
347cmd = "quit"
348
349[f]
350cmd = "first"
351
352[F]
353cmd = "last"
354
355[ctrl+f]
356cmd = "page-down"
357"#,
358 )
359 .unwrap();
360
361 let entries = build_entries(&config.bindings);
362 let labels: Vec<_> = entries
363 .iter()
364 .map(|entry| entry.label.full.as_str())
365 .collect();
366
367 assert_eq!(labels, ["/", "?", "f", "F", "⌃f"]);
368 }
369
370 #[test]
371 fn entries_use_help_fallbacks_and_the_close_override() {
372 let config = Config::parse(
373 r#"
374[esc]
375cmd = "back"
376
377[x]
378sh = "attach-to-review\nignored"
379
380[y]
381cmd = "expand-recursively"
382help = " Custom\tCOPY\nignored"
383
384[z]
385sh = "printf z"
386help = " "
387"#,
388 )
389 .unwrap();
390
391 let entries = build_entries(&config.bindings);
392 let description = |key: &str| {
393 entries
394 .iter()
395 .find(|entry| entry.key == Key::parse(key).unwrap())
396 .unwrap()
397 .description
398 .as_str()
399 };
400
401 assert_eq!(description("esc"), "Close");
402 assert_eq!(description("x"), "`attach-to-r…`");
403 assert_eq!(description("y"), "Custom COPY");
406 assert_eq!(description("z"), "`printf z`");
408 }
409
410 #[test]
411 fn text_cleanup_uses_trimmed_first_line_and_replaces_controls() {
412 assert_eq!(
413 clean_first_line(" Keep\tthis\u{7}\nignore this "),
414 Some("Keep this".to_owned())
415 );
416 assert_eq!(clean_first_line(" \t \nignored"), None);
417 }
418
419 #[test]
420 fn truncation_counts_terminal_cells_and_includes_the_ellipsis() {
421 assert_eq!(
422 truncate_with_ellipsis("attach-to-review", 12),
423 "attach-to-r…"
424 );
425 assert_eq!(truncate_with_ellipsis("界界界", 5), "界界…");
426 assert_eq!(truncate_with_ellipsis("abc", 3), "abc");
427 assert_eq!(truncate_with_ellipsis("abc", 1), "…");
428 assert_eq!(truncate_with_ellipsis("abc", 0), "");
429 }
430
431 #[test]
432 fn grid_fills_down_columns_before_moving_right() {
433 let entries: Vec<_> = ["a", "b", "c", "d", "e"]
434 .into_iter()
435 .map(|name| {
436 let key = Key::parse(name).unwrap();
437 KeybindingEntry {
438 key,
439 label: format_key(key),
440 description: name.to_ascii_uppercase(),
441 }
442 })
443 .collect();
444
445 let grid = build_grid(&entries, 50);
446
447 assert_eq!(grid.column_width, 24);
448 assert_eq!(grid.rows, [vec![0, 3], vec![1, 4], vec![2]]);
449 }
450
451 #[test]
452 fn panel_scroll_resets_when_reopened_and_clamps_to_layout() {
453 let mut panel = KeybindingPanelState::default();
454 panel.open();
455 panel.record_layout(Rect::new(0, 10, 40, 6), 12, 4);
456 panel.scroll_by(20);
457 assert_eq!(panel.scroll(), 8);
458 assert!(panel.contains(20, 12));
459 assert!(!panel.contains(20, 9));
460
461 panel.close();
462 panel.open();
463
464 assert_eq!(panel.scroll(), 0);
465 }
466}