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`, and
16/// `key-hint-key.faint`, `key-hint-label.faint` for a [faint](Self::faint) bar.
17#[derive(Debug, Clone, Default)]
18pub struct KeyHints {
19    left: Vec<Hint>,
20    actions: Vec<Action>,
21    faint: bool,
22}
23
24/// A keymap action on the bar: where it goes and, when the application names it, its label.
25#[derive(Debug, Clone)]
26struct Action {
27    scope: Scope,
28    name: String,
29    place: Place,
30    label: Option<String>,
31}
32
33/// Where an action's hint sits on the bar.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum Place {
36    /// Before the plain hints, the last to drop.
37    First,
38    /// After the plain hints.
39    Left,
40    /// In the right group.
41    Right,
42}
43
44impl KeyHints {
45    /// An empty bar.
46    #[must_use]
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Adds a hint on the left: `key` such as `"↑↓"` and its `label`.
52    #[must_use]
53    pub fn hint(mut self, key: impl Into<String>, label: impl Into<String>) -> Self {
54        self.left.push((key.into(), label.into()));
55        self
56    }
57
58    /// Adds a keymap action on the left; its keys and translated label come from the keymap
59    /// and the locale.
60    #[must_use]
61    pub fn action(self, scope: Scope, action: impl Into<String>) -> Self {
62        self.with_action(scope, action.into(), Place::Left, None)
63    }
64
65    /// Adds a keymap action before every plain [`hint`](Self::hint), resolved like
66    /// [`action`](Self::action). When the bar is too narrow it is the last of the left group to
67    /// drop, for the one key a screen cannot do without, such as the key that brings a closed
68    /// panel back.
69    #[must_use]
70    pub fn action_first(self, scope: Scope, action: impl Into<String>) -> Self {
71        self.with_action(scope, action.into(), Place::First, None)
72    }
73
74    /// Adds a keymap action on the left with the application's own `label`: the key comes from
75    /// the keymap, so it follows the person's bindings, and the words from the application, for
76    /// a key whose meaning changes with the screen, such as `enter` saying "install" or "open".
77    /// Like [`action`](Self::action), an action without a chord draws nothing.
78    #[must_use]
79    pub fn action_labelled(self, scope: Scope, action: impl Into<String>, label: impl Into<String>) -> Self {
80        self.with_action(scope, action.into(), Place::Left, Some(label.into()))
81    }
82
83    /// Adds a keymap action on the right.
84    #[must_use]
85    pub fn action_right(self, scope: Scope, action: impl Into<String>) -> Self {
86        self.with_action(scope, action.into(), Place::Right, None)
87    }
88
89    /// Draws the whole bar in the faint tone, keys and labels alike, for a screen that has gone
90    /// quiet, as [`Breadcrumb::faint`](super::Breadcrumb::faint) does for a path.
91    #[must_use]
92    pub fn faint(mut self, faint: bool) -> Self {
93        self.faint = faint;
94        self
95    }
96
97    fn with_action(mut self, scope: Scope, name: String, place: Place, label: Option<String>) -> Self {
98        self.actions.push(Action { scope, name, place, label });
99        self
100    }
101
102    fn resolved(&self, cx: &PaintCx<'_>) -> (Vec<Hint>, Vec<Hint>) {
103        let mut first = Vec::new();
104        let mut left = self.left.clone();
105        let mut right = Vec::new();
106        for action in &self.actions {
107            let Some(key) = cx.env().keymap().label_for(action.scope, &action.name) else {
108                continue;
109            };
110            let label = action
111                .label
112                .clone()
113                .unwrap_or_else(|| cx.env().i18n().translate(&action.scope.label_key(&action.name), &[]));
114            match action.place {
115                Place::First => first.push((key, label)),
116                Place::Left => left.push((key, label)),
117                Place::Right => right.push((key, label)),
118            }
119        }
120        first.append(&mut left);
121        (first, right)
122    }
123}
124
125/// A key label and its description.
126type Hint = (String, String);
127
128/// Cells between two hints.
129const SPACING: u16 = 3;
130
131/// The padded key, a space and the label; saturating, as a hint may be wider than any screen.
132fn hint_width(hint: &Hint) -> u16 {
133    cells::sum([text::width(&hint.0), 2, 1, text::width(&hint.1)])
134}
135
136impl<Msg: 'static> Widget<Msg> for KeyHints {
137    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
138        Size::new(available.width, 1.min(available.height))
139    }
140
141    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
142        let bar = cx.style("key-hints", None, &[]);
143        let background = bar.text().bg.unwrap_or_else(|| cx.color("surface"));
144        cx.clear(area, background);
145        let inner = area.inset(bar.padding());
146        let variant = self.faint.then_some("faint");
147        let key_style = cx.style("key-hint-key", variant, &[]).text();
148        let label_style = cx.style("key-hint-label", variant, &[]).text();
149        let (mut left, right) = self.resolved(cx);
150
151        let group_width = |hints: &[Hint]| -> u16 {
152            let count = u16::try_from(hints.len()).unwrap_or(u16::MAX);
153            let hints = cells::sum(hints.iter().map(hint_width));
154            hints.saturating_add(SPACING.saturating_mul(count.saturating_sub(1)))
155        };
156        let right_width = group_width(&right);
157        let separation = if right.is_empty() { 0 } else { SPACING };
158        let left_budget = inner.width.saturating_sub(right_width.saturating_add(separation));
159        while group_width(&left) > left_budget {
160            left.pop();
161        }
162        let draw = |cx: &mut PaintCx<'_>, mut x: i32, hints: &[Hint]| {
163            for (key, label) in hints {
164                let padded = format!(" {key} ");
165                x += i32::from(cx.text(x, inner.y, &padded, key_style, text::width(&padded))) + 1;
166                x += i32::from(cx.text(x, inner.y, label, label_style, text::width(label))) + i32::from(SPACING);
167            }
168        };
169        draw(cx, inner.x, &left);
170        draw(cx, inner.right() - i32::from(right_width), &right);
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::runtime::{App, Command, Harness};
178    use crate::widget::View;
179
180    struct Demo;
181
182    impl App for Demo {
183        type Msg = ();
184        fn update(&mut self, _: ()) -> Command<()> {
185            Command::none()
186        }
187        fn view(&self, ui: &mut View<'_, ()>) {
188            ui.add(
189                KeyHints::new()
190                    .hint("↑↓", "move")
191                    .action(Scope::Global, "focus-next")
192                    .action_right(Scope::Global, "quit"),
193            )
194            .fill_width();
195        }
196    }
197
198    #[test]
199    fn draws_hints_from_keymap_and_drops_what_does_not_fit() {
200        let wide = Harness::new(Demo, 50, 1);
201        assert_eq!(wide.screen(), "   ↑↓  move    tab  next            ctrl q  quit\n");
202        let narrow = Harness::new(Demo, 30, 1);
203        assert_eq!(narrow.screen(), "   ↑↓  move     ctrl q  quit\n");
204    }
205
206    /// A bar with a plain hint, an action that must stay and an action named by the application.
207    struct Ordered {
208        faint: bool,
209    }
210
211    impl App for Ordered {
212        type Msg = ();
213        fn update(&mut self, (): ()) -> Command<()> {
214            Command::none()
215        }
216        fn view(&self, ui: &mut View<'_, ()>) {
217            ui.add(
218                KeyHints::new()
219                    .hint("↑↓", "move")
220                    .action_labelled(Scope::Global, "focus-next", "install")
221                    .action_first(Scope::Global, "quit")
222                    .faint(self.faint),
223            )
224            .fill_width();
225        }
226    }
227
228    #[test]
229    fn a_first_action_comes_before_the_plain_hints_and_is_the_last_to_drop() {
230        let wide = Harness::new(Ordered { faint: false }, 60, 1);
231        assert_eq!(wide.screen(), "   ctrl q  quit    ↑↓  move    tab  install\n");
232        let narrow = Harness::new(Ordered { faint: false }, 20, 1);
233        assert_eq!(narrow.screen(), "   ctrl q  quit\n", "the others drop first");
234    }
235
236    #[test]
237    fn a_labelled_and_a_first_action_follow_the_persons_keymap() {
238        let mut env = crate::env::Env::builtin();
239        env.keymap_mut().bind(Scope::Global, "quit", &["ctrl+x".parse().expect("chord")]);
240        env.keymap_mut().bind(Scope::Global, "focus-next", &["f6".parse().expect("chord")]);
241        let h = Harness::with_env(Ordered { faint: false }, env, 60, 1);
242        assert_eq!(h.screen(), "   ctrl x  quit    ↑↓  move    f6  install\n");
243    }
244
245    #[test]
246    fn a_faint_bar_draws_its_keys_and_labels_quieter() {
247        let loud = Harness::new(Ordered { faint: false }, 60, 1);
248        let quiet = Harness::new(Ordered { faint: true }, 60, 1);
249        assert_eq!(loud.screen(), quiet.screen(), "the same hints");
250        assert_ne!(loud.fg(4, 0), quiet.fg(4, 0), "the key steps back");
251        assert_ne!(loud.bg(4, 0), quiet.bg(4, 0));
252        assert_ne!(loud.fg(11, 0), quiet.fg(11, 0), "and so does its label");
253    }
254
255    #[test]
256    fn labels_follow_the_language() {
257        let mut h = Harness::new(Demo, 50, 1);
258        h.set_locale("tr");
259        assert!(h.screen().contains("ctrl q  çık"));
260    }
261
262    #[test]
263    fn hints_wider_than_any_screen_are_dropped_without_overflowing() {
264        struct Huge;
265
266        impl App for Huge {
267            type Msg = ();
268            fn update(&mut self, (): ()) -> Command<()> {
269                Command::none()
270            }
271            fn view(&self, ui: &mut View<'_, ()>) {
272                let long = "k".repeat(40_000);
273                ui.add(KeyHints::new().hint(long.clone(), long.clone()).hint(long, "move")).fill_width();
274            }
275        }
276
277        let h = Harness::new(Huge, 30, 1);
278        assert_eq!(h.screen(), "\n");
279    }
280}