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
Signed with PGP, not checked
Commit: 79635c67542bc1e970b09adf966961b42e127a3d
Parent: 9ccfa54
4 files changed, +618 insertions, -14 deletions
@@ -6,10 +6,11 @@
6 6 //! than in each view, so a new subcommand supplies only its body and its
7 7 //! hints and inherits the rest.
8 8
9 + use std::process::Command;
9 10 use std::time::Duration;
10 11
11 12 use alloy_tui::keys::{Action, classify};
12 - use alloy_tui::{AlloyLog, AlloyStatusBar, Hint, Severity, Theme, hint, layout};
13 + use alloy_tui::{AlloyLog, AlloyModal, AlloyStatusBar, Hint, Severity, Theme, hint, layout};
13 14 use anyhow::Result;
14 15 use ratatui::Frame;
15 16 use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind};
@@ -18,10 +19,55 @@
18 19 use crate::cli::CommandLog;
19 20
20 21 /// What a view wants the shell to do after handling a key.
21 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 + ///
23 + /// Not `Copy` or `PartialEq`: two variants carry owned data. Callers match
24 + /// rather than compare.
25 + ///
26 + /// `Exit`, `Confirm`, and `Suspend` have no production consumer yet. The first
27 + /// view with a destructive action and a shell-out is `alloy pkg`, which is
28 + /// blocked on Fedora hardware; the shell side landed first because both
29 + /// mechanisms get more expensive to retrofit once `alloy config` has form state
30 + /// to lose. Covered by tests in this module. Drop the allow when `alloy pkg`
31 + /// lands.
32 + #[allow(dead_code)]
33 + #[derive(Debug)]
22 34 pub enum Flow {
23 35 Continue,
24 36 Exit,
37 + /// Open a confirmation modal. The view keeps whatever it was about to do
38 + /// and performs it in [`View::confirmed`] if the user agrees.
39 + Confirm(Confirm),
40 + /// Hand the terminal to an interactive program, then come back.
41 + ///
42 + /// For children that want a real TTY (`distrobox enter`, an editor). The
43 + /// shell tears the TUI down, runs it to completion, and re-initializes.
44 + Suspend(Command),
45 + }
46 +
47 + /// A confirmation prompt raised by a view.
48 + ///
49 + /// Carries only what to display. The pending action stays in the view, which
50 + /// keeps [`View`] object-safe and means the shell never has to understand what
51 + /// it is confirming.
52 + #[derive(Debug)]
53 + pub struct Confirm {
54 + pub title: String,
55 + pub message: String,
56 + pub severity: Severity,
57 + }
58 +
59 + impl Confirm {
60 + /// A destructive confirm: the common case, and the reason this exists.
61 + ///
62 + /// Unused until `alloy pkg` gains its remove action; see [`Flow`].
63 + #[allow(dead_code)]
64 + pub fn destructive(title: impl Into<String>, message: impl Into<String>) -> Self {
65 + Self {
66 + title: title.into(),
67 + message: message.into(),
68 + severity: Severity::Error,
69 + }
70 + }
25 71 }
26 72
27 73 /// A console screen. Views own their data and their body; the shell owns the
@@ -45,6 +91,17 @@
45 91 /// Handle a key the shell did not claim.
46 92 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow;
47 93
94 + /// The user confirmed the modal this view raised with [`Flow::Confirm`].
95 + ///
96 + /// Default is nothing, so a view with no destructive actions ignores the
97 + /// whole mechanism.
98 + fn confirmed(&mut self, _log: &mut CommandLog) {}
99 +
100 + /// The user dismissed the modal this view raised.
101 + ///
102 + /// Views clear their pending action here. Default is nothing.
103 + fn cancelled(&mut self) {}
104 +
48 105 /// Called roughly every [`TICK`] while no key is pressed.
49 106 ///
50 107 /// For views onto state that changes without the user (an app starting
@@ -81,8 +138,13 @@
81 138 view: &mut dyn View,
82 139 log: &mut CommandLog,
83 140 ) -> Result<()> {
141 + // The one modal slot. Deliberately not a stack: a confirm raised from a
142 + // confirm is a design smell, and an unbounded stack turns Esc into "how
143 + // many times do I press this" rather than "back out".
144 + let mut modal: Option<Confirm> = None;
145 +
84 146 loop {
85 - terminal.draw(|frame| draw(frame, theme, view, log))?;
147 + terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref()))?;
86 148
87 149 // Poll rather than block, so a view can refresh state that changes
88 150 // without the user. `poll` returns as soon as an event arrives, so
@@ -101,18 +163,106 @@
101 163 continue;
102 164 }
103 165
104 - match classify(key) {
105 - Action::Quit | Action::Cancel => return Ok(()),
106 - _ => {
107 - if view.handle(key, log) == Flow::Exit {
108 - return Ok(());
166 + let action = classify(key);
167 +
168 + // A modal owns every key while it is open. Without this the reserved
169 + // keys still reach the shell, so `q` would quit the console out from
170 + // under a "delete this?" prompt.
171 + if modal.is_some() {
172 + match modal_key(action) {
173 + ModalOutcome::Confirmed => {
174 + modal = None;
175 + view.confirmed(log);
109 176 }
177 + ModalOutcome::Cancelled => {
178 + modal = None;
179 + view.cancelled();
180 + }
181 + ModalOutcome::Ignored => {}
110 182 }
183 + continue;
184 + }
185 +
186 + match action {
187 + Action::Quit | Action::Cancel => return Ok(()),
188 + _ => match view.handle(key, log) {
189 + Flow::Exit => return Ok(()),
190 + Flow::Continue => {}
191 + Flow::Confirm(confirm) => modal = Some(confirm),
192 + Flow::Suspend(command) => suspend(terminal, view, log, command)?,
193 + },
111 194 }
112 195 }
113 196 }
114 197
115 - fn draw(frame: &mut Frame, theme: &Theme, view: &dyn View, log: &mut CommandLog) {
198 + /// What a key does to an open modal.
199 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
200 + enum ModalOutcome {
201 + Confirmed,
202 + Cancelled,
203 + Ignored,
204 + }
205 +
206 + /// Route a reserved action against an open modal.
207 + ///
208 + /// Pure, and separate from the event loop, because this is the piece with the
209 + /// consequences: it decides whether `q` closes a destructive prompt or the
210 + /// whole console. A loop needing a live terminal is a bad place to keep a rule
211 + /// that wants testing.
212 + ///
213 + /// Everything unrecognized is ignored rather than passed through to the view.
214 + /// A modal is a question, and a view acting on keys while the user is being
215 + /// asked something is how a confirm ends up applying to a different row than
216 + /// the one it named.
217 + const fn modal_key(action: Action) -> ModalOutcome {
218 + match action {
219 + Action::Activate => ModalOutcome::Confirmed,
220 + // `q` cancels rather than quitting. It is the console's quit key
221 + // everywhere else, so a user reaching for it mid-prompt means "get me
222 + // out of this", not "close the application".
223 + Action::Cancel | Action::Quit => ModalOutcome::Cancelled,
224 + _ => ModalOutcome::Ignored,
225 + }
226 + }
227 +
228 + /// Hand the terminal to an interactive child, then take it back.
229 + ///
230 + /// The TUI is torn down before the child runs and rebuilt after, so the child
231 + /// gets a normal terminal: raw mode off, alternate screen exited, cursor
232 + /// visible. `distrobox enter` and anything else expecting a real TTY needs
233 + /// that; run underneath a live ratatui it would draw into the alternate screen
234 + /// and fight the event loop for input.
235 + ///
236 + /// The terminal is rebuilt whether or not the child succeeded. A child exiting
237 + /// nonzero is ordinary (the user typed `exit 1`, the box was gone) and must not
238 + /// strand them in a torn-down TUI.
239 + fn suspend(
240 + terminal: &mut ratatui::DefaultTerminal,
241 + view: &mut dyn View,
242 + log: &mut CommandLog,
243 + mut command: Command,
244 + ) -> Result<()> {
245 + ratatui::restore();
246 + let status = command.status();
247 + *terminal = ratatui::init();
248 + terminal.clear()?;
249 +
250 + // The child usually changed what the view is looking at — entering a box
251 + // starts it. Refresh through the tick path, which is where console
252 + // bookkeeping belongs.
253 + view.tick(log);
254 +
255 + status?;
256 + Ok(())
257 + }
258 +
259 + fn draw(
260 + frame: &mut Frame,
261 + theme: &Theme,
262 + view: &dyn View,
263 + log: &mut CommandLog,
264 + modal: Option<&Confirm>,
265 + ) {
116 266 let areas = layout::console(frame.area());
117 267
118 268 view.render(frame, areas.body, theme);
@@ -125,10 +275,152 @@
125 275 status_bar = status_bar.status(severity, message);
126 276 }
127 277 frame.render_widget(status_bar, areas.footer);
278 +
279 + // Drawn last so it sits over the view. The modal covers the body only: the
280 + // command log stays readable underneath, which matters when the thing being
281 + // confirmed is the command shown on the log's last line.
282 + if let Some(confirm) = modal {
283 + let area = layout::centered(areas.body, MODAL_WIDTH, MODAL_HEIGHT);
284 + frame.render_widget(
285 + AlloyModal::new(theme, &confirm.title, &confirm.message).severity(confirm.severity),
286 + area,
287 + );
288 + }
128 289 }
129 290
291 + /// Modal box size. Wide enough for a package name plus a sentence about what
292 + /// removing it does, short enough to leave the view visible around it.
293 + const MODAL_WIDTH: u16 = 54;
294 + const MODAL_HEIGHT: u16 = 7;
295 +
130 296 /// Title text for a view's body block, padded so it does not sit flush against
131 297 /// the border corner.
132 298 pub fn block_title(title: &str) -> String {
133 299 format!(" {title} ")
134 300 }
301 +
302 + #[cfg(test)]
303 + mod tests {
304 + use super::*;
305 + use ratatui::layout::Rect;
306 +
307 + /// A view that records what the shell called on it. Stands in for the real
308 + /// views, none of which have destructive actions yet.
309 + #[derive(Default)]
310 + struct StubView {
311 + confirmed: usize,
312 + cancelled: usize,
313 + }
314 +
315 + impl View for StubView {
316 + fn title(&self) -> String {
317 + "stub".into()
318 + }
319 + fn hints(&self) -> Vec<Hint> {
320 + Vec::new()
321 + }
322 + fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {}
323 + fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow {
324 + Flow::Continue
325 + }
326 + fn confirmed(&mut self, _log: &mut CommandLog) {
327 + self.confirmed += 1;
328 + }
329 + fn cancelled(&mut self) {
330 + self.cancelled += 1;
331 + }
332 + }
333 +
334 + #[test]
335 + fn enter_confirms_and_esc_cancels() {
336 + assert_eq!(modal_key(Action::Activate), ModalOutcome::Confirmed);
337 + assert_eq!(modal_key(Action::Cancel), ModalOutcome::Cancelled);
338 + }
339 +
340 + // The whole reason this mechanism exists. Before it, Cancel and Quit both
341 + // returned from the event loop, so there was no way to ask a question the
342 + // user could decline without also closing the console.
343 + #[test]
344 + fn q_cancels_the_modal_rather_than_quitting_the_console() {
345 + assert_eq!(modal_key(Action::Quit), ModalOutcome::Cancelled);
346 + }
347 +
348 + // A modal is a question. Keys that would otherwise act on the view behind
349 + // it must not reach it, or a confirm can be answered against a row the user
350 + // moved off while the prompt was up.
351 + #[test]
352 + fn keys_that_are_not_an_answer_do_nothing() {
353 + for action in [
354 + Action::NextFocus,
355 + Action::PrevFocus,
356 + Action::NextTab,
357 + Action::PrevTab,
358 + Action::Save,
359 + Action::Filter,
360 + Action::Command,
361 + Action::Help,
362 + Action::Passthrough,
363 + ] {
364 + assert_eq!(
365 + modal_key(action),
366 + ModalOutcome::Ignored,
367 + "{action:?} must not answer a modal"
368 + );
369 + }
370 + }
371 +
372 + #[test]
373 + fn confirm_and_cancel_reach_the_view() {
374 + let mut view = StubView::default();
375 + let mut log = CommandLog::new();
376 +
377 + view.confirmed(&mut log);
378 + view.cancelled();
379 +
380 + assert_eq!(view.confirmed, 1);
381 + assert_eq!(view.cancelled, 1);
382 + }
383 +
384 + // Views with no destructive actions must not have to know this exists.
385 + #[test]
386 + fn confirm_hooks_default_to_nothing() {
387 + struct Bare;
388 + impl View for Bare {
389 + fn title(&self) -> String {
390 + "bare".into()
391 + }
392 + fn hints(&self) -> Vec<Hint> {
393 + Vec::new()
394 + }
395 + fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {}
396 + fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow {
397 + Flow::Continue
398 + }
399 + }
400 +
401 + let mut log = CommandLog::new();
402 + Bare.confirmed(&mut log);
403 + Bare.cancelled();
404 + }
405 +
406 + #[test]
407 + fn a_destructive_confirm_carries_the_error_accent() {
408 + let confirm = Confirm::destructive("remove", "Remove tailscale?");
409 + assert_eq!(confirm.severity, Severity::Error);
410 + assert_eq!(confirm.title, "remove");
411 + }
412 +
413 + // Flow's data-carrying variants exist for `alloy pkg`, which is blocked on
414 + // Fedora hardware. Constructing them here keeps them compiled and checked
415 + // rather than sitting behind an allow(dead_code) until that lands.
416 + #[test]
417 + fn flow_carries_a_confirm_and_a_suspendable_command() {
418 + let flow = Flow::Confirm(Confirm::destructive("remove", "Remove tailscale?"));
419 + assert!(matches!(flow, Flow::Confirm(_)));
420 +
421 + let flow = Flow::Suspend(Command::new("distrobox"));
422 + assert!(matches!(flow, Flow::Suspend(_)));
423 +
424 + assert!(matches!(Flow::Exit, Flow::Exit));
425 + }
426 + }
@@ -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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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.