返回 CodeWhale
hints.rs
1 //! Key hints: `↑↓ move · Enter select · Esc cancel`.
2 //!
3 //! Lifted from the engine's `ActionHint` and `action_footer_lines`
4 //! (`crates/tui/src/tui/views/mod.rs`, `Hmbown/CodeWhale` `58b1dd3dd`): hints
5 //! wrap onto another row rather than run off the edge, and no action is ever
6 //! dropped (#3732). The key is bold `Foreground` and the verb `Muted`, joined
7 //! by ` · `. Verbs come first and lower case: "move", not "Navigation".
8 //!
9 //! A row built from a [`Keymap`] ([`KeyHints::from_keymap`]) folds by
10 //! priority when it is too narrow: the lowest-priority hints drop first, the
11 //! highest stays, and a `? more` hint says that something was dropped.
12
13 use std::borrow::Cow;
14 use unicode_segmentation::UnicodeSegmentation;
15
16 use ratatui::{
17 buffer::Buffer,
18 layout::Rect,
19 style::Modifier,
20 text::{Line, Span},
21 widgets::{Paragraph, Widget},
22 };
23
24 use crate::{Keymap, Paint, Role, Theme, keys::Platform, text};
25
26 /// One key and what it does.
27 #[derive(Clone, Debug, PartialEq, Eq)]
28 pub struct KeyHint {
29 pub keys: Cow<'static, str>,
30 pub verb: Cow<'static, str>,
31 /// `false` dims the hint: the key exists but does nothing right now.
32 pub enabled: bool,
33 }
34
35 impl KeyHint {
36 #[must_use]
37 pub fn new(keys: impl Into<Cow<'static, str>>, verb: impl Into<Cow<'static, str>>) -> Self {
38 Self {
39 keys: keys.into(),
40 verb: verb.into(),
41 enabled: true,
42 }
43 }
44
45 /// A hint whose key is spelled from a key event by [`crate::keys`], so
46 /// hints and bindings share one spelling: `Ctrl+O`, `⌥V`, `Shift+Tab`.
47 #[must_use]
48 pub fn chord(
49 key: &crossterm::event::KeyEvent,
50 platform: crate::keys::Platform,
51 verb: impl Into<Cow<'static, str>>,
52 ) -> Self {
53 Self::new(crate::keys::chord_label(key, platform), verb)
54 }
55
56 #[must_use]
57 pub fn disabled(mut self) -> Self {
58 self.enabled = false;
59 self
60 }
61
62 fn width(&self) -> usize {
63 text::width(&text::display_safe(&self.keys))
64 + 1
65 + text::width(&text::display_safe(&self.verb))
66 }
67
68 fn spans(&self, theme: &Theme) -> [Span<'static>; 3] {
69 let (key_style, verb_style) = if self.enabled {
70 (
71 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
72 theme.fg(Role::Muted),
73 )
74 } else {
75 (
76 theme.fg(Role::Muted),
77 theme.fg(Role::Muted).add_modifier(Modifier::DIM),
78 )
79 };
80 [
81 Span::styled(text::display_safe(&self.keys).into_owned(), key_style),
82 Span::raw(" "),
83 Span::styled(text::display_safe(&self.verb).into_owned(), verb_style),
84 ]
85 }
86 }
87
88 /// The words [`KeyHints::from_keymap`] shows itself. The kit owns no copy
89 /// beyond this English default: a host passes its own per locale.
90 #[derive(Clone, Debug, PartialEq, Eq)]
91 pub struct KeyHintsWords {
92 /// The verb beside the help key when hints were folded away: `? more`.
93 pub more: Cow<'static, str>,
94 }
95
96 impl Default for KeyHintsWords {
97 fn default() -> Self {
98 Self {
99 more: Cow::Borrowed("more"),
100 }
101 }
102 }
103
104 /// A row of key hints that wraps instead of clipping.
105 #[derive(Clone, Debug, Default, PartialEq, Eq)]
106 pub struct KeyHints {
107 pub items: Vec<KeyHint>,
108 }
109
110 impl KeyHints {
111 #[must_use]
112 pub fn new(items: Vec<KeyHint>) -> Self {
113 Self { items }
114 }
115
116 /// Hints for the bindings of `keymap` that fit one row `width` cells
117 /// wide, in declaration order.
118 ///
119 /// When the row is too narrow the lowest-priority hints drop first (a
120 /// tie drops the later declaration), the highest-priority hint is always
121 /// kept, and a hint is never cut in half. If something was dropped and
122 /// the keymap has a help binding, the row ends with `? more` (the help
123 /// key, then `words.more`). Disabled bindings show dimmed, and bindings
124 /// with no verb and the help binding itself are not ordinary hints.
125 #[must_use]
126 pub fn from_keymap<A>(
127 keymap: &Keymap<A>,
128 platform: Platform,
129 width: u16,
130 words: &KeyHintsWords,
131 ) -> Self {
132 let shown: Vec<(u8, KeyHint)> = keymap
133 .bindings()
134 .iter()
135 .filter_map(|b| b.hint(platform).map(|h| (b.priority, h)))
136 .collect();
137 let more = keymap
138 .help_binding()
139 .map(|b| KeyHint::new(b.keys.label(platform), words.more.clone()));
140 let (width, sep) = (usize::from(width), Self::separator_width(platform.ascii));
141
142 // Most important first; a tie keeps the earlier declaration.
143 let mut by_importance: Vec<usize> = (0..shown.len()).collect();
144 by_importance.sort_by_key(|&i| (std::cmp::Reverse(shown[i].0), i));
145 let fits = |keep: usize| -> bool {
146 let hints: usize = by_importance[..keep]
147 .iter()
148 .map(|&i| shown[i].1.width())
149 .sum();
150 let more = match (&more, keep < shown.len()) {
151 (Some(more), true) => sep + more.width(),
152 _ => 0,
153 };
154 hints + sep * keep.saturating_sub(1) + more <= width
155 };
156 let mut keep = shown.len();
157 while keep > 1 && !fits(keep) {
158 keep -= 1;
159 }
160
161 let mut kept = by_importance[..keep].to_vec();
162 kept.sort_unstable();
163 let mut items: Vec<KeyHint> = kept.into_iter().map(|i| shown[i].1.clone()).collect();
164 if keep < shown.len()
165 && let Some(more) = more
166 {
167 // Even the one hint that must stay may leave no room for it.
168 let room = fits_after(&items, sep, width, more.width());
169 if room {
170 items.push(more);
171 }
172 }
173 Self::new(items)
174 }
175
176 /// The width of the separator [`KeyHints::lines`] draws: ` · `, or two
177 /// spaces when ASCII-safe.
178 fn separator_width(ascii: bool) -> usize {
179 if ascii { 2 } else { 3 }
180 }
181
182 /// ` · ` between hints; two spaces when ASCII-safe, because the ASCII
183 /// form of `·` is `.` and would read as punctuation.
184 fn separator(theme: &Theme) -> Span<'static> {
185 if theme.ascii() {
186 Span::raw(" ")
187 } else {
188 Span::styled(" · ", theme.fg(Role::Border))
189 }
190 }
191
192 /// Lay the hints out in rows no wider than `width`. Packs greedily and
193 /// starts a new row rather than truncating; a hint wider than `width`
194 /// wraps between styled graphemes, preserving all its actions.
195 #[must_use]
196 pub fn lines(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
197 let width = usize::from(width);
198 if self.items.is_empty() || width == 0 {
199 return Vec::new();
200 }
201 let sep = Self::separator(theme);
202 let sep_width = text::width(&sep.content);
203 let mut lines = Vec::new();
204 let mut current: Vec<Span<'static>> = Vec::new();
205 let mut used = 0usize;
206 for hint in &self.items {
207 let w = hint.width();
208 if w > width {
209 if !current.is_empty() {
210 lines.push(Line::from(std::mem::take(&mut current)));
211 }
212 lines.extend(wrap_spans(&hint.spans(theme), width as u16));
213 used = 0;
214 continue;
215 }
216 if !current.is_empty() && used + sep_width + w > width {
217 lines.push(Line::from(std::mem::take(&mut current)));
218 used = 0;
219 }
220 if !current.is_empty() {
221 current.push(sep.clone());
222 used += sep_width;
223 }
224 current.extend(hint.spans(theme));
225 used += w;
226 }
227 if !current.is_empty() {
228 lines.push(Line::from(current));
229 }
230 lines
231 }
232 }
233
234 /// Hard-wrap already sanitized spans while retaining their styles. A wide
235 /// grapheme in a one-cell viewport becomes `?`: it cannot occupy half a cell.
236 pub(crate) fn wrap_spans(spans: &[Span<'static>], width: u16) -> Vec<Line<'static>> {
237 let width = usize::from(width);
238 if width == 0 {
239 return Vec::new();
240 }
241 let mut lines = Vec::new();
242 let mut current: Vec<Span<'static>> = Vec::new();
243 let mut used = 0;
244 for span in spans {
245 for grapheme in span.content.graphemes(true) {
246 let cells = text::width(grapheme);
247 let (grapheme, cells) = if cells > width {
248 ("?", 1)
249 } else {
250 (grapheme, cells)
251 };
252 if used + cells > width && !current.is_empty() {
253 lines.push(Line::from(std::mem::take(&mut current)));
254 used = 0;
255 }
256 if let Some(last) = current.last_mut()
257 && last.style == span.style
258 {
259 last.content.to_mut().push_str(grapheme);
260 } else {
261 current.push(Span::styled(grapheme.to_string(), span.style));
262 }
263 used += cells;
264 }
265 }
266 if !current.is_empty() {
267 lines.push(Line::from(current));
268 }
269 lines
270 }
271
272 /// Whether one more hint of `extra` cells fits after `items` in `width`.
273 fn fits_after(items: &[KeyHint], sep: usize, width: usize, extra: usize) -> bool {
274 let used: usize =
275 items.iter().map(KeyHint::width).sum::<usize>() + sep * items.len().saturating_sub(1);
276 used + sep + extra <= width
277 }
278
279 impl Paint for KeyHints {
280 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
281 let area = area.intersection(buf.area);
282 Paragraph::new(self.lines(area.width, theme)).render(area, buf);
283 }
284
285 fn height(&self, width: u16, theme: &Theme) -> u16 {
286 u16::try_from(self.lines(width, theme).len()).unwrap_or(u16::MAX)
287 }
288 }
289
290 #[cfg(test)]
291 mod tests {
292 use super::*;
293 use crate::testing::Profile;
294
295 fn hints() -> KeyHints {
296 KeyHints::new(vec![
297 KeyHint::new("↑↓", "move"),
298 KeyHint::new("Enter", "select"),
299 KeyHint::new("Esc", "cancel"),
300 ])
301 }
302
303 fn plain(lines: &[Line<'_>]) -> Vec<String> {
304 lines.iter().map(|l| l.to_string()).collect()
305 }
306
307 #[test]
308 fn packs_on_one_row_when_it_fits() {
309 let theme = Profile::DarkTrue.theme();
310 assert_eq!(
311 plain(&hints().lines(80, &theme)),
312 ["↑↓ move · Enter select · Esc cancel"]
313 );
314 }
315
316 #[test]
317 fn chord_hints_use_the_key_label_authority() {
318 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
319 let mac = crate::keys::Platform {
320 macos: true,
321 ascii: false,
322 };
323 let alt_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT);
324 assert_eq!(KeyHint::chord(&alt_v, mac, "details").keys, "⌥V");
325 let ctrl_o = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL);
326 assert_eq!(KeyHint::chord(&ctrl_o, mac, "reasoning").keys, "Ctrl+O");
327 }
328
329 #[test]
330 fn wraps_and_never_drops_an_action() {
331 let theme = Profile::DarkTrue.theme();
332 let rows = plain(&hints().lines(22, &theme));
333 assert_eq!(rows, ["↑↓ move · Enter select", "Esc cancel"]);
334 let rows = plain(&hints().lines(4, &theme));
335 assert_eq!(
336 rows.concat(),
337 "↑↓ moveEnter selectEsc cancel",
338 "every action survives even when an individual hint must wrap"
339 );
340 assert!(rows.iter().all(|row| text::width(row) <= 4));
341 }
342 }
343
343 lines RUST