Skip to main content

ite_cli/
keybindings.rs

1//! Keybinding-panel entries, formatting, layout, and scroll state.
2
3use 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
14/// The reserved key that toggles the panel; user configs cannot rebind it
15/// (see `App::new`). Already in `Key`'s normalized form.
16pub const TOGGLE_KEY: Key = Key {
17    code: KeyCode::Char('?'),
18    mods: KeyModifiers::NONE,
19};
20/// Closes the open panel before its normal binding runs (see `App::handle_key`).
21pub 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    /// Indices into the entries slice, one inner vec per on-screen row,
49    /// filled down columns before moving right.
50    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            // The panel is only on screen while it is open, and then `esc`
187            // closes it instead of running its binding (see `App::handle_key`).
188            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        // `help` is cleaned at display time: trimmed first line, controls
404        // become spaces.
405        assert_eq!(description("y"), "Custom COPY");
406        // Blank `help` falls back to the action-derived description.
407        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}