Skip to main content

max / alloy_tui

13.4 KB · 387 lines History Blame Raw
1 //! The keymap overlay: everything the focused pane can do, on one screen.
2 //!
3 //! A keyboard-driven TUI has no menu bar, so what a pane can do is knowable only
4 //! by having been told. The footer carries a handful of hints and nothing
5 //! carries the rest. This is the rest.
6 //!
7 //! Two rules it exists to enforce, both from the classic Mac tradition of
8 //! keeping the whole command surface visible:
9 //!
10 //! **Dimmed, never hidden.** An unavailable binding keeps its place and its
11 //! spelling, greyed, with the reason beside it. A binding that disappears when
12 //! it does not apply takes its own existence with it, so the user never learns
13 //! the pane has that power at all, and the rows they *can* use move under them
14 //! every time the selection changes.
15 //!
16 //! **The reserved keys are always shown.** A view supplies only its own
17 //! bindings; [`keys::RESERVED`] is appended here so the same block appears in
18 //! every pane of every Alloy TUI, in the same order, whether or not the view
19 //! remembered it.
20 //!
21 //! Pure render, like everything else in this crate: the shell owns the flag that
22 //! says the overlay is open, and rebuilds this each frame.
23 //!
24 //! <!-- wiki: alloy-console -->
25
26 use ratatui::buffer::Buffer;
27 use ratatui::layout::Rect;
28 use ratatui::style::{Modifier, Style};
29 use ratatui::text::{Line, Span};
30 use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
31
32 use crate::keys::{self, Action};
33 use crate::theme::Theme;
34 use crate::widgets::floating_shadow;
35
36 /// One key and what it does here.
37 pub struct Binding<'a> {
38 pub key: &'a str,
39 pub label: &'a str,
40 /// Whether pressing it right now does anything.
41 pub enabled: bool,
42 /// Why not, when it does not. Shown beside a disabled binding, because
43 /// "greyed out and unexplained" is its own small mystery.
44 pub reason: Option<&'a str>,
45 }
46
47 /// An available binding.
48 pub const fn binding<'a>(key: &'a str, label: &'a str) -> Binding<'a> {
49 Binding {
50 key,
51 label,
52 enabled: true,
53 reason: None,
54 }
55 }
56
57 /// A binding that is real but not available right now.
58 pub const fn unavailable<'a>(key: &'a str, label: &'a str, reason: &'a str) -> Binding<'a> {
59 Binding {
60 key,
61 label,
62 enabled: false,
63 reason: Some(reason),
64 }
65 }
66
67 /// A titled run of bindings.
68 pub struct KeyGroup<'a> {
69 pub title: &'a str,
70 pub bindings: Vec<Binding<'a>>,
71 }
72
73 impl<'a> KeyGroup<'a> {
74 pub fn new(title: &'a str, bindings: Vec<Binding<'a>>) -> Self {
75 Self { title, bindings }
76 }
77 }
78
79 /// The overlay itself.
80 pub struct AlloyKeymap<'a> {
81 theme: &'a Theme,
82 title: &'a str,
83 groups: Vec<KeyGroup<'a>>,
84 unavailable: &'a [Action],
85 }
86
87 impl<'a> AlloyKeymap<'a> {
88 /// `title` names the pane whose keys these are, so an overlay opened by
89 /// accident says where the user is as well as what they can press.
90 pub fn new(theme: &'a Theme, title: &'a str, groups: Vec<KeyGroup<'a>>) -> Self {
91 Self {
92 theme,
93 title,
94 groups,
95 unavailable: &[],
96 }
97 }
98
99 /// Reserved actions this view does not answer, so they render dimmed rather
100 /// than promising something that will not happen. A view with no tabs passes
101 /// the tab movers here.
102 #[must_use]
103 pub fn unavailable(mut self, actions: &'a [Action]) -> Self {
104 self.unavailable = actions;
105 self
106 }
107
108 /// The view's groups, then the reserved block.
109 fn all_groups(&self) -> Vec<KeyGroup<'_>> {
110 let mut groups: Vec<KeyGroup<'_>> = self
111 .groups
112 .iter()
113 .map(|g| KeyGroup {
114 title: g.title,
115 bindings: g
116 .bindings
117 .iter()
118 .map(|b| Binding {
119 key: b.key,
120 label: b.label,
121 enabled: b.enabled,
122 reason: b.reason,
123 })
124 .collect(),
125 })
126 .collect();
127
128 groups.push(KeyGroup {
129 title: "EVERYWHERE",
130 bindings: keys::RESERVED
131 .iter()
132 .map(|r| Binding {
133 key: r.key,
134 label: r.label,
135 enabled: !self.unavailable.contains(&r.action),
136 reason: if self.unavailable.contains(&r.action) {
137 Some("not here")
138 } else {
139 None
140 },
141 })
142 .collect(),
143 });
144
145 groups
146 }
147 }
148
149 impl Widget for AlloyKeymap<'_> {
150 fn render(self, area: Rect, buf: &mut Buffer) {
151 if area.height == 0 || area.width == 0 {
152 return;
153 }
154
155 let base = Style::default()
156 .bg(self.theme.surface_overlay)
157 .fg(self.theme.content_primary);
158
159 // The overlay covers what is behind it rather than blending with it: a
160 // half-legible keymap over live content is harder to read than either.
161 Clear.render(area, buf);
162
163 let block = Block::default()
164 .borders(Borders::ALL)
165 .border_style(Style::default().fg(self.theme.border_strong))
166 .style(base)
167 .shadow(floating_shadow(self.theme))
168 .title(format!(" keys: {} ", self.title));
169 let inner = block.inner(area);
170 block.render(area, buf);
171 if inner.height == 0 || inner.width == 0 {
172 return;
173 }
174
175 let groups = self.all_groups();
176 // One key column for the whole overlay, not one per group, so the labels
177 // line up top to bottom and the eye has a single edge to run down.
178 let key_width = groups
179 .iter()
180 .flat_map(|g| g.bindings.iter())
181 .map(|b| b.key.chars().count())
182 .max()
183 .unwrap_or(0);
184
185 let mut lines: Vec<Line> = Vec::new();
186 for (i, group) in groups.iter().enumerate() {
187 if i > 0 {
188 lines.push(Line::default());
189 }
190 // Uppercase and bold: the two levers a terminal has for hierarchy,
191 // per docs/DESIGN-LANGUAGE.md. No color, because a section header is
192 // chrome and color here is information.
193 lines.push(Line::from(Span::styled(
194 group.title.to_uppercase(),
195 base.add_modifier(Modifier::BOLD),
196 )));
197 for b in &group.bindings {
198 let (key_style, label_style) = if b.enabled {
199 (
200 base.fg(self.theme.action_primary),
201 base.fg(self.theme.content_primary),
202 )
203 } else {
204 (
205 base.fg(self.theme.content_muted),
206 base.fg(self.theme.content_muted),
207 )
208 };
209 let mut spans = vec![
210 Span::styled(" ", base),
211 Span::styled(format!("{:key_width$}", b.key), key_style),
212 Span::styled(" ", base),
213 Span::styled(b.label, label_style),
214 ];
215 if let Some(reason) = b.reason.filter(|_| !b.enabled) {
216 spans.push(Span::styled(
217 format!(" ({reason})"),
218 base.fg(self.theme.content_muted),
219 ));
220 }
221 lines.push(Line::from(spans));
222 }
223 }
224
225 // Truncation is announced. A help screen that quietly stops short is
226 // the one place a silent cut is least forgivable: the reader is here
227 // precisely because they are looking for something they cannot find.
228 let height = inner.height as usize;
229 if lines.len() > height {
230 let shown = height.saturating_sub(1);
231 let hidden = lines.len() - shown;
232 lines.truncate(shown);
233 lines.push(Line::from(Span::styled(
234 format!(" ... {hidden} more, resize to see"),
235 base.fg(self.theme.content_muted),
236 )));
237 }
238
239 Paragraph::new(lines).style(base).render(inner, buf);
240 }
241 }
242
243 #[cfg(test)]
244 mod tests {
245 use super::*;
246 use ratatui::style::Color;
247
248 fn theme() -> Theme {
249 Theme {
250 mode: crate::theme::Mode::Dark,
251 surface_page: Color::Rgb(0, 0, 0),
252 surface_raised: Color::Rgb(1, 1, 1),
253 surface_sunken: Color::Rgb(2, 2, 2),
254 surface_overlay: Color::Rgb(3, 3, 3),
255 content_primary: Color::Rgb(4, 4, 4),
256 content_secondary: Color::Rgb(5, 5, 5),
257 content_muted: Color::Rgb(6, 6, 6),
258 action_primary: Color::Rgb(7, 7, 7),
259 status_danger: Color::Rgb(8, 8, 8),
260 status_success: Color::Rgb(9, 9, 9),
261 status_warning: Color::Rgb(10, 10, 10),
262 status_info: Color::Rgb(11, 11, 11),
263 line_border: Color::Rgb(12, 12, 12),
264 border_subtle: Color::Rgb(13, 13, 13),
265 border_strong: Color::Rgb(14, 14, 14),
266 bevel_light: Color::Rgb(16, 16, 16),
267 bevel_dark: Color::Rgb(17, 17, 17),
268 category: [Color::Rgb(15, 15, 15); 6],
269 }
270 }
271
272 fn pane_group() -> Vec<KeyGroup<'static>> {
273 vec![KeyGroup::new(
274 "this pane",
275 vec![
276 binding("j/k", "select"),
277 unavailable("w", "wifi radio", "no wifi device"),
278 ],
279 )]
280 }
281
282 fn render(w: u16, h: u16, keymap: AlloyKeymap) -> Vec<String> {
283 let area = Rect::new(0, 0, w, h);
284 let mut buf = Buffer::empty(area);
285 keymap.render(area, &mut buf);
286 (0..h)
287 .map(|y| {
288 (0..w)
289 .map(|x| buf[(x, y)].symbol())
290 .collect::<String>()
291 .trim_end()
292 .to_string()
293 })
294 .collect()
295 }
296
297 #[test]
298 fn it_lists_the_panes_keys_then_the_reserved_ones() {
299 let theme = theme();
300 let rendered = render(46, 22, AlloyKeymap::new(&theme, "network", pane_group())).join("\n");
301 assert!(rendered.contains("THIS PANE"), "{rendered}");
302 assert!(rendered.contains("j/k"), "{rendered}");
303 assert!(rendered.contains("EVERYWHERE"), "{rendered}");
304 // The reserved block is appended without the caller supplying it.
305 assert!(rendered.contains("Shift-Tab"), "{rendered}");
306 assert!(rendered.contains("Ctrl-S"), "{rendered}");
307 // And the overlay says where you are.
308 assert!(rendered.contains("keys: network"), "{rendered}");
309 }
310
311 // The whole point. An unavailable binding is still on the screen, in its
312 // place, spelled the same, with a reason.
313 #[test]
314 fn an_unavailable_binding_is_dimmed_and_explained_not_removed() {
315 let theme = theme();
316 let area = Rect::new(0, 0, 46, 22);
317 let mut buf = Buffer::empty(area);
318 AlloyKeymap::new(&theme, "network", pane_group()).render(area, &mut buf);
319
320 let rendered: Vec<String> = (0..area.height)
321 .map(|y| {
322 (0..area.width)
323 .map(|x| buf[(x, y)].symbol())
324 .collect::<String>()
325 })
326 .collect();
327 let row = rendered
328 .iter()
329 .position(|r| r.contains("wifi radio"))
330 .expect("the unavailable binding is still listed");
331 assert!(
332 rendered[row].contains("(no wifi device)"),
333 "{:?}",
334 rendered[row]
335 );
336
337 // Dimmed, and the available one above it is not.
338 let x = rendered[row].find('w').expect("the key is drawn") as u16;
339 assert_eq!(buf[(x, row as u16)].fg, theme.content_muted);
340 let live = rendered
341 .iter()
342 .position(|r| r.contains("select"))
343 .expect("the available binding is listed");
344 let lx = rendered[live].find('j').expect("the key is drawn") as u16;
345 assert_eq!(buf[(lx, live as u16)].fg, theme.action_primary);
346 }
347
348 // A view without tabs says so rather than advertising two keys that do
349 // nothing in it.
350 #[test]
351 fn reserved_keys_the_view_does_not_answer_are_dimmed_too() {
352 let theme = theme();
353 let unavailable = [Action::NextTab, Action::PrevTab];
354 let rendered = render(
355 46,
356 22,
357 AlloyKeymap::new(&theme, "network", pane_group()).unavailable(&unavailable),
358 );
359 let row = rendered
360 .iter()
361 .find(|r| r.contains("next tab"))
362 .expect("the tab key is still listed");
363 assert!(row.contains("(not here)"), "{row}");
364 }
365
366 // Never a silent cut, least of all here.
367 #[test]
368 fn an_overlay_too_short_for_its_content_says_how_much_is_missing() {
369 let theme = theme();
370 let rendered = render(46, 8, AlloyKeymap::new(&theme, "network", pane_group()));
371 // The last row of the area is the block's bottom edge; the marker sits
372 // on the last row inside it.
373 let marker = rendered
374 .iter()
375 .rev()
376 .find(|r| r.contains("more, resize to see"));
377 assert!(marker.is_some(), "{rendered:#?}");
378
379 // And it is the final line of content, not something floating mid-list.
380 let marker_row = rendered
381 .iter()
382 .position(|r| r.contains("more, resize to see"))
383 .expect("just asserted present");
384 assert_eq!(marker_row, rendered.len() - 2, "{rendered:#?}");
385 }
386 }
387