1use 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, rows_offset: Cell<usize>, 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 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 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 let visible_rows = (height as usize).saturating_sub(1); 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 offset = cursor;
144 } else if cursor >= offset + visible_rows {
145 offset = cursor + 1 - visible_rows;
147 }
148 }
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); 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 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 for _ in 0..15 { h.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); }
247 let _ = h.render(80, 8); assert_eq!(h.rows_offset.get(), 9);
250
251 for _ in 0..10 { h.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); }
253 let _ = h.render(80, 8);
254 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 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}