Skip to main content

tess/overlay/
help.rs

1//! Help overlay: lists every binding from KEY_REGISTRY, grouped by category,
2//! with type-to-filter. User remaps from keys.toml replace the displayed
3//! keys per command_name.
4
5use std::borrow::Cow;
6use std::cell::Cell;
7use std::collections::HashMap;
8
9use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
11use crate::keymap::{Category, KeyEntry, KEY_REGISTRY};
12use crate::overlay::{Overlay, OverlayFrame, OverlayOutcome};
13
14pub struct HelpOverlay {
15    filter: String,
16    cursor: usize,              // body-row index (0 = title row)
17    rows_offset: Cell<usize>,   // first visible body row (interior-mutable so render stays &self)
18    user_remaps: HashMap<String, Vec<String>>,
19}
20
21impl HelpOverlay {
22    pub fn new(user_remaps: HashMap<String, Vec<String>>) -> Self {
23        Self {
24            filter: String::new(),
25            cursor: 0,
26            rows_offset: Cell::new(0),
27            user_remaps,
28        }
29    }
30
31    fn visible_entries(&self) -> Vec<&'static KeyEntry> {
32        let needle = self.filter.to_lowercase();
33        KEY_REGISTRY.iter()
34            .filter(|e| {
35                if needle.is_empty() { return true; }
36                let keys_joined = e.keys.join(" ").to_lowercase();
37                e.description.to_lowercase().contains(&needle)
38                    || keys_joined.contains(&needle)
39            })
40            .collect()
41    }
42
43    fn display_keys<'a>(&'a self, entry: &'static KeyEntry) -> Vec<&'a str> {
44        if let Some(user) = self.user_remaps.get(entry.command_name) {
45            user.iter().map(String::as_str).collect()
46        } else {
47            entry.keys.iter().copied().collect()
48        }
49    }
50}
51
52impl Overlay for HelpOverlay {
53    fn handle_key(&mut self, key: KeyEvent) -> OverlayOutcome {
54        match (key.code, key.modifiers) {
55            (KeyCode::Esc, _) => {
56                if self.filter.is_empty() {
57                    OverlayOutcome::Close
58                } else {
59                    self.filter.clear();
60                    self.cursor = 0;
61                    self.rows_offset.set(0);
62                    OverlayOutcome::Stay
63                }
64            }
65            (KeyCode::Up, _) => {
66                self.cursor = self.cursor.saturating_sub(1);
67                OverlayOutcome::Stay
68            }
69            (KeyCode::Char('k'), m) if m == KeyModifiers::NONE => {
70                self.cursor = self.cursor.saturating_sub(1);
71                OverlayOutcome::Stay
72            }
73            (KeyCode::Down, _) => {
74                self.cursor = self.cursor.saturating_add(1);
75                OverlayOutcome::Stay
76            }
77            (KeyCode::Char('j'), m) if m == KeyModifiers::NONE => {
78                self.cursor = self.cursor.saturating_add(1);
79                OverlayOutcome::Stay
80            }
81            (KeyCode::PageUp, _) => { self.cursor = self.cursor.saturating_sub(10); OverlayOutcome::Stay }
82            (KeyCode::PageDown, _) => { self.cursor = self.cursor.saturating_add(10); OverlayOutcome::Stay }
83            (KeyCode::Home, _) => { self.cursor = 0; OverlayOutcome::Stay }
84            (KeyCode::Backspace, _) => {
85                self.filter.pop();
86                self.cursor = 0;
87                self.rows_offset.set(0);
88                OverlayOutcome::Stay
89            }
90            (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) => {
91                self.filter.push(c);
92                self.cursor = 0;
93                self.rows_offset.set(0);
94                OverlayOutcome::Stay
95            }
96            _ => OverlayOutcome::Stay,
97        }
98    }
99
100    fn render(&self, _width: u16, height: u16) -> OverlayFrame {
101        let entries = self.visible_entries();
102        let total = entries.len();
103        let mut body = Vec::new();
104        let title = if self.filter.is_empty() {
105            "Help".to_string()
106        } else {
107            format!("Help ({} matches for \"{}\")", total, self.filter)
108        };
109        body.push(title);
110        body.push(String::new());
111
112        // Compute the key column width once.
113        let key_col = entries.iter()
114            .map(|e| self.display_keys(e).join(" / ").chars().count())
115            .max()
116            .unwrap_or(0)
117            .min(30);
118
119        // Walk Category::ORDER, emit a header line then entries in that
120        // category that are in `entries`.
121        for cat in Category::ORDER {
122            let cat_entries: Vec<&KeyEntry> = entries.iter()
123                .copied()
124                .filter(|e| e.category == *cat)
125                .collect();
126            if cat_entries.is_empty() { continue; }
127            body.push(String::new());
128            body.push(cat.label().to_string());
129            for e in &cat_entries {
130                let keys_str = self.display_keys(e).join(" / ");
131                body.push(format!("  {keys_str:<key_col$}  {desc}", desc = e.description));
132            }
133        }
134
135        // Scroll: clamp cursor to body length, then adjust rows_offset to
136        // keep cursor in view (mirrors FilePicker's stable scroll algorithm).
137        let visible_rows = (height as usize).saturating_sub(1); // reserve bottom row for status
138        let cursor = self.cursor.min(body.len().saturating_sub(1));
139        let mut offset = self.rows_offset.get();
140        if visible_rows > 0 {
141            if cursor < offset {
142                // Cursor went off the top: scroll up to put it at the top.
143                offset = cursor;
144            } else if cursor >= offset + visible_rows {
145                // Cursor went off the bottom: scroll just enough to put it at the bottom.
146                offset = cursor + 1 - visible_rows;
147            }
148            // Otherwise: cursor is already visible; leave offset alone.
149        }
150        self.rows_offset.set(offset);
151
152        let clipped: Vec<String> = body.into_iter()
153            .skip(offset)
154            .take(visible_rows.max(1))
155            .collect();
156
157        let status = "[filter]  \u{2191}\u{2193} Esc".to_string();
158        OverlayFrame { body: clipped, status }
159    }
160
161    fn handle_mouse(&mut self, ev: crossterm::event::MouseEvent, _body_rows: u16) -> OverlayOutcome {
162        use crossterm::event::MouseEventKind;
163        match ev.kind {
164            MouseEventKind::ScrollDown => { self.cursor = self.cursor.saturating_add(1); OverlayOutcome::Stay }
165            MouseEventKind::ScrollUp   => { self.cursor = self.cursor.saturating_sub(1); OverlayOutcome::Stay }
166            _ => OverlayOutcome::Stay,
167        }
168    }
169
170    fn title(&self) -> Cow<'_, str> { Cow::Borrowed("Help") }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crossterm::event::{MouseEvent, MouseEventKind};
177    use std::collections::HashMap;
178
179    fn help() -> HelpOverlay { HelpOverlay::new(HashMap::new()) }
180
181    #[test]
182    fn esc_closes_when_filter_empty() {
183        let mut h = help();
184        let out = h.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
185        assert!(matches!(out, OverlayOutcome::Close));
186    }
187
188    #[test]
189    fn esc_clears_filter_first() {
190        let mut h = help();
191        h.handle_key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
192        let out = h.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
193        assert!(matches!(out, OverlayOutcome::Stay));
194        assert_eq!(h.filter, "");
195    }
196
197    #[test]
198    fn filter_matches_description_substring() {
199        let mut h = help();
200        h.handle_key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
201        h.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
202        h.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
203        let entries = h.visible_entries();
204        assert!(entries.iter().any(|e| e.command_name == "mark-set"));
205        assert!(entries.iter().any(|e| e.command_name == "mark-jump"));
206        assert!(!entries.iter().any(|e| e.command_name == "scroll-down"));
207    }
208
209    #[test]
210    fn user_remap_replaces_default_keys() {
211        let mut remaps = HashMap::new();
212        remaps.insert("scroll-down".to_string(), vec!["F3".to_string(), "Space".to_string()]);
213        let h = HelpOverlay::new(remaps);
214        let entry = KEY_REGISTRY.iter().find(|e| e.command_name == "scroll-down").unwrap();
215        let displayed = h.display_keys(entry);
216        assert_eq!(displayed, vec!["F3", "Space"]);
217    }
218
219    #[test]
220    fn render_includes_category_headers_in_fixed_order() {
221        let h = help();
222        let frame = h.render(80, 200); // tall enough to show all categories without clipping
223        // Find the row indices of each category label.
224        let positions: Vec<usize> = Category::ORDER.iter()
225            .map(|c| frame.body.iter().position(|l| l == c.label()).unwrap_or(usize::MAX))
226            .collect();
227        // Strictly ascending (with usize::MAX guard for any category missing).
228        for w in positions.windows(2) {
229            assert!(w[0] < w[1], "categories out of order: {:?}", positions);
230        }
231    }
232
233    #[test]
234    fn render_filter_title_shows_matches() {
235        let mut h = help();
236        h.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
237        let frame = h.render(80, 30);
238        assert!(frame.body[0].starts_with("Help ("), "title: {:?}", frame.body[0]);
239        assert!(frame.body[0].contains("\"q\""));
240    }
241
242    #[test]
243    fn scroll_offset_keeps_cursor_in_band_stably() {
244        let mut h = help();
245        // Move cursor far down — past the visible window of a 8-row terminal.
246        for _ in 0..15 { h.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); }
247        let _ = h.render(80, 8);  // visible_rows = 7
248        // cursor = 15, visible_rows = 7 → offset = 9 (cursor at bottom of window).
249        assert_eq!(h.rows_offset.get(), 9);
250
251        // Scroll up — cursor went off the top.
252        for _ in 0..10 { h.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); }
253        let _ = h.render(80, 8);
254        // cursor = 5, offset was 9, 5 < 9 → offset = 5.
255        assert_eq!(h.rows_offset.get(), 5);
256    }
257
258    #[test]
259    fn filter_change_resets_scroll() {
260        let mut h = help();
261        for _ in 0..20 { h.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); }
262        let _ = h.render(80, 8);
263        assert!(h.rows_offset.get() > 0, "should be scrolled after moving down");
264        // Type a filter — should reset both cursor and scroll.
265        h.handle_key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
266        assert_eq!(h.cursor, 0);
267        assert_eq!(h.rows_offset.get(), 0);
268    }
269
270    #[test]
271    fn scrollwheel_moves_help_cursor() {
272        let mut h = help();
273        let me = MouseEvent { kind: MouseEventKind::ScrollDown, column: 0, row: 0, modifiers: KeyModifiers::NONE };
274        h.handle_mouse(me, 10);
275        assert_eq!(h.cursor, 1);
276    }
277}