# Alloy component library The intended idioms for the `alloy_tui` crate: the ratatui design-system library the `alloy` console and every authored Alloy TUI depend on. It is Alloy's one authored visual identity. ## Scope `alloy_tui` exposes: - **Design tokens** as a runtime `Theme` value resolving makeover intents into ratatui `Color` / `Style`. Single source of truth for everything in [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md). - **Themed primitives** configured against the tokens: blocks, list rows, tags, form fields, status bars, log panes. Each carries its focus/selected/disabled variants. - **Layout helpers**: Alloy-flavored wrappers over ratatui's `Layout` constraint solver (section spacing measured in cells). - **A focus + keymap model**: the piece ratatui does not give you. Rendering is immediate-mode, but input is event-driven, so the crate owns the navigation model the app drives from its event loop: `FocusRing` across panes, `Cursor` down a list. It does *not* expose: - Application-level state or logic. - Reactive primitives, signals, observables (would violate principle 3 in [MANIFESTO.md](MANIFESTO.md)). - **Backend detection.** The mock-or-real pattern lives per view in the console binary, because what counts as the real backend is `nmcli` for one view and `pactl` for the next. A design-system crate has no business knowing either name. ## The ratatui architecture, stated plainly ratatui is immediate-mode *rendering* with event-driven *input*. Each tick the app draws its whole UI from current state into a `Frame` (no retained widget tree to diverge), then separately handles input events that mutate that state. `alloy_tui` follows that split: the primitives below are render helpers, and the focus/keymap model is what turns key events into state changes. There is no inline `button.clicked()` as in a retained or egui-style toolkit; a "button" renders in its current state, and the app's event handler decides what the activation key does when that control holds focus. ## The four idioms The crate uses four idioms consistently so authoring across TUIs feels uniform. ### Functions for one-shot render helpers The lightest pattern. A helper takes the `Frame` and an area and draws from state: ```rust pub fn status_line(f: &mut Frame, area: Rect, theme: &Theme, state: &StatusState) { let style = match state.severity { Severity::Error => Severity::Error.style(theme), _ => Style::default().fg(theme.content_muted), }; f.render_widget(Paragraph::new(state.message.as_str()).style(style), area); } ``` Use functions for one-shot chrome that takes few arguments and needs no configuration. ### Builders for configurable widgets ratatui widgets are already builders; `alloy_tui` wraps them so the theming is not restatable per call site. A wrapper either returns a configured ratatui widget or renders itself: ```rust pub struct AlloyBlock<'a> { theme: &'a Theme, focused: bool, } impl<'a> AlloyBlock<'a> { pub fn new(theme: &'a Theme) -> Self { /* ... */ } pub fn focused(mut self, focused: bool) -> Self { /* ... */ } // Returns a themed ratatui Block; the caller titles and renders it. // Per DESIGN-LANGUAGE.md, focus swaps *only* the border color. Surface, // title, and everything else stay constant across focused/unfocused; // border-only is the whole rule. 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)) } } // Call site: let block = AlloyBlock::new(theme).focused(focus.is_focused(PANE_NET)).build(); let inner = block.inner(area); frame.render_widget(block.title(" network "), area); ``` The theme arrives as a borrowed `&Theme` rather than being read from globals, because there are no global tokens to read: the palette is loaded from a file at startup. Every widget in the crate takes the theme the same way. The title is applied by the caller rather than the builder, so the wrapper stays a thin themed `Block` and composes with everything ratatui already does with one. ### Layout helpers over the constraint solver ratatui's `Layout` is the flow primitive; `alloy_tui` adds themed splits that bake in Alloy's cell spacing: ```rust // A titled section: a header row, one cell of breathing space, then the body area. pub fn section(area: Rect, title: &str) -> (Rect /* header */, Rect /* body */) { let rows = Layout::vertical([ Constraint::Length(1), // header Constraint::Length(1), // gap, in cells Constraint::Min(0), // body ]).split(area); (rows[0], rows[2]) } ``` Composition is by nesting `Layout` splits, exactly as ratatui intends; the helpers only fix the spacing constants and header styling. ### `Widget` / `StatefulWidget` for reusable types ratatui's canonical extension traits. Use for types that render themselves and, when they carry selection/scroll state, `StatefulWidget`: ```rust pub struct UrgencyTag<'a> { theme: &'a Theme, kind: Urgency } impl Widget for UrgencyTag<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let accent = match self.kind { Urgency::Low => self.theme.status_info, Urgency::Medium => self.theme.status_warning, Urgency::High => self.theme.status_danger, }; // Accent on the glyph + label only, never as a filled background: // color is information, not chrome (see DESIGN-LANGUAGE.md). Line::from(vec![ Span::styled(self.kind.glyph(), Style::default().fg(accent)), Span::raw(" "), Span::styled(self.kind.label(), Style::default().fg(self.theme.content_primary)), ]).render(area, buf); } } // Call site: f.render_widget(UrgencyTag { theme, kind: Urgency::High }, area); ``` The starter widget set (per [CONSOLE.md](CONSOLE.md)). Shipped: `AlloyBlock`, `AlloyList`, `AlloyStatusBar`, `AlloyLog`, `AlloyConnector`, `AlloyTabs`, and `AlloyModal`. Shipped with `alloy settings` as 1.2: `AlloyForm`, a single `AlloyField`, `AlloyPicker`, and a display-only `AlloyTable` (see the next section). `AlloyList` scrolls statelessly: the first visible row is derived from the selection every frame rather than carried in a `ListState`. That is what keeps it immediate-mode, and it costs centered scrolling rather than minimal scrolling. Because anything drawing alongside a list has to agree with it about which rows are on screen, that derivation is public (`list_offset`, `list_row_y`) rather than reimplemented by callers; `AlloyConnector` uses it to find the row it points at. ### The config form: one `AlloyField`, not five Five separate field widgets (`ColorField`, `EnumField`, `RangedNumberField`, `ToggleField`, `TextField`) would differ in exactly one thing, because the architecture above keeps state and validation in the console binary rather than in the widget: how the *value cell* paints. So the crate ships one widget: ```rust pub enum FieldKind<'a> { Toggle(bool), Text(&'a str), Number(&'a str), // the binary pre-formats Enum { label: &'a str }, // the current selection's label Color { hex: &'a str }, // draws a swatch alongside the value } pub struct AlloyField<'a> { theme: &'a Theme, label: &'a str, kind: FieldKind<'a>, focused: bool, edit: Option<&'a TextField>, // Some => in edit mode, draw the caret buffer diagnostic: Option<(Severity, &'a str)>, help: Option<&'a str>, } ``` `AlloyField` is pure render, like every widget here: it owns no value and does no validation. Which schema type a field is comes off a `.schema` file read at startup, so it is runtime data, not a compile-time type. Five static field types would buy no safety at the one call site (the form renderer, which builds rows from the schema at runtime) and would be erased back into a `match` on the schema type immediately. And because `AlloyForm` has to hold a heterogeneous row list, an enum is required regardless: five widgets would be that enum plus five near-duplicate wrappers. Collapsing removes code without losing anything. `AlloyForm` is the chrome around a sequence of `AlloyField` rows: collapsible section panes (one per schema section, folded with Space), scroll reusing `list_offset` / `list_row_y`, focus highlight, and the help + inline-diagnostic lines. It takes a flat slice of visible rows the binary rebuilds each frame (section headers plus the fields of open sections), with a `Cursor` riding it. `AlloyTable` renders list-of-tables records read-only in v1; full add/remove/cell-edit is v1.1. `TextField`, the single-line caret buffer `AlloyField` shows in edit mode, promotes out of the console binary's `field.rs` unchanged; it started there because the crate is a separate published repo and a widget there is a release, and this is that release (`alloy_tui` 1.2). `AlloyPicker` is the overlay a closed vocabulary opens. It was specced against three-value enums like rio's `cursor.shape`; the System tab's zone row is an enum over `timedatectl list-timezones`, about 600 entries, so the overlay filters as you type: a `TextField` above an `AlloyList`, matching on a plain substring. No fuzzy match, because zone names are terse and hierarchical and a ranker over 600 strings is a scoring function to tune for no gain a substring does not give. The overlay needs a widget of its own. `AlloyModal` cannot wrap a list: it renders its own message paragraph, so composing in the binary would mean hand-rolling the `surface.overlay` chrome next to a widget that already owns it. Every floating thing in every Alloy TUI should read the same, which is the argument for the crate and against the view. `AlloyPicker` owns nothing, like the rest: the buffer, the filtering, and the selection are the caller's. ## Focus and keymap model The part ratatui does not provide. `alloy_tui` ships: - A `FocusRing`: an ordered ring of focusable panes, with `next()` / `prev()` / `current()` / `is_focused(slot)`. The app holds one in its state; widgets render their focused variant when the ring reports their slot. It wraps, because wrapping past the last pane back to the first is what a user means by Tab. A zero-length ring is inert rather than a modulo by zero, so a view whose panes have not loaded needs no special case. - A `Cursor`: a selection over a list, which is a different problem and therefore a different type. It clamps instead of wrapping, since a user holding `j` at the bottom of a list expects to stay there, and it re-clamps on `resize()` when a refresh returns fewer rows than before. It reports `Option`, so an empty list selects nothing rather than reporting row 0. - Reserved keymap constants so every Alloy TUI navigates identically: `Tab` / `Shift-Tab` move focus, `Enter` activates, `Esc` cancels, `Ctrl-S` saves, `q` quits, with `?`, `/`, and `:` reserved for help, filter, and command entry. These live in one place; apps match against the classified `Action` rather than hardcoding keycodes. Two caveats the classifier documents rather than hides: callers must filter to key *press* events, since Windows terminals deliver press and release and an unfiltered loop performs everything twice, and a view holding an active text input must not treat `q`, `/`, or `:` as reserved. - A footer-chrome helper that renders the active keymap hints along the bottom row, so the reserved keys are always discoverable, with a status slot at the right end. This is the direct descendant of `sysop-tui`, retinted from that crate's const palette to the runtime theme. ## Design tokens Tokens are the single source of truth, and they are read at runtime through makeover. There is no build-time token compilation step. [TOKENS.md](TOKENS.md) is authoritative; this section only describes the crate-side shape. Themes are makeover `.toml` files, the same schema every make-family app reads, living in makeover's `themes/` and `~/.config/alloy/themes/`. `alloy_tui` loads one, resolves the intents it needs into ratatui colors, and derives two Alloy-specific tokens locally so theme files stay minimal and cross-app compatible: ``` border-subtle = mix(line.border, surface.page, 60%) decorative divider border-strong = mix(line.border, content.primary, 65%) focus / selection ``` Mixing is in linear sRGB, matching the audit math in TOKENS.md, and is pinned by a test against a value from that document's contrast table. If the test fails, the table is stale rather than the code. The result is one `Theme` value per load, threaded by reference through the widgets. Two consequences worth stating, because both differ from a constants pipeline: - **A malformed or partial theme is rejected, not defaulted.** Every intent the crate renders is required at load. There is no built-in fallback palette anywhere in the crate or the console, because TOKENS.md's rule is that no hex values live in Rust, and a silent fallback would put a palette there that exists in no theme file. - **Light and dark are not two compiled ramps.** They are separate theme files (`akari-dawn`, `akari-night`), selected by `--theme` or guessed from `$COLORFGBG` at startup. `Mode` records which kind was loaded; it does not select between built-in palettes. Truecolor is the target (rio and every modern terminal render 24-bit `Color::Rgb`). A 256-color downgrade table for terminals reporting no truecolor is still deferred, as it is in TOKENS.md. ## Per-frame discipline ratatui already redraws the whole frame each tick, so there is no `set_style` seam to police as egui had. The discipline is narrower and just as strict: **widgets never construct a `Color` literally; every color comes off the `&Theme` they were handed.** One `Theme` is built per theme load and threaded through the render call, so light/dark selection happens in one place. That keeps theming centralized and prevents per-app drift. ## What this enforces - Every Alloy TUI looks coherent because they all consume the same tokens and the same primitives. - The non-reactive principle is enforced by exposing no reactive primitives. - Mandatory state variants from [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md#affordance-depth-via-ramp-not-via-color) are baked into the primitives: `AlloyBlock` cannot render without resolving its focused/unfocused border from the ramp, and `AlloyField` resolves its disabled style from `text-muted`. - The theme file is the seam where design tooling could plug in. ## Late-term: a design tool, and the token seam The theme file is the deliberate seam where external design tooling could live, and it is toolkit-agnostic: anything that emits a valid makeover `.toml` is a viable producer (a hand-edited file, Tokens Studio, a future Alloy-built tool), and the format is shared with every make-family app. A speculative token-sync tool keeps the working name **`cast`** (alloys are cast; the tool casts a design source into Alloy tokens). The broader research question of a design tool native to immediate-mode UI is captured in [RESEARCH-IMMEDIATE-MODE-DESIGN.md](RESEARCH-IMMEDIATE-MODE-DESIGN.md); it targets terminal compositions rather than GPU-drawn ones, and the architecture is the same. The commitment that keeps the door open: **tokens are a file, not Rust source.** As long as the theme file is the authority and Rust holds no hex values of its own, the producer is swappable and the design system stays portable. Runtime loading strengthens that commitment: swapping a theme is now a file change and a relaunch rather than a rebuild.