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