//! Themed ratatui widget wrappers. //! //! v1 target per docs/CONSOLE.md: `AlloyBlock`, `AlloyList`, `AlloyForm`, //! `AlloyTable`, `AlloyStatusBar`, `AlloyLog`, plus form-field widgets driven //! by the config schema. This module carries the four the console shell needs //! to render a screen end to end — block, list, log pane, status bar — plus //! the `Severity` accent they all compose with. `AlloyForm`, `AlloyTable`, and //! the schema-driven fields land with `alloy config`. use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Widget}; use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style}; use crate::text; use crate::theme::Theme; /// Themed `Block`: default borders + palette chrome. Wraps `ratatui::widgets::Block` /// so downstream code composes with it directly (`AlloyBlock::new(theme).build()` /// returns the inner `Block`, which callers then `.title(..)` and pass to a /// widget). /// /// Per DESIGN-LANGUAGE.md: chrome is tinted-greyscale; borders never carry an /// accent — accents live on text via `Severity`. Focused vs. unfocused chrome /// swaps `border-subtle` (decorative) for `border-strong` (focus/selection), /// matching TOKENS.md's derived-border tiers. pub struct AlloyBlock<'a> { theme: &'a Theme, focused: bool, } impl<'a> AlloyBlock<'a> { pub fn new(theme: &'a Theme) -> Self { Self { theme, focused: false } } pub fn focused(mut self, focused: bool) -> Self { self.focused = focused; self } pub fn build(self) -> Block<'a> { let border_color = if self.focused { self.theme.border_strong } else { self.theme.border_subtle }; Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .style( Style::default() .bg(self.theme.surface_page) .fg(self.theme.content_primary), ) } } /// Severity accent — categorical status tag whose color comes from the theme's /// `status.*` intents. Per DESIGN-LANGUAGE.md, this is one of the few places /// color is on-purpose: never on chrome, only on glyphs and text. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Severity { Info, Healthy, Warn, Error, } impl Severity { pub const fn label(self) -> &'static str { match self { Severity::Info => "INFO", Severity::Healthy => "OK", Severity::Warn => "WARN", Severity::Error => "ERROR", } } pub fn color(self, theme: &Theme) -> Color { match self { Severity::Info => theme.status_info, Severity::Healthy => theme.status_success, Severity::Warn => theme.status_warning, Severity::Error => theme.status_danger, } } pub fn style(self, theme: &Theme) -> Style { Style::default().fg(self.color(theme)) } } /// A footer key hint: the key, and what it does. pub struct Hint { pub key: &'static str, pub label: &'static str, } /// Terse constructor for a [`Hint`], so hint lists read as data at the call /// site: `[hint("Tab", "focus"), hint("q", "quit")]`. pub const fn hint(key: &'static str, label: &'static str) -> Hint { Hint { key, label } } /// The one-row footer: key hints on the left, transient status on the right. /// /// This is docs/CONSOLE.md's "common status area" and /// docs/COMPONENT-LIBRARY.md's footer chrome in one widget — they occupy the /// same row, and splitting them into two widgets would mean two things /// competing for it. Descended from sysop-tui's `Footer`, with the status /// slot added. pub struct AlloyStatusBar<'a> { theme: &'a Theme, hints: Vec, status: Option<(Severity, String)>, } impl<'a> AlloyStatusBar<'a> { pub fn new(theme: &'a Theme, hints: impl IntoIterator) -> Self { Self { theme, hints: hints.into_iter().collect(), status: None, } } /// Attach a transient status message (busy, error, dirty) to the right end. pub fn status(mut self, severity: Severity, message: impl Into) -> Self { self.status = Some((severity, message.into())); self } } impl Widget for AlloyStatusBar<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let base = Style::default().bg(self.theme.surface_sunken); Paragraph::new("").style(base).render(area, buf); let mut spans: Vec = Vec::with_capacity(self.hints.len() * 3); for (i, h) in self.hints.iter().enumerate() { if i > 0 { spans.push(Span::raw(" ")); } spans.push(text::action(self.theme, h.key)); spans.push(Span::raw(" ")); spans.push(text::muted(self.theme, h.label)); } Paragraph::new(Line::from(spans)) .style(base) .render(area, buf); // The status sits on the same row, right-aligned. Rendering it as a // second pass into a right-hand slice means a long hint list is // overwritten by the status rather than pushing it off-screen — the // status is the more urgent of the two. if let Some((severity, message)) = self.status { let text_width = message.chars().count() as u16 + 1; let width = text_width.min(area.width); let slot = Rect { x: area.x + area.width - width, width, ..area }; Paragraph::new(Line::from(Span::styled( message, severity.style(self.theme).patch(base), ))) .style(base) .right_aligned() .render(slot, buf); } } } /// Themed selectable list. /// /// Rows are pre-composed `Line`s so callers keep control of their own content /// styling (a `Severity` span in a row survives selection); this widget owns /// only the gutter marker, the row style, and scrolling. pub struct AlloyList<'a> { theme: &'a Theme, items: Vec>, selected: Option, } impl<'a> AlloyList<'a> { pub fn new(theme: &'a Theme, items: impl IntoIterator>) -> Self { Self { theme, items: items.into_iter().collect(), selected: None, } } pub fn selected(mut self, selected: Option) -> Self { self.selected = selected; self } /// First visible row for a viewport of `height` rows. fn offset(&self, height: usize) -> usize { list_offset(self.items.len(), height, self.selected) } } /// First visible row of a list, given its length, viewport height, and /// selection. /// /// Stateless by design: the offset is derived from the selection each frame /// rather than carried between frames, which is what lets [`AlloyList`] stay /// immediate-mode. The cost is that scrolling centers the selection instead of /// scrolling by the minimum amount; the benefit is that no caller has to own /// and thread a `ListState`. /// /// Public because anything drawing *alongside* a list has to agree with it /// about which rows are on screen and where. [`AlloyConnector`](crate::AlloyConnector) /// needs a row's y position, and computing that from a second, separate copy /// of this rule is how a connector ends up pointing one row off after a scroll. pub fn list_offset(len: usize, height: usize, selected: Option) -> usize { let (Some(selected), true) = (selected, len > height) else { return 0; }; let max_offset = len - height; selected.saturating_sub(height / 2).min(max_offset) } /// Screen row for list item `index`, or `None` when it is scrolled out of /// view. /// /// `area` is the list's viewport, already inside any block border. pub fn list_row_y(area: Rect, len: usize, selected: Option, index: usize) -> Option { if area.height == 0 || index >= len { return None; } let offset = list_offset(len, area.height as usize, selected); let row = index.checked_sub(offset)?; if row >= area.height as usize { return None; } Some(area.y + row as u16) } impl Widget for AlloyList<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let height = area.height as usize; let offset = self.offset(height); for (row, (index, item)) in self .items .iter() .enumerate() .skip(offset) .take(height) .enumerate() { let is_selected = self.selected == Some(index); let style = if is_selected { selected_style(self.theme) } else { unselected_style(self.theme) }; let marker = if is_selected { MARKER } else { MARKER_BLANK }; let mut spans = vec![Span::styled(format!("{marker} "), style)]; spans.extend(item.spans.iter().cloned()); let line_area = Rect { y: area.y + row as u16, height: 1, ..area }; Paragraph::new(Line::from(spans)) .style(style) .render(line_area, buf); } } } /// A one-row tab bar. /// /// Holds no state: the selected index comes from the caller's /// [`FocusRing`](crate::FocusRing), which is already a wrapping cursor over N /// slots with the `focus(slot)` a verb needs to open the view on a given tab. /// Same split as [`AlloyList`] and [`Cursor`](crate::Cursor) — widget shared, /// state owned by the view. /// /// Selection reads as brackets plus weight rather than color. Per /// DESIGN-LANGUAGE.md color stays off chrome, and per the same reasoning as /// [`MARKER`](crate::MARKER) being a plain triangle, a bracket survives a /// console with no theme and no patched font — the TTY before the session /// starts, `alloy` over SSH. pub struct AlloyTabs<'a> { theme: &'a Theme, labels: Vec, selected: usize, } impl<'a> AlloyTabs<'a> { pub fn new(theme: &'a Theme, labels: impl IntoIterator>) -> Self { Self { theme, labels: labels.into_iter().map(Into::into).collect(), selected: 0, } } /// Select a tab. Out-of-range indices select nothing, matching /// [`FocusRing::focus`](crate::FocusRing::focus): landing on a neighbouring /// tab is worse than showing none as current. pub fn selected(mut self, selected: usize) -> Self { self.selected = selected; self } } /// Gap between tabs. Wide enough that two short labels do not read as one. const TAB_GAP: &str = " "; impl Widget for AlloyTabs<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let mut spans: Vec = Vec::with_capacity(self.labels.len() * 2); for (i, label) in self.labels.iter().enumerate() { if i > 0 { spans.push(Span::raw(TAB_GAP)); } // Unselected labels carry spaces where the selected one carries // brackets, so a label occupies the same cells either way and the // bar does not shift horizontally as selection moves. Same reason // MARKER_BLANK exists for list rows. let (open, close, style) = if i == self.selected { ("[ ", " ]", selected_style(self.theme)) } else { (" ", " ", unselected_style(self.theme)) }; spans.push(Span::styled(format!("{open}{label}{close}"), style)); } let row = Rect { height: 1, ..area }; Paragraph::new(Line::from(spans)) .style(Style::default().bg(self.theme.surface_page)) .render(row, buf); } } /// A centered confirmation modal, drawn over the view that raised it. /// /// Confirmation is design-system chrome rather than per-view furniture: every /// destructive action in every Alloy TUI asks the same way, with the same /// keys. That is the reason this lives here and the shell owns the state, /// instead of each view drawing its own prompt. /// /// Sits on `surface.overlay`, the one theme surface reserved for content /// floating above the page, and borrows `Severity` for the accent so a /// destructive confirm reads red and a benign one does not. pub struct AlloyModal<'a> { theme: &'a Theme, title: &'a str, message: &'a str, severity: Severity, } impl<'a> AlloyModal<'a> { pub fn new(theme: &'a Theme, title: &'a str, message: &'a str) -> Self { Self { theme, title, message, severity: Severity::Warn, } } pub fn severity(mut self, severity: Severity) -> Self { self.severity = severity; self } } impl Widget for AlloyModal<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let base = Style::default() .bg(self.theme.surface_overlay) .fg(self.theme.content_primary); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(self.theme.border_strong)) .style(base) .title(format!(" {} ", self.title)); let inner = block.inner(area); block.render(area, buf); if inner.height == 0 { return; } // Message on top, keys on the last row. The keys are pinned to the // bottom rather than following the message so their position does not // move with message length: a confirm the user cannot dismiss is the // one failure this widget must not have. let keys = Line::from(vec![ text::action(self.theme, "Enter"), Span::styled(" confirm", Style::default().fg(self.theme.content_muted)), Span::raw(" "), text::action(self.theme, "Esc"), Span::styled(" cancel", Style::default().fg(self.theme.content_muted)), ]); let message_height = inner.height.saturating_sub(1); if message_height > 0 { Paragraph::new(Line::from(Span::styled( self.message, self.severity.style(self.theme).patch(base), ))) .style(base) .wrap(ratatui::widgets::Wrap { trim: true }) .render(Rect { height: message_height, ..inner }, buf); } Paragraph::new(keys) .style(base) .render( Rect { y: inner.y + inner.height - 1, height: 1, ..inner }, buf, ); } } /// One line of the command log: the CLI invocation that was run, and how it /// went. /// /// The console fronts CLIs rather than hiding them (docs/CONSOLE.md), so /// `command` holds the actual argv the console executed — verbatim, so a user /// can copy it into a shell and get the same result. #[derive(Debug, Clone)] pub struct LogEntry { pub command: String, pub outcome: Severity, } impl LogEntry { pub fn new(command: impl Into, outcome: Severity) -> Self { Self { command: command.into(), outcome, } } } /// The always-on command-log pane. /// /// Renders the tail of the log — the most recent invocation on the bottom row, /// terminal-transcript order, so the pane reads the way a shell scrollback /// does. pub struct AlloyLog<'a> { theme: &'a Theme, entries: &'a [LogEntry], } impl<'a> AlloyLog<'a> { pub fn new(theme: &'a Theme, entries: &'a [LogEntry]) -> Self { Self { theme, entries } } } impl Widget for AlloyLog<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let block = AlloyBlock::new(self.theme).build().title(" commands "); let inner = block.inner(area); block.render(area, buf); if inner.height == 0 { return; } let visible = inner.height as usize; let tail = self.entries.len().saturating_sub(visible); let lines: Vec = self.entries[tail..] .iter() .map(|entry| { Line::from(vec![ Span::styled("$ ", entry.outcome.style(self.theme)), text::secondary(self.theme, entry.command.clone()), ]) }) .collect(); Paragraph::new(lines) .style(Style::default().bg(self.theme.surface_page)) .render(inner, buf); } } #[cfg(test)] mod tests { use super::*; use ratatui::style::Color; fn theme() -> Theme { Theme { mode: crate::theme::Mode::Dark, surface_page: Color::Rgb(0, 0, 0), surface_raised: Color::Rgb(1, 1, 1), surface_sunken: Color::Rgb(2, 2, 2), surface_overlay: Color::Rgb(3, 3, 3), content_primary: Color::Rgb(4, 4, 4), content_secondary: Color::Rgb(5, 5, 5), content_muted: Color::Rgb(6, 6, 6), action_primary: Color::Rgb(7, 7, 7), status_danger: Color::Rgb(8, 8, 8), status_success: Color::Rgb(9, 9, 9), status_warning: Color::Rgb(10, 10, 10), status_info: Color::Rgb(11, 11, 11), line_border: Color::Rgb(12, 12, 12), border_subtle: Color::Rgb(13, 13, 13), border_strong: Color::Rgb(14, 14, 14), category: [Color::Rgb(15, 15, 15); 6], } } fn list_of(n: usize, selected: Option) -> AlloyList<'static> { // Leaked so the test list can hold a 'static theme reference; the // widget borrows rather than owns, and these are per-test one-offs. let theme: &'static Theme = Box::leak(Box::new(theme())); let items: Vec> = (0..n).map(|i| Line::from(format!("row {i}"))).collect(); AlloyList::new(theme, items).selected(selected) } #[test] fn short_list_never_scrolls() { assert_eq!(list_of(3, Some(2)).offset(10), 0); } // Selection near the top must not scroll past the start of the list — a // naive `selected - height/2` underflows or shows blank rows above row 0. #[test] fn offset_clamps_at_the_top() { assert_eq!(list_of(50, Some(0)).offset(10), 0); assert_eq!(list_of(50, Some(2)).offset(10), 0); } // Selection at the end must land the last row on the last visible line, // not scroll into empty space past the end of the list. #[test] fn offset_clamps_at_the_bottom() { assert_eq!(list_of(50, Some(49)).offset(10), 40); } #[test] fn offset_centers_a_midlist_selection() { assert_eq!(list_of(50, Some(25)).offset(10), 20); } #[test] fn row_y_maps_visible_items_to_screen_rows() { let area = Rect::new(0, 5, 20, 10); assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5)); assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7)); } // After a scroll the mapping has to follow the offset. A connector using a // separate copy of the scroll rule is exactly what this prevents. #[test] fn row_y_accounts_for_scrolling() { let area = Rect::new(0, 0, 20, 10); // 50 items, selection at 25 => offset 20, so item 20 is the top row. assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0)); assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5)); } #[test] fn row_y_is_none_for_rows_scrolled_out_of_view() { let area = Rect::new(0, 0, 20, 10); assert_eq!(list_row_y(area, 50, Some(25), 0), None, "above the viewport"); assert_eq!(list_row_y(area, 50, Some(25), 49), None, "below the viewport"); assert_eq!(list_row_y(area, 3, Some(0), 9), None, "past the end of the list"); } fn render_tabs(selected: usize, width: u16) -> String { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, width, 1)); AlloyTabs::new(&theme, ["installed", "boxes", "system"]) .selected(selected) .render(Rect::new(0, 0, width, 1), &mut buf); buf.content().iter().map(|cell| cell.symbol()).collect() } #[test] fn selected_tab_is_bracketed_and_others_are_not() { let rendered = render_tabs(0, 60); assert!(rendered.contains("[ installed ]"), "selected tab is bracketed"); assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not"); assert!(rendered.contains("boxes"), "unselected labels still render"); } // The bar must not shift horizontally as selection moves, or every tab // change reads as the whole row twitching. Unselected labels pad to the // bracket width for exactly this reason. #[test] fn labels_hold_their_columns_across_selections() { let first = render_tabs(0, 60); let last = render_tabs(2, 60); assert_eq!( first.find("system"), last.find("system"), "a label sits in the same columns whichever tab is selected" ); } // FocusRing::focus ignores out-of-range slots rather than clamping, and the // bar has to agree: showing a neighbouring tab as current would misreport // which screen the user is looking at. #[test] fn out_of_range_selection_brackets_nothing() { let rendered = render_tabs(9, 60); assert!(!rendered.contains('['), "no tab is marked current"); assert!(rendered.contains("installed"), "labels still render"); } #[test] fn zero_height_area_renders_nothing_rather_than_panicking() { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1)); AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf); AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf); } fn render_modal(area: Rect) -> Vec { let theme = theme(); let mut buf = Buffer::empty(area); AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf); (0..area.height) .map(|y| { (0..area.width) .map(|x| buf[(x, y)].symbol()) .collect::() }) .collect() } #[test] fn modal_shows_its_message_and_both_keys() { let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n"); assert!(rows.contains("Remove tailscale?"), "message renders"); assert!(rows.contains("remove"), "title renders"); assert!(rows.contains("Enter"), "confirm key renders"); assert!(rows.contains("Esc"), "cancel key renders"); } // The keys are pinned to the last inner row rather than flowing after the // message. A prompt whose dismiss keys move with message length, or fall // off a short box, is a modal the user cannot get out of. #[test] fn keys_sit_on_the_last_row_whatever_the_message_length() { for height in [5, 7, 12] { let rows = render_modal(Rect::new(0, 0, 40, height)); let last_inner = &rows[height as usize - 2]; assert!( last_inner.contains("Enter") && last_inner.contains("Esc"), "height {height}: keys belong on the last inner row, got {last_inner:?}" ); } } #[test] fn modal_survives_an_area_too_small_to_draw_in() { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7)); AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf); AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf); AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf); } // A log longer than its pane shows the newest entries. Showing the head // instead would freeze the pane on startup noise and never display the // command the user just triggered. #[test] fn log_renders_the_newest_entries() { let theme = theme(); let entries: Vec = (0..10) .map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy)) .collect(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4)); AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf); let rendered = buf .content() .iter() .map(|cell| cell.symbol()) .collect::(); assert!(rendered.contains("nmcli run 9"), "newest entry must be visible"); assert!(!rendered.contains("nmcli run 0"), "oldest entry must have scrolled off"); } }