Skip to main content

max / alloy

settings: the tab chrome and the Applications tab `alloy settings` opens on a two-pane Applications tab: apps on the left, the form for the selected one on the right, joined by the connector when the form has focus. Navigation and folding only. Editing a field is the next step, and the System tab the one after; the System tab says so on screen rather than rendering an empty pane, because a tab that looks finished and does nothing is worse than one that says what it is waiting for. Schemas needed somewhere to say what file they describe. `target` was a filename and the real path lived in a comment, so the header gains `target_path`, carrying `~` and `$XDG_CONFIG_HOME` unexpanded: parsing a schema should not depend on who is running it. It is optional, since `alloy config <path>` names its file, and a schema without one describes a shape rather than a location and cannot be listed as an app. A schema that fails to parse still appears in the list, under its file name, carrying its diagnostic. Dropping it would leave someone looking for rio's config staring at a list that does not mention rio and does not say why. Sections above eight fields open folded. The number exists for rio's colors: 29 slots would otherwise be two thirds of the form, and someone who came to change the font would scroll past every palette entry to reach it. A list field is summarised by its record count for now. AlloyTable is the real answer and it is more rows than a one-line form row has.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 18:35 UTC
Signed with PGP, not checked
Commit: 337ee46744bd558c13a02f68b1357156fe98cd48
Parent: 625083d
7 files changed, +531 insertions, -3 deletions
M docs/CONSOLE.md +3 -2
@@ -123,11 +123,12 @@
123 123
124 124 Schema-format v1, worked in full at [`schemas/rio.toml.schema`](../schemas/rio.toml.schema). The design pass against rio's real config surface shaked out five affordances beyond a naive field list: sections, groups, presets, format hints, and an unknown-key policy.
125 125
126 - **Header.** Every schema declares its target file, the tool it targets, a target-version semver range, the schema-DSL version, and an unknown-key policy. Unknown keys default to `preserve` so that a rio release adding a new field never bricks the editor: the field survives the edit round-trip and surfaces as an info diagnostic.
126 + **Header.** Every schema declares its target file, where that file lives, the tool it targets, a target-version semver range, the schema-DSL version, and an unknown-key policy. `target_path` carries `~` and `$XDG_CONFIG_HOME` unexpanded, so parsing a schema does not depend on the environment it is parsed in; whoever opens the file resolves them. It is optional, because `alloy config <path>` names the file itself, but a schema without one describes a shape and not a location and so cannot be listed as an app. Unknown keys default to `preserve` so that a rio release adding a new field never bricks the editor: the field survives the edit round-trip and surfaces as an info diagnostic.
127 127
128 128 ```toml
129 129 [schema]
130 130 target = "rio.toml"
131 + target_path = "$XDG_CONFIG_HOME/rio/config.toml"
131 132 target_tool = "rio"
132 133 target_version = ">=0.2"
133 134 schema_version = "1"
@@ -185,7 +186,7 @@
185 186 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 settings` 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.
186 187
187 188 - **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.
188 - - **In progress.** `alloy settings`, 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 and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release; (4) tab chrome and the Applications tab; (5) the filterable pick overlay; (6) the System tab, time rows first, which is the smallest complete slice of the command side: one front, one enum, one bool, one display-only row; (7) the remaining System rows; (8) collapsible sections, presets, live diagnostics; (9) save, dirty tracking, quit-confirm, text-edit fallback; (10) `AlloyTable` read-only.
189 + - **In progress.** `alloy settings`, 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 and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped, navigation only**; (5) per-field edit, then the filterable pick overlay; (6) the System tab, time rows first, which is the smallest complete slice of the command side: one front, one enum, one bool, one display-only row; (7) the remaining System rows; (8) presets and live diagnostics, the collapsible sections having landed with the tab; (9) save, dirty tracking, quit-confirm, text-edit fallback; (10) `AlloyTable` read-only.
189 190 - **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.
190 191 - **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.
191 192 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
@@ -10,6 +10,7 @@
10 10
11 11 [schema]
12 12 target = "rio.toml"
13 + target_path = "$XDG_CONFIG_HOME/rio/config.toml"
13 14 target_tool = "rio"
14 15 target_version = ">=0.2"
15 16 schema_version = "1"
@@ -149,6 +149,11 @@
149 149 Ok(bind)
150 150 }
151 151
152 + /// The file this form is bound to.
153 + pub(crate) fn path(&self) -> &Path {
154 + &self.path
155 + }
156 +
152 157 /// The file as it would be written right now.
153 158 pub(crate) fn text(&self) -> String {
154 159 self.document.to_string()
@@ -487,7 +487,7 @@
487 487 /// Only for display. The log pane is narrow and every path this writes lives
488 488 /// under the home directory, so spelling it out costs a fifth of the line to
489 489 /// say something the reader already knows.
490 - fn contract_home(path: &Path) -> String {
490 + pub(crate) fn contract_home(path: &Path) -> String {
491 491 let shown = path.display().to_string();
492 492 let Some(home) = std::env::var_os("HOME") else {
493 493 return shown;
@@ -15,6 +15,7 @@
15 15 mod pkg;
16 16 mod run;
17 17 mod schema;
18 + mod settings;
18 19 mod shell;
19 20 mod theme;
20 21 mod wizard;
@@ -62,6 +63,8 @@
62 63 Update,
63 64 /// Install Alloy to a disk
64 65 Install,
66 + /// System settings and application configs
67 + Settings,
65 68 }
66 69
67 70 /// Which surface of the package view to open on.
@@ -109,6 +112,10 @@
109 112 let mut view = pkg::PkgView::new(pkg::Tab::System, &mut log);
110 113 shell::run(&theme, &mut view, &mut log)
111 114 }
115 + Command::Settings => {
116 + let mut view = settings::SettingsView::new(settings::Tab::Applications);
117 + shell::run(&theme, &mut view, &mut log)
118 + }
112 119 Command::Install => {
113 120 let mut view = install::InstallView::new(&mut log);
114 121 shell::run(&theme, &mut view, &mut log)
@@ -61,6 +61,14 @@
61 61 /// written: matching it against the installed tool needs a version to
62 62 /// compare with, which is the view's business and not the parser's.
63 63 pub(crate) target_version: Option<String>,
64 + /// Where the target file lives, unexpanded: `~` and `$XDG_CONFIG_HOME` are
65 + /// resolved by whoever opens it, not here, so parsing a schema does not
66 + /// depend on the environment it is parsed in.
67 + ///
68 + /// Optional because `alloy config <path>` names the file itself. A schema
69 + /// without one describes a shape and not a location, so it cannot be
70 + /// listed as an app.
71 + pub(crate) target_path: Option<String>,
64 72 pub(crate) unknown_keys: UnknownKeys,
65 73 }
66 74
@@ -242,6 +250,7 @@
242 250 target: raw.schema.target,
243 251 target_tool: raw.schema.target_tool,
244 252 target_version: raw.schema.target_version,
253 + target_path: raw.schema.target_path,
245 254 unknown_keys,
246 255 };
247 256
@@ -352,6 +361,7 @@
352 361 target: String,
353 362 target_tool: String,
354 363 target_version: Option<String>,
364 + target_path: Option<String>,
355 365 schema_version: String,
356 366 unknown_keys: Option<String>,
357 367 }
@@ -863,6 +873,10 @@
863 873 let schema = rio();
864 874 assert_eq!(schema.header.target_tool, "rio");
865 875 assert_eq!(schema.header.target_version.as_deref(), Some(">=0.2"));
876 + assert_eq!(
877 + schema.header.target_path.as_deref(),
878 + Some("$XDG_CONFIG_HOME/rio/config.toml"),
879 + );
866 880 assert_eq!(schema.header.unknown_keys, UnknownKeys::Preserve);
867 881 assert_eq!(schema.sections.len(), 7);
868 882 assert_eq!(schema.presets.len(), 1);
@@ -1,0 +1,965 @@
1 + //! `alloy settings`: the two-tab settings screen.
2 + //!
3 + //! [[alloy-settings]] and docs/CONSOLE.md: one view, two tabs, and the same
4 + //! form under both. **System** holds live state read through command fronts;
5 + //! **Applications** holds the adopted stack's config files, grouped behind the
6 + //! app each one configures rather than presented as paths. What differs between
7 + //! them is only the [`Bind`](crate::bind::Bind) under the form.
8 + //!
9 + //! This module carries the tab chrome and the Applications tab. The System tab
10 + //! is the next step of the build order; it says so on screen rather than
11 + //! rendering an empty pane, because a tab that looks finished and does nothing
12 + //! is worse than one that says what it is waiting for.
13 + //!
14 + //! ## What a schema catalog is
15 + //!
16 + //! One `.schema` file per adopted config, found on a search path the same shape
17 + //! as [`theme`](crate::theme)'s: the user's own first, then what the image
18 + //! ships, then the in-repo checkout when running from a dev tree. A file that
19 + //! fails to parse still appears in the list carrying its diagnostic. Dropping
20 + //! it would leave a user looking for rio's config staring at a list that simply
21 + //! does not mention rio, with nothing to explain why.
22 + //!
23 + //! <!-- wiki: alloy-settings -->
24 +
25 + use std::collections::BTreeSet;
26 + use std::path::{Path, PathBuf};
27 +
28 + use alloy_tui::keys::{Action, classify};
29 + use alloy_tui::{
30 + AlloyBlock, AlloyConnector, AlloyField, AlloyForm, AlloyList, AlloyTabs, Cursor, FieldKind,
31 + FocusRing, FormRow, Hint, Severity, Theme, hint, layout, list_row_y, text,
32 + };
33 + use ratatui::Frame;
34 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
35 + use ratatui::layout::{Constraint, Layout, Rect};
36 + use ratatui::text::{Line, Span};
37 + use ratatui::widgets::Paragraph;
38 + use toml::Value;
39 +
40 + use crate::bind::{Bind, FileBind};
41 + use crate::cli::{CommandLog, contract_home};
42 + use crate::schema::{self, Field, Schema, Section};
43 + use crate::shell::{Flow, View};
44 +
45 + /// Extension every schema file carries.
46 + const SCHEMA_EXT: &str = "schema";
47 +
48 + /// A section with more fields than this starts folded.
49 + ///
50 + /// The number exists for one section in particular: rio's 29-slot colors group
51 + /// would otherwise be two thirds of the form, and someone who came to change
52 + /// the font would scroll past every palette entry to reach it. Above the
53 + /// threshold the section is a single row that says what is inside; below it,
54 + /// folding a five-field section would hide it for no gain.
55 + const FOLD_ABOVE: usize = 8;
56 +
57 + // ---------------------------------------------------------------------------
58 + // The catalog
59 + // ---------------------------------------------------------------------------
60 +
61 + /// Where schema files are looked for, highest precedence first.
62 + fn search_path() -> Vec<PathBuf> {
63 + let mut dirs = Vec::new();
64 + if let Some(config) = config_home() {
65 + dirs.push(config.join("alloy").join("schemas"));
66 + }
67 + dirs.push(PathBuf::from("/usr/share/alloy/schemas"));
68 + // Build-from-source fallback, so `cargo run` in a fresh clone opens the
69 + // schemas the repo ships rather than an empty list.
70 + dirs.push(PathBuf::from(concat!(
71 + env!("CARGO_MANIFEST_DIR"),
72 + "/../../schemas"
73 + )));
74 + dirs
75 + }
76 +
77 + /// `$XDG_CONFIG_HOME`, or `~/.config`.
78 + ///
79 + /// The spec says a relative value is invalid and must be ignored rather than
80 + /// resolved against the working directory, which is why the absolute check is
81 + /// here. Same rule [`theme`](crate::theme) applies.
82 + fn config_home() -> Option<PathBuf> {
83 + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
84 + let path = PathBuf::from(xdg);
85 + if path.is_absolute() {
86 + return Some(path);
87 + }
88 + }
89 + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
90 + }
91 +
92 + fn home() -> Option<PathBuf> {
93 + std::env::var_os("HOME").map(PathBuf::from)
94 + }
95 +
96 + /// Resolve a schema's `target_path` against the environment.
97 + ///
98 + /// Two substitutions, both at the front: `~` for the home directory and
99 + /// `$XDG_CONFIG_HOME` for the config home. Schemas carry these unexpanded so
100 + /// that parsing one does not depend on who is running it, and a schema written
101 + /// on a machine with a custom `XDG_CONFIG_HOME` still describes the right file
102 + /// on one without.
103 + pub(crate) fn expand(target: &str) -> Option<PathBuf> {
104 + expand_in(target, config_home().as_deref(), home().as_deref())
105 + }
106 +
107 + /// [`expand`] against explicit directories.
108 + ///
109 + /// Split out so the substitution rules are testable without setting process
110 + /// environment: `set_var` is unsafe in a threaded test binary for good reason,
111 + /// and a test that changes `HOME` under the other tests is a flake waiting for
112 + /// a slow machine.
113 + fn expand_in(target: &str, config: Option<&Path>, home: Option<&Path>) -> Option<PathBuf> {
114 + if let Some(rest) = target.strip_prefix("$XDG_CONFIG_HOME/") {
115 + return Some(config?.join(rest));
116 + }
117 + if let Some(rest) = target.strip_prefix("~/") {
118 + return Some(home?.join(rest));
119 + }
120 + // Anything else must name itself. A relative path would resolve against
121 + // whatever directory the console happened to be started in.
122 + let path = PathBuf::from(target);
123 + path.is_absolute().then_some(path)
124 + }
125 +
126 + /// One entry in the Applications list: an app, and the form behind it.
127 + struct App {
128 + /// What the list shows. The tool, not the path: someone looking for rio's
129 + /// config is looking for rio.
130 + name: String,
131 + /// The `.schema` file this came from, named in the diagnostic when it is
132 + /// the thing that is broken.
133 + source: PathBuf,
134 + state: Result<Form, String>,
135 + }
136 +
137 + /// An opened app's form: the bind, and where the user is in it.
138 + ///
139 + /// Fold state and cursor live here rather than on the view so that leaving an
140 + /// app and coming back lands where you left, which matters most for the one
141 + /// app whose form is long enough to scroll.
142 + struct Form {
143 + bind: FileBind,
144 + /// Section paths that are currently collapsed.
145 + folded: BTreeSet<String>,
146 + cursor: Cursor,
147 + }
148 +
149 + /// One line of a rendered form.
150 + enum Row<'a> {
151 + Section(&'a Section),
152 + Field(&'a Field),
153 + }
154 +
155 + impl App {
156 + /// Read every schema on the search path, nearest first.
157 + ///
158 + /// A directory that does not exist is skipped rather than reported: only
159 + /// one of the three is expected to exist on any given machine.
160 + fn catalog() -> Vec<Self> {
161 + let mut apps: Vec<Self> = Vec::new();
162 + for dir in search_path() {
163 + let Ok(entries) = std::fs::read_dir(&dir) else {
164 + continue;
165 + };
166 + let mut found: Vec<PathBuf> = entries
167 + .filter_map(Result::ok)
168 + .map(|entry| entry.path())
169 + .filter(|path| path.extension().is_some_and(|ext| ext == SCHEMA_EXT))
170 + .collect();
171 + // read_dir order is the filesystem's, which is not stable between
172 + // machines. The list a user sees should not be.
173 + found.sort();
174 + for path in found {
175 + let app = Self::open(&path);
176 + // Nearest wins: a user's own schema for a tool replaces the
177 + // one the image ships rather than listing the tool twice.
178 + if !apps.iter().any(|existing| existing.name == app.name) {
179 + apps.push(app);
180 + }
181 + }
182 + }
183 + apps
184 + }
185 +
186 + fn open(source: &Path) -> Self {
187 + // Falls back to the file's own name so a schema too broken to name its
188 + // tool still appears as something the user can recognise.
189 + let fallback = source.file_stem().map_or_else(
190 + || source.display().to_string(),
191 + |stem| stem.to_string_lossy().to_string(),
192 + );
193 +
194 + let schema = match Schema::load(source) {
195 + Ok(schema) => schema,
196 + Err(error) => {
197 + return Self {
198 + name: fallback,
199 + source: source.to_path_buf(),
200 + state: Err(format!("{error:#}")),
201 + };
202 + }
203 + };
204 +
205 + let name = schema.header.target_tool.clone();
206 + let state = match schema.header.target_path.as_deref() {
207 + None => Err("the schema does not say where the file lives".to_string()),
208 + Some(target) => match expand(target) {
209 + None => Err(format!("cannot resolve `{target}`")),
210 + Some(path) => FileBind::open(schema, &path).map_or_else(
211 + |error| Err(format!("{error:#}")),
212 + |bind| Ok(Form::new(bind)),
213 + ),
214 + },
215 + };
216 + Self {
217 + name,
218 + source: source.to_path_buf(),
219 + state,
220 + }
221 + }
222 + }
223 +
224 + impl Form {
225 + fn new(bind: FileBind) -> Self {
226 + // Big sections start folded. Computed once, at open: a fold state that
227 + // recomputed itself would spring back open the moment a user closed a
228 + // small section.
229 + let folded = bind
230 + .sections()
231 + .iter()
232 + .filter(|section| {
233 + bind.fields()
234 + .iter()
235 + .filter(|field| in_section(&field.path, &section.path))
236 + .count()
237 + > FOLD_ABOVE
238 + })
239 + .map(|section| section.path.clone())
240 + .collect();
241 +
242 + let mut form = Self {
243 + bind,
244 + folded,
245 + cursor: Cursor::new(),
246 + };
247 + form.cursor.resize(form.rows().len());
248 + form
249 + }
250 +
251 + /// The visible rows: every section header, plus the fields of the open
252 + /// ones, plus any field no section claims.
253 + ///
254 + /// Rebuilt each frame rather than cached. It is a walk over a few dozen
255 + /// fields, and a cached copy is a second source of truth that has to be
256 + /// invalidated on every fold.
257 + fn rows(&self) -> Vec<Row<'_>> {
258 + let mut rows = Vec::new();
259 + for section in self.bind.sections() {
260 + rows.push(Row::Section(section));
261 + if self.folded.contains(&section.path) {
262 + continue;
263 + }
264 + rows.extend(
265 + self.bind
266 + .fields()
267 + .iter()
268 + .filter(|field| in_section(&field.path, &section.path))
269 + .map(Row::Field),
270 + );
271 + }
272 + // Fields outside every section go last, under no header. A schema that
273 + // declares no sections at all is the same case, and renders as a plain
274 + // list of fields.
275 + rows.extend(
276 + self.bind
277 + .fields()
278 + .iter()
279 + .filter(|field| {
280 + !self
281 + .bind
282 + .sections()
283 + .iter()
284 + .any(|section| in_section(&field.path, &section.path))
285 + })
286 + .map(Row::Field),
287 + );
288 + rows
289 + }
290 +
291 + /// Fold or unfold the section under the cursor. Returns whether it did.
292 + fn toggle_fold(&mut self) -> bool {
293 + let Some(index) = self.cursor.selected() else {
294 + return false;
295 + };
296 + let rows = self.rows();
297 + let Some(Row::Section(section)) = rows.get(index) else {
298 + return false;
299 + };
300 + let path = section.path.clone();
301 + drop(rows);
302 + if !self.folded.remove(&path) {
303 + self.folded.insert(path);
304 + }
305 + // Folding changes how many rows there are, and the cursor is riding
306 + // that list. Resizing after rather than before keeps the header the
307 + // user just folded under the cursor.
308 + let len = self.rows().len();
309 + self.cursor.resize(len);
310 + true
311 + }
312 + }
313 +
314 + /// Whether a field path falls inside a section, on whole segments.
315 + ///
316 + /// The same rule [`Schema::section_of`](crate::schema::Schema::section_of)
317 + /// applies, and it is applied here rather than called because this asks about
318 + /// one section rather than searching for the best one.
319 + fn in_section(path: &str, section: &str) -> bool {
320 + path.strip_prefix(section)
321 + .is_some_and(|rest| rest.starts_with('.'))
322 + }
323 +
324 + /// A row's value, formatted for display, and whether it came from the schema
325 + /// rather than the file.
326 + struct Cell {
327 + text: String,
328 + unset: bool,
329 + toggle: Option<bool>,
330 + color: bool,
331 + }
332 +
333 + /// Format a field's current value.
334 + fn cell(bind: &FileBind, field: &Field) -> Cell {
335 + let read = bind.read(&field.path);
336 + let unset = read.is_none();
337 + let value = read.or_else(|| field.default_value());
338 +
339 + let color = matches!(field.kind, schema::FieldKind::Color { .. });
340 + let toggle = match &value {
341 + Some(Value::Boolean(flag)) => Some(*flag),
342 + _ => None,
343 + };
344 +
345 + let text = match (&value, &field.kind) {
346 + // A v1 list renders read-only, and an AlloyTable is more rows than a
347 + // form row has. Summarising is what fits until the table lands; the
348 + // count is the part a user scanning the form wants.
349 + (Some(Value::Array(rows)), _) => match rows.len() {
350 + 1 => "1 entry".to_string(),
351 + n => format!("{n} entries"),
352 + },
353 + (_, schema::FieldKind::List { .. }) => "none".to_string(),
354 + // An enum shows its label, which is why the schema carries one: rio's
355 + // "Disabled" is a value, "No decorations" is what it means.
356 + (Some(Value::String(raw)), schema::FieldKind::Enum { values, .. }) => values
357 + .iter()
358 + .find(|choice| &choice.value == raw)
359 + .map_or_else(|| raw.clone(), |choice| choice.label.clone()),
360 + (Some(Value::String(text)), _) => text.clone(),
361 + (Some(Value::Integer(number)), _) => number.to_string(),
362 + (Some(Value::Float(number)), _) => number.to_string(),
363 + (Some(Value::Boolean(_)), _) => String::new(),
364 + (Some(other), _) => other.to_string(),
365 + // Neither the file nor the schema has anything to say. Not the same as
366 + // an empty string, which is a value someone chose.
367 + (None, _) => "unset".to_string(),
368 + };
369 +
370 + Cell {
371 + text,
372 + unset,
373 + toggle,
374 + color,
375 + }
376 + }
377 +
378 + // ---------------------------------------------------------------------------
379 + // The view
380 + // ---------------------------------------------------------------------------
381 +
382 + /// The two tabs, in bar order. System first, because it is the one a user goes
383 + /// looking for.
384 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
385 + pub(crate) enum Tab {
386 + System,
387 + Applications,
388 + }
389 +
390 + impl Tab {
391 + const ALL: [Tab; 2] = [Tab::System, Tab::Applications];
392 +
393 + const fn label(self) -> &'static str {
394 + match self {
395 + Tab::System => "system",
396 + Tab::Applications => "applications",
397 + }
398 + }
399 +
400 + const fn slot(self) -> usize {
401 + match self {
402 + Tab::System => 0,
403 + Tab::Applications => 1,
404 + }
405 + }
406 +
407 + const fn from_slot(slot: usize) -> Self {
408 + match slot {
409 + 0 => Tab::System,
410 + _ => Tab::Applications,
411 + }
412 + }
413 + }
414 +
415 + /// Which pane of the Applications tab has focus.
416 + const PANE_APPS: usize = 0;
417 + const PANE_FORM: usize = 1;
418 +
419 + /// What the System tab says until it exists.
420 + const SYSTEM_PENDING: &str =
421 + "time, hostname, locale and theme land here. Not built yet: see docs/CONSOLE.md.";
422 +
423 + /// The `alloy settings` screen.
424 + pub(crate) struct SettingsView {
425 + tabs: FocusRing,
426 + panes: FocusRing,
427 + apps: Vec<App>,
428 + cursor: Cursor,
429 + }
430 +
431 + impl SettingsView {
432 + pub(crate) fn new(tab: Tab) -> Self {
433 + let apps = App::catalog();
434 + let mut cursor = Cursor::new();
435 + cursor.resize(apps.len());
436 +
437 + let mut tabs = FocusRing::new(Tab::ALL.len());
438 + tabs.focus(tab.slot());
439 +
440 + Self {
441 + tabs,
442 + panes: FocusRing::new(2),
443 + apps,
444 + cursor,
445 + }
446 + }
447 +
448 + fn tab(&self) -> Tab {
449 + Tab::from_slot(self.tabs.current())
450 + }
451 +
452 + fn app(&self) -> Option<&App> {
453 + self.apps.get(self.cursor.selected()?)
454 + }
455 +
456 + fn form_mut(&mut self) -> Option<&mut Form> {
457 + let index = self.cursor.selected()?;
458 + self.apps.get_mut(index)?.state.as_mut().ok()
459 + }
460 +
461 + fn render_system(frame: &mut Frame, area: Rect, theme: &Theme) {
462 + frame.render_widget(
463 + Paragraph::new(Line::from(text::muted(theme, SYSTEM_PENDING))),
464 + area,
465 + );
466 + }
467 +
468 + fn render_applications(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
469 + if self.apps.is_empty() {
470 + frame.render_widget(
471 + Paragraph::new(Line::from(text::muted(
472 + theme,
473 + format!(
474 + "no schemas found (searched {})",
475 + search_path()
476 + .iter()
477 + .map(|dir| contract_home(dir))
478 + .collect::<Vec<_>>()
479 + .join(", ")
480 + ),
481 + ))),
482 + area,
483 + );
484 + return;
485 + }
486 +
487 + let panes = layout::panes(area);
488 +
489 + let apps_block = AlloyBlock::new(theme)
490 + .focused(self.panes.is_focused(PANE_APPS))
491 + .build()
492 + .title(" apps ");
493 + let apps_inner = apps_block.inner(panes.left);
494 + frame.render_widget(apps_block, panes.left);
495 + frame.render_widget(
496 + AlloyList::new(
497 + theme,
498 + self.apps.iter().map(|app| match &app.state {
499 + Ok(_) => Line::from(text::primary(theme, app.name.clone())),
500 + // A broken schema is still its app in the list, marked, so
Lines truncated