Skip to main content

max / alloy_tui

console: add tabs, a modal slot, and terminal suspend Shell and design-system groundwork for `alloy pkg`, landed ahead of the view itself because two of these get more expensive to retrofit once `alloy config` has form state to lose. Design: wiki [[alloy-package-ux]]. keys: add NextTab/PrevTab on l/h. Tab already means focus everywhere and that meaning is documented across every view, so two navigation axes need two keys. Widens the text-entry obligation on classify: h and l are ordinary letters, so a view forwarding raw keys to an input now changes tabs mid-word rather than only on a stray q. shell: one modal slot, deliberately not a stack. Cancel and Quit both returned from the event loop, so there was no way to ask a question the user could decline without also closing the console, which blocked every destructive action. Views opt in through Flow::Confirm and get confirmed/cancelled hooks that default to nothing. The modal covers the body only, leaving the command log readable underneath, and q cancels rather than quitting since reaching for it mid-prompt means get me out of this. Routing lives in a pure modal_key function so the rule is testable without a live terminal. shell: Flow::Suspend hands the terminal to an interactive child and takes it back. distrobox enter wants a real TTY; run under a live ratatui it would draw into the alternate screen and fight the event loop for input. The terminal is rebuilt whether or not the child succeeded, since a nonzero exit is ordinary and must not strand the user in a torn-down TUI. alloy_tui: AlloyTabs and AlloyModal, plus layout::centered. Tabs carry no state, driven by the caller's FocusRing, matching the AlloyList/Cursor split. Selection reads as brackets plus weight rather than color, which keeps color off chrome and survives a console with no theme and no patched font. Unselected labels pad to bracket width so the bar does not shift as selection moves. The modal pins its keys to the last row so a long message cannot push them out of reach. Flow's data-carrying variants have no production consumer until alloy pkg, which is blocked on Fedora hardware; they are covered by tests and marked with an allow that names the blocker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 19:01 UTC
Commit: 51296984b2bb35155c4f1328cfce669666e6d35f
Parent: 04c222b
3 files changed, +317 insertions, -5 deletions
M src/keys.rs +44 -5
@@ -3,8 +3,14 @@
3 3 //!
4 4 //! Per docs/COMPONENT-LIBRARY.md the reserved keys live in exactly one place so
5 5 //! apps match against `Action` rather than hardcoding keycodes: `Tab` /
6 - //! `Shift-Tab` move focus, `Enter` activates, `Esc` cancels, `Ctrl-S` saves,
7 - //! `q` quits, `?` opens help, `/` filters, `:` opens the command line.
6 + //! `Shift-Tab` move focus, `l` / `h` move between tabs, `Enter` activates,
7 + //! `Esc` cancels, `Ctrl-S` saves, `q` quits, `?` opens help, `/` filters, `:`
8 + //! opens the command line.
9 + //!
10 + //! Tabs get `l` / `h` rather than `Tab` because `Tab` already means focus and
11 + //! that meaning is documented across every view. Two navigation axes need two
12 + //! keys, and the vim pair reads as horizontal movement, which is what a tab bar
13 + //! is.
8 14 //!
9 15 //! Descended from sysop-tui's `keys.rs`, widened from that crate's six actions
10 16 //! to the full reserved set the console's form surfaces need.
@@ -17,6 +23,8 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
17 23 pub enum Action {
18 24 NextFocus,
19 25 PrevFocus,
26 + NextTab,
27 + PrevTab,
20 28 Activate,
21 29 Cancel,
22 30 Save,
@@ -37,9 +45,15 @@ pub enum Action {
37 45 /// press and release for every key, so an unfiltered event loop performs
38 46 /// each action twice.
39 47 /// - **Ignore the character actions while text entry has focus.** `q`, `/`,
40 - /// and `:` are literal characters a user types into a field; a view holding
41 - /// an active text input should route keys to the input and consult this
42 - /// classifier only for `Cancel`, `Save`, and the focus movers.
48 + /// `:`, `h`, and `l` are literal characters a user types into a field; a view
49 + /// holding an active text input should route keys to the input and consult
50 + /// this classifier only for `Cancel`, `Save`, and the focus movers.
51 + ///
52 + /// `h` and `l` make this obligation sharper than it was. The earlier
53 + /// character actions were punctuation and one letter that rarely opens a
54 + /// word; `h` and `l` are ordinary letters that appear in almost any typed
55 + /// value, so a view that forwards raw keys to an input without this check
56 + /// now changes tabs mid-word rather than merely on a stray `q`.
43 57 pub fn classify(key: KeyEvent) -> Action {
44 58 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
45 59 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
@@ -49,6 +63,8 @@ pub fn classify(key: KeyEvent) -> Action {
49 63 KeyCode::BackTab => Action::PrevFocus,
50 64 KeyCode::Tab if shift => Action::PrevFocus,
51 65 KeyCode::Tab => Action::NextFocus,
66 + KeyCode::Char('l') => Action::NextTab,
67 + KeyCode::Char('h') => Action::PrevTab,
52 68 KeyCode::Enter => Action::Activate,
53 69 KeyCode::Esc => Action::Cancel,
54 70 KeyCode::Char('?') => Action::Help,
@@ -90,4 +106,27 @@ mod tests {
90 106 assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough);
91 107 assert_eq!(classify(key(KeyCode::Down, KeyModifiers::NONE)), Action::Passthrough);
92 108 }
109 +
110 + #[test]
111 + fn h_and_l_move_between_tabs() {
112 + assert_eq!(classify(key(KeyCode::Char('l'), KeyModifiers::NONE)), Action::NextTab);
113 + assert_eq!(classify(key(KeyCode::Char('h'), KeyModifiers::NONE)), Action::PrevTab);
114 + }
115 +
116 + // Tabs and focus are two navigation axes and must stay on separate keys.
117 + // docs/COMPONENT-LIBRARY.md documents Tab as focus movement across every
118 + // view, so a tab bar claiming Tab would silently redefine it everywhere.
119 + #[test]
120 + fn tab_key_still_means_focus_not_tabs() {
121 + assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus);
122 + assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus);
123 + }
124 +
125 + // j/k stay unreserved so a list cursor keeps them. Only the horizontal half
126 + // of the vim pair is spoken for.
127 + #[test]
128 + fn vertical_vim_keys_are_not_claimed_by_tabs() {
129 + assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough);
130 + assert_eq!(classify(key(KeyCode::Char('k'), KeyModifiers::NONE)), Action::Passthrough);
131 + }
93 132 }
@@ -61,6 +61,23 @@ pub fn console_with_log_height(area: Rect, log_height: u16) -> ConsoleAreas {
61 61 /// one to enter the right pane.
62 62 pub const GUTTER_WIDTH: u16 = 3;
63 63
64 + /// Center a `width` x `height` box inside `area`, for a modal drawn over a
65 + /// view.
66 + ///
67 + /// Clamps rather than overflowing: a box larger than the area it sits in
68 + /// becomes the area. A modal that renders partly offscreen is worse than a
69 + /// cramped one, because the keys that dismiss it are listed on its last row.
70 + pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
71 + let width = width.min(area.width);
72 + let height = height.min(area.height);
73 + Rect {
74 + x: area.x + (area.width - width) / 2,
75 + y: area.y + (area.height - height) / 2,
76 + width,
77 + height,
78 + }
79 + }
80 +
64 81 /// Two panes with a connector gutter between them.
65 82 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
66 83 pub struct PaneAreas {
@@ -283,6 +283,167 @@ impl Widget for AlloyList<'_> {
283 283 }
284 284 }
285 285
286 + /// A one-row tab bar.
287 + ///
288 + /// Holds no state: the selected index comes from the caller's
289 + /// [`FocusRing`](crate::FocusRing), which is already a wrapping cursor over N
290 + /// slots with the `focus(slot)` a verb needs to open the view on a given tab.
291 + /// Same split as [`AlloyList`] and [`Cursor`](crate::Cursor) — widget shared,
292 + /// state owned by the view.
293 + ///
294 + /// Selection reads as brackets plus weight rather than color. Per
295 + /// DESIGN-LANGUAGE.md color stays off chrome, and per the same reasoning as
296 + /// [`MARKER`](crate::MARKER) being a plain triangle, a bracket survives a
297 + /// console with no theme and no patched font — the TTY before the session
298 + /// starts, `alloy` over SSH.
299 + pub struct AlloyTabs<'a> {
300 + theme: &'a Theme,
301 + labels: Vec<String>,
302 + selected: usize,
303 + }
304 +
305 + impl<'a> AlloyTabs<'a> {
306 + pub fn new(theme: &'a Theme, labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
307 + Self {
308 + theme,
309 + labels: labels.into_iter().map(Into::into).collect(),
310 + selected: 0,
311 + }
312 + }
313 +
314 + /// Select a tab. Out-of-range indices select nothing, matching
315 + /// [`FocusRing::focus`](crate::FocusRing::focus): landing on a neighbouring
316 + /// tab is worse than showing none as current.
317 + pub fn selected(mut self, selected: usize) -> Self {
318 + self.selected = selected;
319 + self
320 + }
321 + }
322 +
323 + /// Gap between tabs. Wide enough that two short labels do not read as one.
324 + const TAB_GAP: &str = " ";
325 +
326 + impl Widget for AlloyTabs<'_> {
327 + fn render(self, area: Rect, buf: &mut Buffer) {
328 + if area.height == 0 || area.width == 0 {
329 + return;
330 + }
331 +
332 + let mut spans: Vec<Span> = Vec::with_capacity(self.labels.len() * 2);
333 + for (i, label) in self.labels.iter().enumerate() {
334 + if i > 0 {
335 + spans.push(Span::raw(TAB_GAP));
336 + }
337 + // Unselected labels carry spaces where the selected one carries
338 + // brackets, so a label occupies the same cells either way and the
339 + // bar does not shift horizontally as selection moves. Same reason
340 + // MARKER_BLANK exists for list rows.
341 + let (open, close, style) = if i == self.selected {
342 + ("[ ", " ]", selected_style(self.theme))
343 + } else {
344 + (" ", " ", unselected_style(self.theme))
345 + };
346 + spans.push(Span::styled(format!("{open}{label}{close}"), style));
347 + }
348 +
349 + let row = Rect { height: 1, ..area };
350 + Paragraph::new(Line::from(spans))
351 + .style(Style::default().bg(self.theme.surface_page))
352 + .render(row, buf);
353 + }
354 + }
355 +
356 + /// A centered confirmation modal, drawn over the view that raised it.
357 + ///
358 + /// Confirmation is design-system chrome rather than per-view furniture: every
359 + /// destructive action in every Alloy TUI asks the same way, with the same
360 + /// keys. That is the reason this lives here and the shell owns the state,
361 + /// instead of each view drawing its own prompt.
362 + ///
363 + /// Sits on `surface.overlay`, the one theme surface reserved for content
364 + /// floating above the page, and borrows `Severity` for the accent so a
365 + /// destructive confirm reads red and a benign one does not.
366 + pub struct AlloyModal<'a> {
367 + theme: &'a Theme,
368 + title: &'a str,
369 + message: &'a str,
370 + severity: Severity,
371 + }
372 +
373 + impl<'a> AlloyModal<'a> {
374 + pub fn new(theme: &'a Theme, title: &'a str, message: &'a str) -> Self {
375 + Self {
376 + theme,
377 + title,
378 + message,
379 + severity: Severity::Warn,
380 + }
381 + }
382 +
383 + pub fn severity(mut self, severity: Severity) -> Self {
384 + self.severity = severity;
385 + self
386 + }
387 + }
388 +
389 + impl Widget for AlloyModal<'_> {
390 + fn render(self, area: Rect, buf: &mut Buffer) {
391 + if area.height == 0 || area.width == 0 {
392 + return;
393 + }
394 +
395 + let base = Style::default()
396 + .bg(self.theme.surface_overlay)
397 + .fg(self.theme.content_primary);
398 +
399 + let block = Block::default()
400 + .borders(Borders::ALL)
401 + .border_style(Style::default().fg(self.theme.border_strong))
402 + .style(base)
403 + .title(format!(" {} ", self.title));
404 + let inner = block.inner(area);
405 + block.render(area, buf);
406 +
407 + if inner.height == 0 {
408 + return;
409 + }
410 +
411 + // Message on top, keys on the last row. The keys are pinned to the
412 + // bottom rather than following the message so their position does not
413 + // move with message length: a confirm the user cannot dismiss is the
414 + // one failure this widget must not have.
415 + let keys = Line::from(vec![
416 + text::action(self.theme, "Enter"),
417 + Span::styled(" confirm", Style::default().fg(self.theme.content_muted)),
418 + Span::raw(" "),
419 + text::action(self.theme, "Esc"),
420 + Span::styled(" cancel", Style::default().fg(self.theme.content_muted)),
421 + ]);
422 +
423 + let message_height = inner.height.saturating_sub(1);
424 + if message_height > 0 {
425 + Paragraph::new(Line::from(Span::styled(
426 + self.message,
427 + self.severity.style(self.theme).patch(base),
428 + )))
429 + .style(base)
430 + .wrap(ratatui::widgets::Wrap { trim: true })
431 + .render(Rect { height: message_height, ..inner }, buf);
432 + }
433 +
434 + Paragraph::new(keys)
435 + .style(base)
436 + .render(
437 + Rect {
438 + y: inner.y + inner.height - 1,
439 + height: 1,
440 + ..inner
441 + },
442 + buf,
443 + );
444 + }
445 + }
446 +
286 447 /// One line of the command log: the CLI invocation that was run, and how it
287 448 /// went.
288 449 ///
@@ -437,6 +598,101 @@ mod tests {
437 598 assert_eq!(list_row_y(area, 3, Some(0), 9), None, "past the end of the list");
438 599 }
439 600
601 + fn render_tabs(selected: usize, width: u16) -> String {
602 + let theme = theme();
603 + let mut buf = Buffer::empty(Rect::new(0, 0, width, 1));
604 + AlloyTabs::new(&theme, ["installed", "boxes", "system"])
605 + .selected(selected)
606 + .render(Rect::new(0, 0, width, 1), &mut buf);
607 + buf.content().iter().map(|cell| cell.symbol()).collect()
608 + }
609 +
610 + #[test]
611 + fn selected_tab_is_bracketed_and_others_are_not() {
612 + let rendered = render_tabs(0, 60);
613 + assert!(rendered.contains("[ installed ]"), "selected tab is bracketed");
614 + assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not");
615 + assert!(rendered.contains("boxes"), "unselected labels still render");
616 + }
617 +
618 + // The bar must not shift horizontally as selection moves, or every tab
619 + // change reads as the whole row twitching. Unselected labels pad to the
620 + // bracket width for exactly this reason.
621 + #[test]
622 + fn labels_hold_their_columns_across_selections() {
623 + let first = render_tabs(0, 60);
624 + let last = render_tabs(2, 60);
625 + assert_eq!(
626 + first.find("system"),
627 + last.find("system"),
628 + "a label sits in the same columns whichever tab is selected"
629 + );
630 + }
631 +
632 + // FocusRing::focus ignores out-of-range slots rather than clamping, and the
633 + // bar has to agree: showing a neighbouring tab as current would misreport
634 + // which screen the user is looking at.
635 + #[test]
636 + fn out_of_range_selection_brackets_nothing() {
637 + let rendered = render_tabs(9, 60);
638 + assert!(!rendered.contains('['), "no tab is marked current");
639 + assert!(rendered.contains("installed"), "labels still render");
640 + }
641 +
642 + #[test]
643 + fn zero_height_area_renders_nothing_rather_than_panicking() {
644 + let theme = theme();
645 + let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
646 + AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf);
647 + AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf);
648 + }
649 +
650 + fn render_modal(area: Rect) -> Vec<String> {
651 + let theme = theme();
652 + let mut buf = Buffer::empty(area);
653 + AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf);
654 + (0..area.height)
655 + .map(|y| {
656 + (0..area.width)
657 + .map(|x| buf[(x, y)].symbol())
658 + .collect::<String>()
659 + })
660 + .collect()
661 + }
662 +
663 + #[test]
664 + fn modal_shows_its_message_and_both_keys() {
665 + let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n");
666 + assert!(rows.contains("Remove tailscale?"), "message renders");
667 + assert!(rows.contains("remove"), "title renders");
668 + assert!(rows.contains("Enter"), "confirm key renders");
669 + assert!(rows.contains("Esc"), "cancel key renders");
670 + }
671 +
672 + // The keys are pinned to the last inner row rather than flowing after the
673 + // message. A prompt whose dismiss keys move with message length, or fall
674 + // off a short box, is a modal the user cannot get out of.
675 + #[test]
676 + fn keys_sit_on_the_last_row_whatever_the_message_length() {
677 + for height in [5, 7, 12] {
678 + let rows = render_modal(Rect::new(0, 0, 40, height));
679 + let last_inner = &rows[height as usize - 2];
680 + assert!(
681 + last_inner.contains("Enter") && last_inner.contains("Esc"),
682 + "height {height}: keys belong on the last inner row, got {last_inner:?}"
683 + );
684 + }
685 + }
686 +
687 + #[test]
688 + fn modal_survives_an_area_too_small_to_draw_in() {
689 + let theme = theme();
690 + let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7));
691 + AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf);
692 + AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf);
693 + AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf);
694 + }
695 +
440 696 // A log longer than its pane shows the newest entries. Showing the head
441 697 // instead would freeze the pane on startup noise and never display the
442 698 // command the user just triggered.