Skip to main content

qframe/widgets/
key_hints.rs

1//! The key hint bar.
2
3use crate::geometry::{Rect, Size};
4use crate::keymap::Scope;
5use crate::text;
6use crate::widget::{MeasureCx, PaintCx, Widget};
7
8use super::cells;
9
10/// A bar of key hints such as `tab next  ctrl q quit`, fed from the keymap and from hints
11/// the application adds for the current screen.
12///
13/// Keys sit on a raised surface and labels are faint; nothing is bracketed. When the bar is
14/// too narrow, hints are dropped from the end of the left group first; the right group stays.
15/// Style keys: `key-hints` (`bg`, `padding`), `key-hint-key`, `key-hint-label`.
16#[derive(Debug, Clone, Default)]
17pub struct KeyHints {
18    left: Vec<Hint>,
19    actions: Vec<(Scope, String, bool)>,
20}
21
22impl KeyHints {
23    /// An empty bar.
24    #[must_use]
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Adds a hint on the left: `key` such as `"↑↓"` and its `label`.
30    #[must_use]
31    pub fn hint(mut self, key: impl Into<String>, label: impl Into<String>) -> Self {
32        self.left.push((key.into(), label.into()));
33        self
34    }
35
36    /// Adds a keymap action on the left; its keys and translated label come from the keymap
37    /// and the locale.
38    #[must_use]
39    pub fn action(mut self, scope: Scope, action: impl Into<String>) -> Self {
40        self.actions.push((scope, action.into(), false));
41        self
42    }
43
44    /// Adds a keymap action on the right.
45    #[must_use]
46    pub fn action_right(mut self, scope: Scope, action: impl Into<String>) -> Self {
47        self.actions.push((scope, action.into(), true));
48        self
49    }
50
51    fn resolved(&self, cx: &PaintCx<'_>) -> (Vec<Hint>, Vec<Hint>) {
52        let mut left = self.left.clone();
53        let mut right = Vec::new();
54        for (scope, action, on_right) in &self.actions {
55            let chords = cx.env().keymap().chords_for(*scope, action);
56            let Some(chord) = chords.first() else {
57                continue;
58            };
59            let label = cx.env().i18n().translate(&scope.label_key(action), &[]);
60            let hint = (chord.label(), label);
61            if *on_right {
62                right.push(hint);
63            } else {
64                left.push(hint);
65            }
66        }
67        (left, right)
68    }
69}
70
71/// A key label and its description.
72type Hint = (String, String);
73
74/// Cells between two hints.
75const SPACING: u16 = 3;
76
77/// The padded key, a space and the label; saturating, as a hint may be wider than any screen.
78fn hint_width(hint: &Hint) -> u16 {
79    cells::sum([text::width(&hint.0), 2, 1, text::width(&hint.1)])
80}
81
82impl<Msg: 'static> Widget<Msg> for KeyHints {
83    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
84        Size::new(available.width, 1.min(available.height))
85    }
86
87    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
88        let bar = cx.style("key-hints", None, &[]);
89        let background = bar.text().bg.unwrap_or_else(|| cx.color("surface"));
90        cx.clear(area, background);
91        let inner = area.inset(bar.padding());
92        let key_style = cx.style("key-hint-key", None, &[]).text();
93        let label_style = cx.style("key-hint-label", None, &[]).text();
94        let (mut left, right) = self.resolved(cx);
95
96        let group_width = |hints: &[Hint]| -> u16 {
97            let count = u16::try_from(hints.len()).unwrap_or(u16::MAX);
98            let hints = cells::sum(hints.iter().map(hint_width));
99            hints.saturating_add(SPACING.saturating_mul(count.saturating_sub(1)))
100        };
101        let right_width = group_width(&right);
102        let separation = if right.is_empty() { 0 } else { SPACING };
103        let left_budget = inner.width.saturating_sub(right_width.saturating_add(separation));
104        while group_width(&left) > left_budget {
105            left.pop();
106        }
107        let draw = |cx: &mut PaintCx<'_>, mut x: i32, hints: &[Hint]| {
108            for (key, label) in hints {
109                let padded = format!(" {key} ");
110                x += i32::from(cx.text(x, inner.y, &padded, key_style, text::width(&padded))) + 1;
111                x += i32::from(cx.text(x, inner.y, label, label_style, text::width(label))) + i32::from(SPACING);
112            }
113        };
114        draw(cx, inner.x, &left);
115        draw(cx, inner.right() - i32::from(right_width), &right);
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::runtime::{App, Command, Harness};
123    use crate::widget::View;
124
125    struct Demo;
126
127    impl App for Demo {
128        type Msg = ();
129        fn update(&mut self, _: ()) -> Command<()> {
130            Command::none()
131        }
132        fn view(&self, ui: &mut View<'_, ()>) {
133            ui.add(
134                KeyHints::new()
135                    .hint("↑↓", "move")
136                    .action(Scope::Global, "focus-next")
137                    .action_right(Scope::Global, "quit"),
138            )
139            .fill_width();
140        }
141    }
142
143    #[test]
144    fn draws_hints_from_keymap_and_drops_what_does_not_fit() {
145        let wide = Harness::new(Demo, 50, 1);
146        assert_eq!(wide.screen(), "   ↑↓  move    tab  next            ctrl q  quit\n");
147        let narrow = Harness::new(Demo, 30, 1);
148        assert_eq!(narrow.screen(), "   ↑↓  move     ctrl q  quit\n");
149    }
150
151    #[test]
152    fn labels_follow_the_language() {
153        let mut h = Harness::new(Demo, 50, 1);
154        h.set_locale("tr");
155        assert!(h.screen().contains("ctrl q  çık"));
156    }
157
158    #[test]
159    fn hints_wider_than_any_screen_are_dropped_without_overflowing() {
160        struct Huge;
161
162        impl App for Huge {
163            type Msg = ();
164            fn update(&mut self, (): ()) -> Command<()> {
165                Command::none()
166            }
167            fn view(&self, ui: &mut View<'_, ()>) {
168                let long = "k".repeat(40_000);
169                ui.add(KeyHints::new().hint(long.clone(), long.clone()).hint(long, "move")).fill_width();
170            }
171        }
172
173        let h = Harness::new(Huge, 30, 1);
174        assert_eq!(h.screen(), "\n");
175    }
176}