1use crate::geometry::{Rect, Size};
4use crate::keymap::Scope;
5use crate::text;
6use crate::widget::{MeasureCx, PaintCx, Widget};
7
8use super::cells;
9
10#[derive(Debug, Clone, Default)]
18pub struct KeyHints {
19 left: Vec<Hint>,
20 actions: Vec<Action>,
21 faint: bool,
22}
23
24#[derive(Debug, Clone)]
26struct Action {
27 scope: Scope,
28 name: String,
29 place: Place,
30 label: Option<String>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum Place {
36 First,
38 Left,
40 Right,
42}
43
44impl KeyHints {
45 #[must_use]
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 #[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 #[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 #[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 #[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 #[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 #[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
125type Hint = (String, String);
127
128const SPACING: u16 = 3;
130
131fn 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 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}