Skip to main content

max / alloy_tui

12.6 KB · 366 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.makeover.surface_overlay)
157 .fg(self.theme.makeover.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.makeover.action_primary),
201 base.fg(self.theme.makeover.content_primary),
202 )
203 } else {
204 (
205 base.fg(self.theme.makeover.content_muted),
206 base.fg(self.theme.makeover.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.makeover.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.makeover.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
247 fn theme() -> Theme {
248 crate::theme::test_theme(crate::theme::Mode::Dark)
249 }
250
251 fn pane_group() -> Vec<KeyGroup<'static>> {
252 vec![KeyGroup::new(
253 "this pane",
254 vec![
255 binding("j/k", "select"),
256 unavailable("w", "wifi radio", "no wifi device"),
257 ],
258 )]
259 }
260
261 fn render(w: u16, h: u16, keymap: AlloyKeymap) -> Vec<String> {
262 let area = Rect::new(0, 0, w, h);
263 let mut buf = Buffer::empty(area);
264 keymap.render(area, &mut buf);
265 (0..h)
266 .map(|y| {
267 (0..w)
268 .map(|x| buf[(x, y)].symbol())
269 .collect::<String>()
270 .trim_end()
271 .to_string()
272 })
273 .collect()
274 }
275
276 #[test]
277 fn it_lists_the_panes_keys_then_the_reserved_ones() {
278 let theme = theme();
279 let rendered = render(46, 22, AlloyKeymap::new(&theme, "network", pane_group())).join("\n");
280 assert!(rendered.contains("THIS PANE"), "{rendered}");
281 assert!(rendered.contains("j/k"), "{rendered}");
282 assert!(rendered.contains("EVERYWHERE"), "{rendered}");
283 // The reserved block is appended without the caller supplying it.
284 assert!(rendered.contains("Shift-Tab"), "{rendered}");
285 assert!(rendered.contains("Ctrl-S"), "{rendered}");
286 // And the overlay says where you are.
287 assert!(rendered.contains("keys: network"), "{rendered}");
288 }
289
290 // The whole point. An unavailable binding is still on the screen, in its
291 // place, spelled the same, with a reason.
292 #[test]
293 fn an_unavailable_binding_is_dimmed_and_explained_not_removed() {
294 let theme = theme();
295 let area = Rect::new(0, 0, 46, 22);
296 let mut buf = Buffer::empty(area);
297 AlloyKeymap::new(&theme, "network", pane_group()).render(area, &mut buf);
298
299 let rendered: Vec<String> = (0..area.height)
300 .map(|y| {
301 (0..area.width)
302 .map(|x| buf[(x, y)].symbol())
303 .collect::<String>()
304 })
305 .collect();
306 let row = rendered
307 .iter()
308 .position(|r| r.contains("wifi radio"))
309 .expect("the unavailable binding is still listed");
310 assert!(
311 rendered[row].contains("(no wifi device)"),
312 "{:?}",
313 rendered[row]
314 );
315
316 // Dimmed, and the available one above it is not.
317 let x = rendered[row].find('w').expect("the key is drawn") as u16;
318 assert_eq!(buf[(x, row as u16)].fg, theme.makeover.content_muted);
319 let live = rendered
320 .iter()
321 .position(|r| r.contains("select"))
322 .expect("the available binding is listed");
323 let lx = rendered[live].find('j').expect("the key is drawn") as u16;
324 assert_eq!(buf[(lx, live as u16)].fg, theme.makeover.action_primary);
325 }
326
327 // A view without tabs says so rather than advertising two keys that do
328 // nothing in it.
329 #[test]
330 fn reserved_keys_the_view_does_not_answer_are_dimmed_too() {
331 let theme = theme();
332 let unavailable = [Action::NextTab, Action::PrevTab];
333 let rendered = render(
334 46,
335 22,
336 AlloyKeymap::new(&theme, "network", pane_group()).unavailable(&unavailable),
337 );
338 let row = rendered
339 .iter()
340 .find(|r| r.contains("next tab"))
341 .expect("the tab key is still listed");
342 assert!(row.contains("(not here)"), "{row}");
343 }
344
345 // Never a silent cut, least of all here.
346 #[test]
347 fn an_overlay_too_short_for_its_content_says_how_much_is_missing() {
348 let theme = theme();
349 let rendered = render(46, 8, AlloyKeymap::new(&theme, "network", pane_group()));
350 // The last row of the area is the block's bottom edge; the marker sits
351 // on the last row inside it.
352 let marker = rendered
353 .iter()
354 .rev()
355 .find(|r| r.contains("more, resize to see"));
356 assert!(marker.is_some(), "{rendered:#?}");
357
358 // And it is the final line of content, not something floating mid-list.
359 let marker_row = rendered
360 .iter()
361 .position(|r| r.contains("more, resize to see"))
362 .expect("just asserted present");
363 assert_eq!(marker_row, rendered.len() - 2, "{rendered:#?}");
364 }
365 }
366