Skip to main content

ite_cli/
keybindings.rs

1//! View-model and interaction state for the keybinding reference panel. It
2//! turns the application's effective keymap into stable display entries, lays
3//! them out as a width-aware column grid, and tracks open/scroll/onscreen-area
4//! state for keyboard and mouse routing.
5//!
6//! Binding semantics stay in `config`/`app`; pixels and terminal styles stay in
7//! `ui`. The reserved `?` toggle and Escape close keys live here alongside the
8//! panel behavior they control.
9
10use 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
21/// The reserved key that toggles the panel; user configs cannot rebind it
22/// (see `App::new`). Already in `Key`'s normalized form.
23pub const TOGGLE_KEY: Key = Key {
24    code: KeyCode::Char('?'),
25    mods: KeyModifiers::NONE,
26};
27/// Closes the open panel before its normal binding runs (see `App::handle_key`).
28pub 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    /// Indices into the entries slice, one inner vec per on-screen row,
56    /// filled down columns before moving right.
57    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            // The panel is only on screen while it is open, and then `esc`
194            // closes it instead of running its binding (see `App::handle_key`).
195            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(&section), available_width)
250}
251
252/// Lay out user entries first, followed by a blank row that the UI renders as
253/// a separator, then the remaining built-in entries.
254pub 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        // `help` is cleaned at display time: trimmed first line, controls
448        // become spaces.
449        assert_eq!(description("y"), "Custom COPY");
450        // Blank `help` falls back to the action-derived description.
451        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}