1use crate::event::{Event, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::{Key, Scope};
6use crate::text;
7use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
8
9use super::cells;
10use super::editor::Editor;
11use super::filter;
12use super::layer::{self, Backdrop, BarDrag, SurfacePosition};
13use super::rows::WHEEL_ROWS;
14use super::scrollbar::{self, ScrollMetrics};
15
16const DEFAULT_WIDTH: u16 = 64;
18
19const MAX_ROWS: u16 = 18;
21
22const MAX_KEYS_WIDTH: u16 = 24;
24
25pub struct HelpLayer<Msg> {
42 hints: Vec<(String, String)>,
43 width: u16,
44 dismissable: bool,
45 on_close: Msg,
46}
47
48#[derive(Debug, Default)]
49struct HelpMemory {
50 editor: Editor,
51 offset: usize,
52 visible: usize,
54 max_offset: usize,
55 list: Rect,
56 bar: BarDrag,
57}
58
59type Binding = (Vec<String>, String);
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64enum Row {
65 Group(String),
66 Binding { keys: Vec<String>, label: String, positions: Vec<usize> },
67}
68
69impl<Msg: Clone + 'static> HelpLayer<Msg> {
70 #[must_use]
72 pub fn new(on_close: Msg) -> Self {
73 Self { hints: Vec::new(), width: DEFAULT_WIDTH, dismissable: true, on_close }
74 }
75
76 #[must_use]
79 pub fn dismissable(mut self, dismissable: bool) -> Self {
80 self.dismissable = dismissable;
81 self
82 }
83
84 #[must_use]
87 pub fn hint(mut self, key: impl Into<String>, label: impl Into<String>) -> Self {
88 self.hints.push((key.into(), label.into()));
89 self
90 }
91
92 #[must_use]
94 pub fn width(mut self, cells: u16) -> Self {
95 self.width = cells;
96 self
97 }
98
99 fn rows(&self, cx: &PaintCx<'_>, query: &str) -> Vec<Row> {
101 let env = cx.env();
102 let i18n = env.i18n();
103 let mut groups: Vec<(String, Vec<Binding>)> = Vec::new();
104 groups.push((
105 i18n.translate("quvyta.help.screen", &[]),
106 self.hints.iter().map(|(key, label)| (vec![key.clone()], label.clone())).collect(),
107 ));
108 for (scope, group) in [(Scope::App, "quvyta.help.app"), (Scope::Global, "quvyta.help.global")] {
109 let bindings = env
110 .keymap()
111 .iter()
112 .filter(|(s, _, chords)| *s == scope && !chords.is_empty())
113 .map(|(_, action, chords)| {
114 (
115 chords.iter().map(crate::keymap::KeyChord::label).collect(),
116 i18n.translate(&scope.label_key(action), &[]),
117 )
118 })
119 .collect();
120 groups.push((i18n.translate(group, &[]), bindings));
121 }
122 let mut rows = Vec::new();
123 for (title, bindings) in groups {
124 let matched: Vec<Row> = bindings
125 .into_iter()
126 .filter_map(|(keys, label)| {
127 let positions = match filter::fuzzy(query, &label) {
128 Some(found) => found.positions,
129 None => {
130 filter::fuzzy(query, &keys.join(" "))?;
131 Vec::new()
132 }
133 };
134 Some(Row::Binding { keys, label, positions })
135 })
136 .collect();
137 if !matched.is_empty() {
138 rows.push(Row::Group(title));
139 rows.extend(matched);
140 }
141 }
142 rows
143 }
144
145 fn scroll(cx: &mut EventCx<'_, Msg>, to: impl FnOnce(usize, usize) -> usize) {
146 let memory = cx.memory::<HelpMemory>();
147 memory.offset = to(memory.offset, memory.visible.max(1)).min(memory.max_offset);
148 }
149}
150
151impl<Msg: Clone + 'static> Widget<Msg> for HelpLayer<Msg> {
152 fn measure(&self, _cx: &mut MeasureCx<'_>, _available: Size) -> Size {
153 Size::default()
154 }
155
156 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
157 cx.request_overlay(area);
158 }
159
160 fn paint_overlay(&self, cx: &mut PaintCx<'_>, _anchor: Rect) {
161 let screen = cx.clip();
162 let query = cx.memory::<HelpMemory>().editor.text().to_owned();
163 let rows = self.rows(cx, &query);
164 let padding = layer::padding(cx, "modal", self.dismissable);
165 let chrome = padding.vertical().saturating_add(6);
167 let room = screen.height.saturating_sub(chrome.saturating_add(2)).clamp(1, MAX_ROWS);
168 let visible = clamp_u16(i32::try_from(rows.len()).unwrap_or(i32::MAX)).clamp(1, room);
169 let width = self.width.min(screen.width.saturating_sub(2));
170 let look = layer::Look { style: "modal", variant: None, dismissable: self.dismissable };
171 let surface = layer::open(cx, Size::new(width, chrome.saturating_add(visible)), SurfacePosition::Center, look);
172 let inner = surface.inner;
173 let list = Rect::new(inner.x, inner.y + 4, inner.width, visible);
174 let bar = Rect::new(list.right() - 1, list.y, 1, list.height);
175 let metrics = {
176 let memory = cx.memory::<HelpMemory>();
177 if surface.fresh {
178 *memory = HelpMemory::default();
179 }
180 let metrics = ScrollMetrics { total: rows.len(), visible: usize::from(visible), offset: memory.offset };
181 memory.max_offset = metrics.max_offset();
182 memory.offset = memory.offset.min(memory.max_offset);
183 memory.visible = usize::from(visible);
184 memory.list = list;
185 memory.bar.place(metrics.overflows().then_some(bar));
186 ScrollMetrics { offset: memory.offset, ..metrics }
187 };
188 let editor = cx.memory::<HelpMemory>().editor.clone();
189 let bar_active = cx.memory::<HelpMemory>().bar.active(cx.pointer_anywhere());
190 cx.with_clip(surface.shown, |cx| {
191 let title = cx.env().i18n().translate("quvyta.help.title", &[]);
192 layer::title(cx, inner.x, inner.y, inner.width, &title);
193 let placeholder = cx.env().i18n().translate("quvyta.layer.filter", &[]);
194 filter::paint(cx, Rect::new(inner.x, inner.y + 2, inner.width, 1), &editor, &placeholder);
195 let content_width = if metrics.overflows() { list.width.saturating_sub(2) } else { list.width };
196 if rows.is_empty() {
197 let empty = cx.env().i18n().translate("quvyta.help.empty", &[]);
198 let style = cx.style("help-label", None, &[]).text();
199 cx.text(list.x, list.y, &empty, style, content_width);
200 }
201 let keys_width = rows
202 .iter()
203 .map(|row| match row {
204 Row::Binding { keys, .. } => cells::sum(keys.iter().map(|key| text::width(key).saturating_add(3))),
205 Row::Group(_) => 0,
206 })
207 .max()
208 .unwrap_or(0)
209 .min(MAX_KEYS_WIDTH);
210 let group_style = cx.style("help-group", None, &[]).text();
211 let key_style = cx.style("help-key", None, &[]).text();
212 let label_style = cx.style("help-label", None, &[]).text();
213 for (line, row) in rows.iter().skip(metrics.offset).take(usize::from(visible)).enumerate() {
214 let y = list.y + i32::try_from(line).unwrap_or(0);
215 match row {
216 Row::Group(title) => {
217 cx.text(list.x, y, title, group_style, content_width);
218 }
219 Row::Binding { keys, label, positions } => {
220 let mut x = list.x + 2;
221 let keys_end = x + i32::from(keys_width);
222 for key in keys {
223 let chip = format!(" {key} ");
224 let chip_width = text::width(&chip);
225 if x + i32::from(chip_width) > keys_end {
226 break;
227 }
228 x += i32::from(cx.text(x, y, &chip, key_style, chip_width)) + 1;
229 }
230 let label_x = keys_end + 2;
231 let budget = clamp_u16(list.x + i32::from(content_width) - label_x);
232 filter::paint_matched(cx, label_x, y, label, budget, positions, label_style);
233 }
234 }
235 }
236 if metrics.overflows() {
237 scrollbar::paint(cx, bar, metrics, bar_active, None);
238 }
239 let mut hints = Vec::new();
240 if self.dismissable {
241 hints.push(layer::hint(cx, "esc", "close"));
242 }
243 if metrics.overflows() {
244 hints.push(layer::hint(cx, "↑↓", "scroll"));
245 }
246 layer::paint_hints(cx, inner.x, inner.bottom() - 1, inner.width, &hints);
247 });
248 layer::finish(cx, &surface);
249 }
250
251 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
252 match layer::backdrop_event(cx, event, self.dismissable, false) {
253 Backdrop::Close => {
254 cx.emit(self.on_close.clone());
255 return true;
256 }
257 Backdrop::Swallowed | Backdrop::Inside => {
258 let Event::Mouse(mouse) = event else {
259 return true;
260 };
261 let (mut bar, metrics) = {
262 let memory = cx.memory::<HelpMemory>();
263 let total = memory.max_offset + memory.visible;
264 (memory.bar, ScrollMetrics { total, visible: memory.visible, offset: memory.offset })
265 };
266 let dragged = bar.event(cx, mouse, metrics);
267 let memory = cx.memory::<HelpMemory>();
268 memory.bar = bar;
269 if let Some(offset) = dragged {
270 memory.offset = offset.min(memory.max_offset);
271 } else if memory.list.contains(mouse.x, mouse.y) {
272 const WHEEL: usize = WHEEL_ROWS as usize;
273 match mouse.kind {
274 MouseKind::ScrollUp => Self::scroll(cx, |offset, _| offset.saturating_sub(WHEEL)),
275 MouseKind::ScrollDown => Self::scroll(cx, |offset, _| offset + WHEEL),
276 _ => {}
277 }
278 }
279 return true;
280 }
281 Backdrop::Ignored => {}
282 }
283 if let Event::Key(key) = event {
284 if key.is_plain(Key::Up) {
285 Self::scroll(cx, |offset, _| offset.saturating_sub(1));
286 return true;
287 }
288 if key.is_plain(Key::Down) {
289 Self::scroll(cx, |offset, _| offset + 1);
290 return true;
291 }
292 if key.is_plain(Key::PageUp) {
293 Self::scroll(cx, usize::saturating_sub);
294 return true;
295 }
296 if key.is_plain(Key::PageDown) {
297 Self::scroll(cx, |offset, page| offset + page);
298 return true;
299 }
300 }
301 let memory = cx.memory::<HelpMemory>();
302 let before = memory.editor.text().len();
303 let used = filter::edit(&mut memory.editor, event);
304 if used && memory.editor.text().len() != before {
305 memory.offset = 0;
306 }
307 used
308 }
309
310 fn focusable(&self) -> bool {
311 true
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use std::time::Duration;
318
319 use super::*;
320 use crate::runtime::{App, Command, Harness};
321 use crate::widget::View;
322 use crate::widgets::{Button, Text};
323
324 #[derive(Default)]
325 struct Demo {
326 open: bool,
327 saved: u32,
328 firm: bool,
329 }
330
331 #[derive(Clone)]
332 enum Msg {
333 Help,
334 Close,
335 Save,
336 }
337
338 impl App for Demo {
339 type Msg = Msg;
340 fn update(&mut self, msg: Msg) -> Command<Msg> {
341 match msg {
342 Msg::Help => self.open = true,
343 Msg::Close => self.open = false,
344 Msg::Save => self.saved += 1,
345 }
346 Command::none()
347 }
348 fn view(&self, ui: &mut View<'_, Msg>) {
349 ui.column(|ui| {
350 ui.add(Text::new("Deploys"));
351 ui.add(Button::new("Save").on_press(Msg::Save)).id("save");
352 if self.open {
353 ui.add(HelpLayer::new(Msg::Close).dismissable(!self.firm).hint("↑↓", "move between deploys"));
354 }
355 });
356 }
357 fn action(&self, name: &str) -> Option<Msg> {
358 match name {
359 "help" => Some(Msg::Help),
360 "save" => Some(Msg::Save),
361 _ => None,
362 }
363 }
364 }
365
366 fn opened(height: u16) -> Harness<Demo> {
367 opened_with(Demo::default(), height)
368 }
369
370 fn opened_with(demo: Demo, height: u16) -> Harness<Demo> {
371 let mut env = crate::env::Env::builtin();
372 env.keymap_mut().bind(Scope::App, "save", &["ctrl+s".parse().expect("chord")]);
373 let mut h = Harness::with_env(demo, env, 70, height);
374 h.press("?").advance(Duration::from_millis(200));
375 h
376 }
377
378 #[test]
379 fn lists_screen_hints_app_and_framework_bindings_in_groups() {
380 let h = opened(30);
381 let screen = h.screen();
382 assert!(h.app().open, "the global help action reaches the application");
383 for expected in [
384 "Keyboard shortcuts",
385 "This screen",
386 "↑↓",
387 "move between deploys",
388 "Application",
389 "ctrl s",
390 "General",
391 "ctrl q",
392 "quit",
393 ] {
394 assert!(screen.contains(expected), "{expected} missing:\n{screen}");
395 }
396 assert!(screen.find("This screen") < screen.find("Application"));
397 assert!(screen.find("Application") < screen.find("General"));
398 }
399
400 #[test]
401 fn typing_filters_and_escape_closes() {
402 let mut h = opened(30);
403 h.type_text("qt");
404 let screen = h.screen();
405 assert!(screen.contains("quit") && !screen.contains("ctrl s"), "{screen}");
406 assert!(!screen.contains("Application"), "empty groups are hidden");
407 let (x, y) = h.find("quit").expect("quit");
408 let accent = h.env().theme().color("accent");
409 assert_eq!(h.fg(u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0)), accent, "q matched");
410 h.type_text("zz");
411 assert!(h.screen().contains("No matching keys"));
412 h.press("ctrl+s");
413 assert_eq!(h.app().saved, 0, "application shortcuts pause while help is open");
414 h.press("esc");
415 assert!(!h.app().open);
416 assert!(!h.screen().contains("Keyboard shortcuts"));
417 }
418
419 #[test]
420 fn long_lists_scroll_and_reopen_fresh() {
421 let mut h = opened(14);
422 let screen = h.screen();
423 assert!(screen.contains("This screen") && !screen.contains("General"), "{screen}");
424 for _ in 0..6 {
425 h.press("pgdn");
426 }
427 assert!(h.screen().contains("alt b"), "the last binding comes into view: {}", h.screen());
428 h.type_text("q").press("esc").press("?").advance(Duration::from_millis(200));
429 assert!(h.screen().contains("This screen"), "reopening starts unfiltered at the top");
430 }
431
432 #[test]
433 fn the_close_mark_sits_in_the_top_right_corner_lights_three_cells_and_closes() {
434 let mut h = opened(30);
435 let screen = h.screen();
436 let lines: Vec<&str> = screen.lines().collect();
437 let title = lines.iter().position(|line| line.contains("Keyboard shortcuts")).unwrap_or_default();
438 assert!(lines[title - 1].ends_with('×') && !lines[title].contains('×'), "the row above the title: {screen}");
439 let (x, y) = h.find("×").expect("close mark");
440 let (column, row) = (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"));
441 let resting = h.bg(column, row);
442 h.hover(x + 1, y);
443 let lit = h.bg(column, row);
444 assert_ne!(lit, resting);
445 assert_eq!((h.bg(column - 1, row), h.bg(column + 1, row)), (lit, lit), "three cells light up");
446 let pillar = h.env().theme().style("modal", None, &[]);
447 assert!(pillar.get("pillar").is_some(), "the theme gives the surface a pillar");
448 let (left, _) = h.find("▌").expect("pillar");
449 assert!(
450 screen.lines().filter(|line| line.chars().nth(usize::try_from(left).unwrap_or(0)) == Some('▌')).count()
451 > 10
452 );
453 h.click(x - 1, y);
454 assert!(!h.app().open, "a click on the mark closes");
455 }
456
457 #[test]
458 fn a_help_layer_that_is_not_dismissable_ignores_escape_and_draws_no_mark() {
459 let mut h = opened_with(Demo { firm: true, ..Demo::default() }, 30);
460 let screen = h.screen();
461 assert!(!screen.contains('×') && !screen.contains("esc close"), "{screen}");
462 h.press("esc");
463 assert!(h.app().open);
464 }
465
466 #[test]
467 fn the_scrollbar_can_be_pressed_and_dragged() {
468 let mut h = opened(14);
469 assert!(!h.screen().contains("alt b"), "{}", h.screen());
470 let (x, top) = scrollbar_column(&h);
471 let bottom = top + 20;
472 h.mouse(MouseKind::Down(crate::event::MouseButton::Left), x, top);
473 h.mouse(MouseKind::Drag(crate::event::MouseButton::Left), x, bottom);
474 assert!(h.screen().contains("alt b"), "dragging to the end shows the last binding:\n{}", h.screen());
475 h.mouse(MouseKind::Up(crate::event::MouseButton::Left), x, bottom);
476 assert!(h.app().open, "the drag released outside the surface does not close the layer");
477 h.mouse(MouseKind::Down(crate::event::MouseButton::Left), x, top);
478 h.mouse(MouseKind::Up(crate::event::MouseButton::Left), x, top);
479 assert!(h.screen().contains("This screen"), "a press at the top scrolls back:\n{}", h.screen());
480 }
481
482 fn scrollbar_column(h: &Harness<Demo>) -> (i32, i32) {
485 let (_, title_y) = h.find("Keyboard shortcuts").expect("title");
486 let (mark_x, _) = h.find("×").expect("close mark");
487 (mark_x - 2, title_y + 4)
488 }
489}