Skip to main content

qframe/widgets/
help_layer.rs

1//! The key binding overview opened with `?`.
2
3use 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
16/// Width of the layer when none is set, in cells.
17const DEFAULT_WIDTH: u16 = 64;
18
19/// Most rows of bindings shown before the list scrolls.
20const MAX_ROWS: u16 = 18;
21
22/// Widest key column, in cells; longer chord lists are cut.
23const MAX_KEYS_WIDTH: u16 = 24;
24
25/// A layer listing every key binding: the hints of the current screen first, then the
26/// application's keymap actions, then the framework's. Labels come from the language files
27/// (`keys.<action>` and `quvyta.keys.<action>`), keys from the keymap, so rebinding a key or
28/// switching the language updates the list.
29///
30/// Typing filters the list with fuzzy matching (matched characters take the match colour);
31/// ↑/↓, PgUp/PgDn and the wheel scroll it. It is dismissable by default: Esc and the close mark
32/// `×` at the top right send the close message; [`dismissable(false)`](HelpLayer::dismissable)
33/// turns both off. Add it to the view while it should be shown; it opens as a modal layer (see
34/// [`Modal`](super::Modal)), with the same pillar down its left edge. Applications usually open it
35/// from the global `help` action, bound to `?`.
36///
37/// Style keys: `modal`, `modal-title`, `close-mark`, `layer-backdrop`, `layer-filter`, `layer-filter-mark`,
38/// `layer-filter-placeholder`, `layer-filter-cursor`, `layer-match`, `help-group`, `help-key`,
39/// `help-label`, `layer-hint-key`, `layer-hint-label`. Text: `quvyta.help.*`,
40/// `quvyta.layer.*`.
41pub 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    /// Rows shown and the largest offset in the last frame, for scrolling between frames.
53    visible: usize,
54    max_offset: usize,
55    list: Rect,
56    bar: BarDrag,
57}
58
59/// The keys of a binding and its label.
60type Binding = (Vec<String>, String);
61
62/// One row of the list.
63#[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    /// A help layer; Esc and its close mark send `on_close`.
71    #[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    /// Whether Esc and the close mark close the layer; `true` by default. With `false` neither
77    /// works, the mark is not drawn and the application closes the layer itself.
78    #[must_use]
79    pub fn dismissable(mut self, dismissable: bool) -> Self {
80        self.dismissable = dismissable;
81        self
82    }
83
84    /// Adds a key of the current screen that is not in the keymap, e.g. `("↑↓", "move")`. These
85    /// come first, under "This screen".
86    #[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    /// Width in cells, padding included; 64 by default. Narrow screens shrink it.
93    #[must_use]
94    pub fn width(mut self, cells: u16) -> Self {
95        self.width = cells;
96        self
97    }
98
99    /// The rows for `query`: groups with at least one matching binding.
100    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        // Title, filter and hint line, each with a blank row after or before it.
166        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    /// The scrollbar column (the list's last column, just left of the close mark's padding) and
483    /// the list's first row, four rows under the title.
484    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}