Skip to main content

max / alloy

docs: settle the alloy config view architecture and collapse the field widgets The alloy config design was complete at the document level (schema-DSL v1, worked rio.toml.schema) but the bridge to Rust was not written down. Record it. COMPONENT-LIBRARY.md and CONSOLE.md rostered five field widgets (ColorField, EnumField, RangedNumberField, ToggleField, TextField) carried over from the pre-pivot egui design, where each was a stateful component. The ratatui architecture already moved state and validation into the binary, leaving the five differing only in how the value cell paints; AlloyForm's heterogeneous row list forces an enum regardless. Collapse to one AlloyField with a FieldKind value-cell enum, and record the crate/binary split, modal per-field edit, collapsible sections, atomic presets, save/quit-confirm, read-only list-of-tables in v1, the fallback, and the build order. Design thread recorded in the alloy-console wiki note.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 13:15 UTC
Signed with PGP, not checked
Commit: 028c9dd0bfafb74b67a3931c684be586df2b4820
Parent: c7fa795
2 files changed, +48 insertions, -5 deletions
@@ -126,10 +126,40 @@
126 126 f.render_widget(UrgencyTag { theme, kind: Urgency::High }, area);
127 127 ```
128 128
129 - The starter widget set (per [CONSOLE.md](CONSOLE.md)). Shipped: `AlloyBlock`, `AlloyList`, `AlloyStatusBar`, `AlloyLog`, and `AlloyConnector`. Landing with `alloy config`: `AlloyForm`, `AlloyTable`, and the schema-driven form fields (`ColorField`, `EnumField`, `RangedNumberField`, `ToggleField`, `TextField`).
129 + The starter widget set (per [CONSOLE.md](CONSOLE.md)). Shipped: `AlloyBlock`, `AlloyList`, `AlloyStatusBar`, `AlloyLog`, `AlloyConnector`, `AlloyTabs`, and `AlloyModal`. Landing with `alloy config`: `AlloyForm`, a single `AlloyField`, and a display-only `AlloyTable` (see the next section).
130 130
131 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 132
133 + ### The config form: one `AlloyField`, not five
134 +
135 + Earlier drafts of this document and CONSOLE.md rostered five field widgets: `ColorField`, `EnumField`, `RangedNumberField`, `ToggleField`, `TextField`. That roster is a fossil of the pre-pivot egui design, where each field was a stateful component owning its own picker and value state. The ratatui architecture stated above moves state and validation out of the widget and into the console binary, which leaves the five differing in exactly one thing: 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 +
133 163 ## Focus and keymap model
134 164
135 165 The part ratatui does not provide. `alloy_tui` ships:
@@ -169,7 +199,7 @@
169 199
170 200 - Every Alloy TUI looks coherent because they all consume the same tokens and the same primitives.
171 201 - The non-reactive principle is enforced by exposing no reactive primitives.
172 - - 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 form fields resolve their disabled style from `text-muted`.
202 + - 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`.
173 203 - The theme file is the seam where design tooling could plug in.
174 204
175 205 ## Late-term: a design tool, and the token seam
M docs/CONSOLE.md +16 -3
@@ -82,6 +82,18 @@
82 82 - **Live validation.** Type errors, out-of-range values, missing required keys, and unknown keys all surface in the form as inline diagnostics rather than on save.
83 83 - **Documentation inline.** Schema entries carry a description string that shows in the form as help text next to the field.
84 84
85 + ### View architecture
86 +
87 + Settled with the crate/binary split the rest of the console already follows (see [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md)): `alloy_tui` renders dumb themed field widgets and form chrome, and the binary owns the schema parser, the `toml_edit` document, validation, presets, and save. The pieces:
88 +
89 + - **Values live in a `toml_edit::DocumentMut`**, loaded from the target file. That document is the single source of truth and the thing edits are written into in place, which is what makes the roundtrip-safe promise structural rather than remembered. Fields display their schema `default` when the file omits the key, but the key is not materialized on disk until the user edits it, so a minimal config stays minimal.
90 + - **Editing is modal per field.** `mode: Navigate | Editing { row, buffer, original }`. In Navigate the reserved keymap holds (Tab / j-k move, Enter activates, Space folds a section or flips a bool, Ctrl-S saves). Enter on a string, number, or color field opens Editing, where the field owns every key. This is the case the classifier already documents: a view holding an active text input must not treat `q`, `/`, or `:` as reserved. Esc discards the buffer, Enter validates and commits; an invalid value stays in edit with its diagnostic shown rather than being written. Enums do not free-type: Enter opens a pick overlay (the `AlloyModal` + `AlloyList` idiom) listing each value's label and description and committing the raw value. Bools toggle in place.
91 + - **Sections are collapsible.** One pane per schema `[[section]]`, folded with Space on its header, so the 25-slot colors group defaults to a single collapsed row rather than dominating the form. The binary flattens the visible rows each frame (section headers plus the fields of open sections); the `Cursor` rides that.
92 + - **Presets apply atomically.** A `[[preset]]` writes its whole `values` map into the document in one pass: one dirty increment, one undo entry. Undo is single-level in v1. Applying logs as `apply preset "..."`.
93 + - **Save and quit.** Ctrl-S serializes the document back to the target path, logged as `write <path>` (the same `Effect::Write` shape the `alloy pkg` export wrapper established). Quitting with unsaved edits confirms through `AlloyModal`, reusing the Cancel-that-is-not-Quit machinery `alloy pkg` forced into the shell.
94 + - **List-of-tables is read-only in v1.** `type = "list"` records (rio's `bindings.keys`) render through a display-only `AlloyTable`; add, remove, and cell-edit route to the text-edit fallback. Cheap to defer because rio ships bindings empty (Sway owns the global binds). Full table editing is v1.1.
95 + - **Fallback.** No schema, an unknown `schema_version`, or an unknown field type routes the whole file to the syntax-highlighted text-edit pane with a diagnostic explaining why. KDL configs take this route until the open roundtrip-safe-KDL question below resolves.
96 +
85 97 ## Schema strategy
86 98
87 99 Three options considered. Committing to option 2 with 3 as opportunistic input.
@@ -133,13 +145,14 @@
133 145 Contents (v1). Shipped:
134 146 - Themed `ratatui` widget wrappers: `AlloyBlock`, `AlloyList`, `AlloyStatusBar`, `AlloyLog`.
135 147 - `AlloyConnector`, which draws the link between two panes. Added for `alloy audio`'s stream-to-device pairing and not in the original roster.
148 + - `AlloyTabs` and `AlloyModal`, forced in by `alloy pkg`'s three-tab view and its first destructive-action confirm.
136 149 - `keys`: the reserved keymap (Tab, Shift-Tab, Enter, Esc, Ctrl-S, q, plus `?`, `/`, `:`) and the classifier apps match against.
137 150 - `FocusRing` for moving between panes, and `Cursor` for a selection over a list whose length changes underneath it. Two types rather than one: a focus ring wraps, because that is what Tab means, while a list cursor clamps and re-clamps when a refresh returns fewer rows.
138 151 - Footer chrome and the theme layer, ported from mountaineer-sysop's `sysop-tui` and retinted from a runtime theme rather than a const palette.
139 152
140 153 Still to come, with `alloy config`:
141 - - `AlloyForm`, `AlloyTable`.
142 - - Form-field widgets driven by the schema types above (`ColorField`, `EnumField`, `RangedNumberField`, etc.).
154 + - `AlloyForm` (the collapsible-section form chrome) and a display-only `AlloyTable`.
155 + - A single `AlloyField` widget carrying a `FieldKind` value-cell enum (Toggle / Text / Number / Enum / Color), not the five separate field widgets earlier drafts rostered. Those differed only in how the value cell paints once state and validation moved to the binary, and `AlloyForm`'s heterogeneous row list forces an enum regardless; the rationale is worked in full in [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md). `TextField` promotes from the console binary's `field.rs` as the caret buffer it uses in edit mode. Ships as `alloy_tui` 1.2.
143 156
144 157 Not in `alloy_tui`, contrary to earlier drafts: mock-or-real backend detection. It lives per view in the console binary, because what counts as "the real backend" is `nmcli` for one view and `pactl` for another. The pattern carries over from `sysop-tui`; the code does not.
145 158
@@ -157,7 +170,7 @@
157 170 The ordering below is the reverse of what this document originally planned, which put `alloy config` alone at v0.5 and every live-state subcommand at v1. The live-state views went first instead. They are small enough to carve one at a time, and each one forced a piece of shared machinery into existence against something real: the shell chrome and log pane from `net`, the second list and the `Cursor` from `audio`, the two-pane layout and `AlloyConnector` from `audio`'s routing, the background tick from watching streams appear. `alloy config` needs the form widgets and the schema parser at once, and it is a better shape to build on a shell that has already carried three screens.
158 171
159 172 - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, and all three `alloy pkg` tabs (`box`, plus `install` and `update` fronting `rpm-ostree status --json`). Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. `alloy pkg` forced three more pieces into it: `AlloyTabs`, a Cancel that views see before the shell claims it (a confirm needs a cancel that is not "exit the app"), and terminal suspend, so entering a box can hand the TTY to another interactive program.
160 - - **Next.** `alloy config`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together.
173 + - **Next.** `alloy config`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser, (2) `toml_edit` bind layer, both pure and testable against `rio.toml.schema`; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release; (4) the view wiring navigate and edit; (5) collapsible sections, presets, live diagnostics; (6) save, dirty tracking, quit-confirm, text-edit fallback; (7) `AlloyTable` read-only.
161 174 - **Blocked on the target machine.** `alloy display` fronts `swaymsg`/`wlr-randr`, neither of which exists on a non-sway development box, so its parser cannot be checked against real output here — the way the two box-parser bugs got written. The rpm-ostree tabs were in this category until their output was captured from a booted Alloy install in QEMU (2026-07-22); the `install` and `system` parsers are written against that real capture and re-checkable with `parses_this_machines_real_status` on any ostree box. `alloy display` still wants the image or real hardware.
162 175 - **Then.** `alloy sync`, `alloy theme`. `alloy theme` swaps the runtime theme in place (makeover consumer, no re-login). First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy mesh` and `alloy sync` enrollments.
163 176 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)