Skip to main content

max / alloy

15.5 KB · 214 lines History Blame Raw
1 # Alloy component library
2
3 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.
4
5 ## Scope
6
7 `alloy_tui` exposes:
8
9 - **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.
10 - **Themed primitives** configured against the tokens: blocks, list rows, tags, form fields, status bars, log panes. Each carries its focus/selected/disabled variants.
11 - **Layout helpers**: Alloy-flavored wrappers over ratatui's `Layout` constraint solver (section spacing measured in cells).
12 - **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.
13
14 It does *not* expose:
15
16 - Application-level state or logic.
17 - Reactive primitives, signals, observables (would violate principle 3 in [MANIFESTO.md]MANIFESTO.md).
18 - **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.
19
20 ## The ratatui architecture, stated plainly
21
22 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.
23
24 ## The four idioms
25
26 The crate uses four idioms consistently so authoring across TUIs feels uniform.
27
28 ### Functions for one-shot render helpers
29
30 The lightest pattern. A helper takes the `Frame` and an area and draws from state:
31
32 ```rust
33 pub fn status_line(f: &mut Frame, area: Rect, theme: &Theme, state: &StatusState) {
34 let style = match state.severity {
35 Severity::Error => Severity::Error.style(theme),
36 _ => Style::default().fg(theme.content_muted),
37 };
38 f.render_widget(Paragraph::new(state.message.as_str()).style(style), area);
39 }
40 ```
41
42 Use functions for one-shot chrome that takes few arguments and needs no configuration.
43
44 ### Builders for configurable widgets
45
46 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:
47
48 ```rust
49 pub struct AlloyBlock<'a> {
50 theme: &'a Theme,
51 focused: bool,
52 }
53
54 impl<'a> AlloyBlock<'a> {
55 pub fn new(theme: &'a Theme) -> Self { /* ... */ }
56 pub fn focused(mut self, focused: bool) -> Self { /* ... */ }
57
58 // Returns a themed ratatui Block; the caller titles and renders it.
59 // Per DESIGN-LANGUAGE.md, focus swaps *only* the border color. Surface,
60 // title, and everything else stay constant across focused/unfocused;
61 // border-only is the whole rule.
62 pub fn build(self) -> Block<'a> {
63 let border_color = if self.focused {
64 self.theme.border_strong
65 } else {
66 self.theme.border_subtle
67 };
68 Block::default()
69 .borders(Borders::ALL)
70 .border_style(Style::default().fg(border_color))
71 .style(Style::default().bg(self.theme.surface_page).fg(self.theme.content_primary))
72 }
73 }
74
75 // Call site:
76 let block = AlloyBlock::new(theme).focused(focus.is_focused(PANE_NET)).build();
77 let inner = block.inner(area);
78 frame.render_widget(block.title(" network "), area);
79 ```
80
81 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.
82
83 ### Layout helpers over the constraint solver
84
85 ratatui's `Layout` is the flow primitive; `alloy_tui` adds themed splits that bake in Alloy's cell spacing:
86
87 ```rust
88 // A titled section: a header row, one cell of breathing space, then the body area.
89 pub fn section(area: Rect, title: &str) -> (Rect /* header */, Rect /* body */) {
90 let rows = Layout::vertical([
91 Constraint::Length(1), // header
92 Constraint::Length(1), // gap, in cells
93 Constraint::Min(0), // body
94 ]).split(area);
95 (rows[0], rows[2])
96 }
97 ```
98
99 Composition is by nesting `Layout` splits, exactly as ratatui intends; the helpers only fix the spacing constants and header styling.
100
101 ### `Widget` / `StatefulWidget` for reusable types
102
103 ratatui's canonical extension traits. Use for types that render themselves and, when they carry selection/scroll state, `StatefulWidget`:
104
105 ```rust
106 pub struct UrgencyTag<'a> { theme: &'a Theme, kind: Urgency }
107
108 impl Widget for UrgencyTag<'_> {
109 fn render(self, area: Rect, buf: &mut Buffer) {
110 let accent = match self.kind {
111 Urgency::Low => self.theme.status_info,
112 Urgency::Medium => self.theme.status_warning,
113 Urgency::High => self.theme.status_danger,
114 };
115 // Accent on the glyph + label only, never as a filled background:
116 // color is information, not chrome (see DESIGN-LANGUAGE.md).
117 Line::from(vec![
118 Span::styled(self.kind.glyph(), Style::default().fg(accent)),
119 Span::raw(" "),
120 Span::styled(self.kind.label(), Style::default().fg(self.theme.content_primary)),
121 ]).render(area, buf);
122 }
123 }
124
125 // Call site:
126 f.render_widget(UrgencyTag { theme, kind: Urgency::High }, area);
127 ```
128
129 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).
130
131 `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.
132
133 ### The config form: one `AlloyField`, not five
134
135 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:
136
137 ```rust
138 pub enum FieldKind<'a> {
139 Toggle(bool),
140 Text(&'a str),
141 Number(&'a str), // the binary pre-formats
142 Enum { label: &'a str }, // the current selection's label
143 Color { hex: &'a str }, // draws a swatch alongside the value
144 }
145
146 pub struct AlloyField<'a> {
147 theme: &'a Theme,
148 label: &'a str,
149 kind: FieldKind<'a>,
150 focused: bool,
151 edit: Option<&'a TextField>, // Some => in edit mode, draw the caret buffer
152 diagnostic: Option<(Severity, &'a str)>,
153 help: Option<&'a str>,
154 }
155 ```
156
157 `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.
158
159 `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.
160
161 `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).
162
163 `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.
164
165 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.
166
167 ## Focus and keymap model
168
169 The part ratatui does not provide. `alloy_tui` ships:
170
171 - 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.
172 - 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<usize>`, so an empty list selects nothing rather than reporting row 0.
173 - 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.
174 - 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.
175
176 This is the direct descendant of `sysop-tui`, retinted from that crate's const palette to the runtime theme.
177
178 ## Design tokens
179
180 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.
181
182 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:
183
184 ```
185 border-subtle = mix(line.border, surface.page, 60%) decorative divider
186 border-strong = mix(line.border, content.primary, 65%) focus / selection
187 ```
188
189 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.
190
191 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:
192
193 - **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.
194 - **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.
195
196 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.
197
198 ## Per-frame discipline
199
200 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.
201
202 ## What this enforces
203
204 - Every Alloy TUI looks coherent because they all consume the same tokens and the same primitives.
205 - The non-reactive principle is enforced by exposing no reactive primitives.
206 - 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`.
207 - The theme file is the seam where design tooling could plug in.
208
209 ## Late-term: a design tool, and the token seam
210
211 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.
212
213 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.
214