Skip to main content

max / alloy_tui

Answer the question mark A keyboard-driven TUI has no menu bar, so what a pane can do is knowable only by having been told. The footer carries a handful of hints and nothing carried the rest, while `?` was classified, documented in the reserved keymap, and wired to nothing. AlloyKeymap is the rest. Two rules it exists to enforce: Dimmed, never hidden. An unavailable binding keeps its place and its spelling, greyed, with the reason beside it. A binding that disappears when it does not apply takes its own existence with it, so the user never learns the pane has that power at all, and the rows they can use move under them every time the selection changes. net.rs is the live case: its connect and wifi keys are pushed only when they apply. The reserved keys are always shown. A view supplies its own bindings and the EVERYWHERE block is appended here, so the same block appears in every pane of every Alloy TUI, in the same order, whether or not the view remembered it. The keymap itself moves into keys.rs as RESERVED, beside classify(), because a help screen that can disagree with the classifier is worse than no help screen: it teaches keys that do nothing. Two tests hold them together, so a rebind that skips the table fails the build. Truncation is announced rather than silent. A help screen is the last place a quiet cut is acceptable, since the reader is there precisely because they cannot find something.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 01:31 UTC
Signed with PGP, not checked
Commit: b22ae16f7c11d98e1a7bf2e5ff3ec499665ea654
Parent: caf8df3
3 files changed, +524 insertions, -0 deletions
M src/keys.rs +136
@@ -35,6 +35,84 @@
35 35 Passthrough,
36 36 }
37 37
38 + /// One reserved key as a user reads it.
39 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
40 + pub struct ReservedKey {
41 + /// The keystroke, spelled the way it is printed to the screen.
42 + pub key: &'static str,
43 + /// What it does, in the imperative and short enough for a footer.
44 + pub label: &'static str,
45 + /// What [`classify`] turns it into.
46 + pub action: Action,
47 + }
48 +
49 + /// The reserved keymap as a table, for anything that has to *show* it.
50 + ///
51 + /// It lives beside [`classify`] rather than in the help widget because a help
52 + /// screen that can disagree with the classifier is worse than no help screen: it
53 + /// teaches a key that does nothing. `reserved_table_matches_the_classifier`
54 + /// holds the two together, so a key cannot be rebound without this list moving
55 + /// with it.
56 + ///
57 + /// Ordered as a reader learns them: move, act, then leave.
58 + pub const RESERVED: &[ReservedKey] = &[
59 + ReservedKey {
60 + key: "Tab",
61 + label: "next pane",
62 + action: Action::NextFocus,
63 + },
64 + ReservedKey {
65 + key: "Shift-Tab",
66 + label: "previous pane",
67 + action: Action::PrevFocus,
68 + },
69 + ReservedKey {
70 + key: "l",
71 + label: "next tab",
72 + action: Action::NextTab,
73 + },
74 + ReservedKey {
75 + key: "h",
76 + label: "previous tab",
77 + action: Action::PrevTab,
78 + },
79 + ReservedKey {
80 + key: "Enter",
81 + label: "activate",
82 + action: Action::Activate,
83 + },
84 + ReservedKey {
85 + key: "Esc",
86 + label: "cancel",
87 + action: Action::Cancel,
88 + },
89 + ReservedKey {
90 + key: "Ctrl-S",
91 + label: "save",
92 + action: Action::Save,
93 + },
94 + ReservedKey {
95 + key: "/",
96 + label: "filter",
97 + action: Action::Filter,
98 + },
99 + ReservedKey {
100 + key: ":",
101 + label: "command",
102 + action: Action::Command,
103 + },
104 + ReservedKey {
105 + key: "?",
106 + label: "keys",
107 + action: Action::Help,
108 + },
109 + ReservedKey {
110 + key: "q",
111 + label: "quit",
112 + action: Action::Quit,
113 + },
114 + ];
115 +
38 116 /// Classify a key event against the reserved keymap.
39 117 ///
40 118 /// This is a pure classifier with no notion of mode: it reports what a key
@@ -83,6 +161,64 @@
83 161 KeyEvent::new(code, mods)
84 162 }
85 163
164 + /// The keystroke a [`ReservedKey`] prints, read back as an event.
165 + fn parse(spelled: &str) -> KeyEvent {
166 + match spelled {
167 + "Tab" => key(KeyCode::Tab, KeyModifiers::NONE),
168 + "Shift-Tab" => key(KeyCode::BackTab, KeyModifiers::NONE),
169 + "Enter" => key(KeyCode::Enter, KeyModifiers::NONE),
170 + "Esc" => key(KeyCode::Esc, KeyModifiers::NONE),
171 + "Ctrl-S" => key(KeyCode::Char('s'), KeyModifiers::CONTROL),
172 + other => {
173 + let mut chars = other.chars();
174 + let c = chars.next().expect("a key spelling is not empty");
175 + assert!(chars.next().is_none(), "unhandled spelling: {other}");
176 + key(KeyCode::Char(c), KeyModifiers::NONE)
177 + }
178 + }
179 + }
180 +
181 + // The help screen reads RESERVED and the app reads classify(). If they can
182 + // drift, the help teaches keys that do nothing, which is worse than showing
183 + // no help at all.
184 + #[test]
185 + fn reserved_table_matches_the_classifier() {
186 + for entry in RESERVED {
187 + assert_eq!(
188 + classify(parse(entry.key)),
189 + entry.action,
190 + "{} is documented as {:?}",
191 + entry.key,
192 + entry.action
193 + );
194 + }
195 + }
196 +
197 + // Every action a user can reach by pressing something is in the table.
198 + // Passthrough is the exception by definition: it is the absence of a
199 + // reserved meaning.
200 + #[test]
201 + fn every_reserved_action_is_documented() {
202 + for action in [
203 + Action::NextFocus,
204 + Action::PrevFocus,
205 + Action::NextTab,
206 + Action::PrevTab,
207 + Action::Activate,
208 + Action::Cancel,
209 + Action::Save,
210 + Action::Help,
211 + Action::Quit,
212 + Action::Filter,
213 + Action::Command,
214 + ] {
215 + assert!(
216 + RESERVED.iter().any(|r| r.action == action),
217 + "{action:?} is reachable but absent from RESERVED"
218 + );
219 + }
220 + }
221 +
86 222 #[test]
87 223 fn ctrl_s_saves_but_bare_s_does_not() {
88 224 assert_eq!(
M src/lib.rs +2
@@ -20,6 +20,7 @@
20 20 pub mod connector;
21 21 pub mod cursor;
22 22 pub mod focus;
23 + pub mod help;
23 24 pub mod input;
24 25 pub mod keys;
25 26 pub mod layout;
@@ -32,6 +33,7 @@
32 33 pub use connector::AlloyConnector;
33 34 pub use cursor::Cursor;
34 35 pub use focus::FocusRing;
36 + pub use help::{AlloyKeymap, Binding, KeyGroup, binding, unavailable};
35 37 pub use input::TextField;
36 38 pub use keys::{Action, classify};
37 39 pub use layout::{ConsoleAreas, PaneAreas, console, panes};
A src/help.rs +386
@@ -1,0 +1,386 @@
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 + }