Skip to main content

max / alloy

settings: edit a field, and write the file Enter opens the row under the cursor as a caret buffer, Esc discards it, Enter validates and commits. A value that does not pass stays in edit with the reason under it: the text the user typed is the thing they need in front of them to fix it, and dropping it to show an error would take away the evidence. Typing clears the diagnostic, since typing is the answer to it. Space folds a section header and flips a bool, one key with two meanings because the row says which. Enums do not free-type and their pick overlay is the next step, so Enter on one does nothing yet rather than opening a field over a closed vocabulary. Save and the quit-confirm come with this rather than later. An editor whose edits live only in memory, with q closing the console without a word, loses work; the bind already returned the write as an effect and the shell already had the confirm, so the gap was only in the wiring. Parsing text into a value happens here and range and vocabulary stay with the bind. Two places that both know what a valid value is are two places that can disagree.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 18:41 UTC
Signed with PGP, not checked
Commit: cf583264c94bdbc0899cd40abdde1812b7cfaef0
Parent: 6dc4945
2 files changed, +399 insertions, -27 deletions
M docs/CONSOLE.md +1 -1
@@ -186,7 +186,7 @@
186 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.
187 187
188 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.
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 + - **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**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, which is what an enum row is still waiting on; (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) the text-edit fallback, save and dirty tracking having landed with the edit; (10) `AlloyTable` read-only.
190 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.
191 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.
192 192 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
@@ -28,7 +28,7 @@
28 28 use alloy_tui::keys::{Action, classify};
29 29 use alloy_tui::{
30 30 AlloyBlock, AlloyConnector, AlloyField, AlloyForm, AlloyList, AlloyTabs, Cursor, FieldKind,
31 - FocusRing, FormRow, Hint, Severity, Theme, hint, layout, list_row_y, text,
31 + FocusRing, FormRow, Hint, Severity, TextField, Theme, hint, layout, list_row_y, text,
32 32 };
33 33 use ratatui::Frame;
34 34 use ratatui::crossterm::event::{KeyCode, KeyEvent};
@@ -40,7 +40,7 @@
40 40 use crate::bind::{Bind, FileBind};
41 41 use crate::cli::{CommandLog, contract_home};
42 42 use crate::schema::{self, Field, Schema, Section};
43 - use crate::shell::{Flow, View};
43 + use crate::shell::{Confirm, Flow, View};
44 44
45 45 /// Extension every schema file carries.
46 46 const SCHEMA_EXT: &str = "schema";
@@ -144,6 +144,25 @@
144 144 /// Section paths that are currently collapsed.
145 145 folded: BTreeSet<String>,
146 146 cursor: Cursor,
147 + mode: Mode,
148 + }
149 +
150 + /// What the form is doing with the row under the cursor.
151 + ///
152 + /// Modal rather than always-editable, per docs/CONSOLE.md. In `Navigate` the
153 + /// reserved keymap holds; in `Editing` the field owns every key, which is the
154 + /// obligation `classify` documents for a view with an active text input.
155 + enum Mode {
156 + Navigate,
157 + Editing {
158 + /// The row being edited. Held rather than read back off the cursor so
159 + /// that nothing which moves the cursor can move the edit with it.
160 + row: usize,
161 + buffer: TextField,
162 + /// Why the last Enter did not commit. The row stays in edit showing
163 + /// this rather than dropping the user's text on the floor.
164 + error: Option<String>,
165 + },
147 166 }
148 167
149 168 /// One line of a rendered form.
@@ -243,6 +262,7 @@
243 262 bind,
244 263 folded,
245 264 cursor: Cursor::new(),
265 + mode: Mode::Navigate,
246 266 };
247 267 form.cursor.resize(form.rows().len());
248 268 form
@@ -309,6 +329,220 @@
309 329 self.cursor.resize(len);
310 330 true
311 331 }
332 +
333 + fn editing(&self) -> bool {
334 + matches!(self.mode, Mode::Editing { .. })
335 + }
336 +
337 + /// The field under the cursor, if the cursor is on one.
338 + fn selected_field(&self) -> Option<&Field> {
339 + match self.rows().get(self.cursor.selected()?)? {
340 + Row::Field(field) => Some(*field),
341 + Row::Section(_) => None,
342 + }
343 + }
344 +
345 + /// Open the row under the cursor for editing.
346 + ///
347 + /// Bools are not edited: they flip, which is what [`toggle_bool`] is for.
348 + /// Enums do not free-type either, and their pick overlay is the next step
349 + /// of the build order, so Enter on one does nothing yet rather than
350 + /// dropping the user into a text field over a closed vocabulary.
351 + ///
352 + /// [`toggle_bool`]: Form::toggle_bool
353 + fn begin_edit(&mut self) -> bool {
354 + let Some(row) = self.cursor.selected() else {
355 + return false;
356 + };
357 + let Some(field) = self.selected_field() else {
358 + return false;
359 + };
360 + if matches!(
361 + field.kind,
362 + schema::FieldKind::Bool { .. } | schema::FieldKind::Enum { .. }
363 + ) {
364 + return false;
365 + }
366 +
367 + // Seeded with what the row shows, which for an unset field is the
368 + // schema's default. Editing a default rather than retyping it is the
369 + // point of `TextField::set`, and committing one is an explicit Enter,
370 + // so a default only reaches disk because the user put it there.
371 + let mut buffer = TextField::new();
372 + buffer.set(raw(&self.bind, field));
373 + self.mode = Mode::Editing {
374 + row,
375 + buffer,
376 + error: None,
377 + };
378 + true
379 + }
380 +
381 + /// Flip the bool under the cursor and commit it.
382 + fn toggle_bool(&mut self, log: &mut CommandLog) -> bool {
383 + let Some(field) = self.selected_field() else {
384 + return false;
385 + };
386 + let schema::FieldKind::Bool { .. } = field.kind else {
387 + return false;
388 + };
389 + let path = field.path.clone();
390 + // An unset bool reads as its default, so the first press flips away
391 + // from what the row shows rather than from `false`.
392 + let now = matches!(
393 + self.bind.read(&path).or_else(|| field.default_value()),
394 + Some(Value::Boolean(true)),
395 + );
396 + self.commit(&path, Value::Boolean(!now), log);
397 + true
398 + }
399 +
400 + /// Validate the buffer and write it, or stay in edit and say why.
401 + fn commit_edit(&mut self, log: &mut CommandLog) {
402 + let Mode::Editing { row, buffer, .. } = &self.mode else {
403 + return;
404 + };
405 + let (row, text) = (*row, buffer.value().to_string());
406 +
407 + let rows = self.rows();
408 + let Some(Row::Field(field)) = rows.get(row) else {
409 + // The row went away under the edit, which nothing currently does.
410 + // Dropping back to Navigate is the only honest answer: there is no
411 + // field left to write to.
412 + drop(rows);
413 + self.mode = Mode::Navigate;
414 + return;
415 + };
416 + let path = field.path.clone();
417 + let parsed = parse(&text, &field.kind);
418 + drop(rows);
419 +
420 + let value = match parsed {
421 + Ok(value) => value,
422 + Err(reason) => {
423 + self.fail_edit(reason);
424 + return;
425 + }
426 + };
427 + if let Err(error) = self.bind.validate(&path, &value) {
428 + self.fail_edit(format!("{error:#}"));
429 + return;
430 + }
431 + self.commit(&path, value, log);
432 + self.mode = Mode::Navigate;
433 + }
434 +
435 + /// Apply a value and run whatever the bind asks for.
436 + ///
437 + /// A file bind asks for nothing here and writes at save; a command front
438 + /// will hand back its setter. Either way the effects go through the log,
439 + /// so what the console did is visible without the view knowing which kind
440 + /// of bind it is holding.
441 + fn commit(&mut self, path: &str, value: Value, log: &mut CommandLog) {
442 + match self.bind.commit(path, value) {
443 + Ok(effects) => {
444 + for effect in effects {
445 + if let Err(error) = effect.apply(log) {
446 + self.fail_edit(format!("{error:#}"));
447 + return;
448 + }
449 + }
450 + }
451 + Err(error) => self.fail_edit(format!("{error:#}")),
452 + }
453 + }
454 +
455 + /// Keep the row in edit, carrying the reason it did not commit.
456 + ///
457 + /// A rejected value is not discarded: the text the user typed is the thing
458 + /// they need in front of them to fix it.
459 + fn fail_edit(&mut self, reason: String) {
460 + if let Mode::Editing { error, .. } = &mut self.mode {
461 + *error = Some(reason);
462 + }
463 + }
464 +
465 + fn cancel_edit(&mut self) {
466 + self.mode = Mode::Navigate;
467 + }
468 +
469 + /// Route a key into the open buffer.
470 + ///
471 + /// Anything not a character or a caret movement is inert rather than
472 + /// escaping to the view. Tab means focus everywhere else in the console,
473 + /// and a field that let it through would move the user off the row they
474 + /// are halfway through typing.
475 + fn type_key(&mut self, key: KeyEvent) {
476 + let Mode::Editing { buffer, error, .. } = &mut self.mode else {
477 + return;
478 + };
479 + match key.code {
480 + KeyCode::Char(c) => buffer.insert(c),
481 + KeyCode::Backspace => buffer.backspace(),
482 + KeyCode::Delete => buffer.delete(),
483 + KeyCode::Left => buffer.left(),
484 + KeyCode::Right => buffer.right(),
485 + KeyCode::Home => buffer.home(),
486 + KeyCode::End => buffer.end(),
487 + _ => return,
488 + }
489 + // Typing is the user answering the diagnostic, so it goes as soon as
490 + // they start. Leaving it up would have a stale complaint sitting under
491 + // text it no longer describes.
492 + *error = None;
493 + }
494 +
495 + /// Write the file. The bind hands back the effect; the log records it.
496 + fn save(&mut self, log: &mut CommandLog) -> Result<(), String> {
497 + let effects = self.bind.save().map_err(|error| format!("{error:#}"))?;
498 + for effect in effects {
499 + effect.apply(log).map_err(|error| format!("{error:#}"))?;
500 + }
501 + Ok(())
502 + }
503 + }
504 +
505 + /// A field's current value as the text an edit starts from.
506 + ///
507 + /// The raw value, not the rendered one: an enum's label and a list's summary
508 + /// are for reading, and seeding an edit with either would have the user typing
509 + /// over something that was never in the file.
510 + fn raw(bind: &FileBind, field: &Field) -> String {
511 + match bind.read(&field.path).or_else(|| field.default_value()) {
512 + Some(Value::String(text)) => text,
513 + Some(Value::Integer(number)) => number.to_string(),
514 + Some(Value::Float(number)) => number.to_string(),
515 + Some(Value::Boolean(flag)) => flag.to_string(),
516 + Some(other) => other.to_string(),
517 + None => String::new(),
518 + }
519 + }
520 +
521 + /// Turn edited text into a value of the field's own type.
522 + ///
523 + /// The error is what the user reads, so it names what was expected rather than
524 + /// echoing a parser's wording. Range and vocabulary are not checked here: those
525 + /// are the bind's, and checking them twice is two places to disagree.
526 + fn parse(text: &str, kind: &schema::FieldKind) -> Result<Value, String> {
527 + let text = text.trim();
528 + match kind {
529 + schema::FieldKind::Int { .. } => text
530 + .parse::<i64>()
531 + .map(Value::Integer)
532 + .map_err(|_| format!("`{text}` is not a whole number")),
533 + schema::FieldKind::Float { .. } => text
534 + .parse::<f64>()
535 + .map(Value::Float)
536 + .map_err(|_| format!("`{text}` is not a number")),
537 + schema::FieldKind::Bool { .. } => match text {
538 + "true" => Ok(Value::Boolean(true)),
539 + "false" => Ok(Value::Boolean(false)),
540 + _ => Err(format!("`{text}` is not true or false")),
541 + },
542 + // Everything else is a string on the way in. Colors and paths have
543 + // their own shapes, and the bind is what knows them.
544 + _ => Ok(Value::String(text.to_string())),
545 + }
312 546 }
313 547
314 548 /// Whether a field path falls inside a section, on whole segments.
@@ -426,6 +660,9 @@
426 660 panes: FocusRing,
427 661 apps: Vec<App>,
428 662 cursor: Cursor,
663 + /// The last failure that was not a field's own, so it has no row to sit
664 + /// under. A failed save is the one that matters.
665 + error: Option<String>,
429 666 }
430 667
431 668 impl SettingsView {
@@ -442,9 +679,43 @@
442 679 panes: FocusRing::new(2),
443 680 apps,
444 681 cursor,
682 + error: None,
445 683 }
446 684 }
447 685
686 + /// Whether any app is holding edits that are not on disk.
687 + fn unsaved(&self) -> bool {
688 + self.apps
689 + .iter()
690 + .filter_map(|app| app.state.as_ref().ok())
691 + .any(|form| form.bind.dirty())
692 + }
693 +
694 + fn editing(&self) -> bool {
695 + self.app()
696 + .and_then(|app| app.state.as_ref().ok())
697 + .is_some_and(Form::editing)
698 + }
699 +
700 + /// Write the form the user is looking at.
701 + ///
702 + /// Not the whole catalog: a key that silently wrote four other files would
703 + /// be a surprise, and each form is its own file with its own dirty state.
704 + /// The footer still reports unsaved work anywhere, so leaving edits behind
705 + /// on another app is visible rather than silent.
706 + fn save(&mut self, log: &mut CommandLog) {
707 + let Some(index) = self.cursor.selected() else {
708 + return;
709 + };
710 + let Some(Ok(form)) = self.apps.get_mut(index).map(|app| &mut app.state) else {
711 + return;
712 + };
713 + if !form.bind.dirty() {
714 + return;
715 + }
716 + self.error = form.save(log).err();
717 + }
718 +
448 719 fn tab(&self) -> Tab {
449 720 Tab::from_slot(self.tabs.current())
450 721 }
@@ -561,27 +832,47 @@
561 832 })
562 833 .collect();
563 834
564 - let form_rows = rows.iter().zip(&cells).map(|(row, cell)| match row {
565 - Row::Section(section) => FormRow::Section {
566 - label: &section.path,
567 - open: !form.folded.contains(&section.path),
568 - },
569 - Row::Field(field) => {
570 - let cell = cell.as_ref().expect("a field row carries a cell");
571 - let kind = if let Some(flag) = cell.toggle {
572 - FieldKind::Toggle(flag)
573 - } else if cell.color {
574 - FieldKind::Color { hex: &cell.text }
575 - } else {
576 - FieldKind::Text(&cell.text)
577 - };
578 - FormRow::Field(
579 - AlloyField::new(theme, leaf(&field.path), kind)
580 - .unset(cell.unset)
581 - .help(field.description.as_deref()),
582 - )
583 - }
584 - });
835 + let form_rows = rows
836 + .iter()
837 + .zip(&cells)
838 + .enumerate()
839 + .map(|(index, (row, cell))| match row {
840 + Row::Section(section) => FormRow::Section {
841 + label: &section.path,
842 + open: !form.folded.contains(&section.path),
843 + },
844 + Row::Field(field) => {
845 + let cell = cell.as_ref().expect("a field row carries a cell");
846 + let kind = if let Some(flag) = cell.toggle {
847 + FieldKind::Toggle(flag)
848 + } else if cell.color {
849 + FieldKind::Color { hex: &cell.text }
850 + } else {
851 + FieldKind::Text(&cell.text)
852 + };
853 + // The edit buffer and its diagnostic belong to one row.
854 + // `AlloyForm` shows the focused row's diagnostic on its
855 + // footer line, and an invalid value stays in edit, so the
856 + // reason is under the text that caused it.
857 + let editing = match &form.mode {
858 + Mode::Editing { row, buffer, error } if *row == index => {
859 + Some((buffer, error.as_deref()))
860 + }
861 + _ => None,
862 + };
863 + FormRow::Field(
864 + AlloyField::new(theme, leaf(&field.path), kind)
865 + .unset(cell.unset && editing.is_none())
866 + .edit(editing.map(|(buffer, _)| buffer))
867 + .diagnostic(
868 + editing
869 + .and_then(|(_, error)| error)
870 + .map(|error| (Severity::Error, error)),
871 + )
872 + .help(field.description.as_deref()),
873 + )
874 + }
875 + });
585 876
586 877 let mut widget = AlloyForm::new(theme, form_rows);
587 878 if let Some(selected) = form.cursor.selected() {
@@ -603,24 +894,42 @@
603 894 }
604 895
605 896 fn hints(&self) -> Vec<Hint> {
897 + // Editing owns the keyboard, so the footer stops advertising keys that
898 + // would land in the field as characters.
899 + if self.editing() {
900 + return vec![hint("enter", "commit"), hint("esc", "discard")];
901 + }
902 +
606 903 let mut hints = vec![hint("h/l", "tab")];
607 904 if self.tab() == Tab::Applications {
608 905 hints.push(hint("tab", "pane"));
609 906 hints.push(hint("j/k", "select"));
610 907 if self.panes.is_focused(PANE_FORM) {
611 - hints.push(hint("space", "fold"));
908 + hints.extend([
909 + hint("enter", "edit"),
910 + hint("space", "fold/flip"),
911 + hint("ctrl-s", "save"),
912 + ]);
612 913 }
613 914 }
614 915 hints
615 916 }
616 917
617 918 fn status(&self) -> Option<(Severity, String)> {
919 + if let Some(error) = &self.error {
920 + return Some((Severity::Error, error.clone()));
921 + }
618 922 if self.tab() != Tab::Applications {
619 923 return None;
620 924 }
621 925 match &self.app()?.state {
622 - Ok(_) => None,
623 926 Err(reason) => Some((Severity::Error, reason.clone())),
927 + // Every app with pending edits, not only the one on screen: a user
928 + // who edited rio, moved to yazi, and pressed q should be told there
929 + // is something to lose.
930 + Ok(_) => self
931 + .unsaved()
932 + .then(|| (Severity::Warn, "unsaved".to_string())),
624 933 }
625 934 }
626 935
@@ -652,7 +961,73 @@
652 961 }
653 962 }
654 963
655 - fn handle(&mut self, key: KeyEvent, _log: &mut CommandLog) -> Flow {
964 + fn text_entry(&self) -> bool {
965 + self.editing()
966 + }
967 +
968 + /// Esc backs out one layer at a time: out of an edit, then out of the view.
969 + ///
970 + /// The unsaved question is asked by [`quit`](View::quit), which both Esc
971 + /// and `q` reach; asking it here as well would ask twice for one key.
972 + fn cancel(&mut self) -> Flow {
973 + if self.editing() {
974 + if let Some(form) = self.form_mut() {
975 + form.cancel_edit();
976 + }
977 + return Flow::Continue;
978 + }
979 + self.quit()
980 + }
981 +
982 + fn quit(&mut self) -> Flow {
983 + if self.unsaved() {
984 + return Flow::Confirm(Confirm::destructive(
985 + "unsaved edits",
986 + "Leave without writing the changes?",
987 + ));
988 + }
989 + Flow::Exit
990 + }
991 +
992 + /// The user chose to leave with edits pending.
993 + fn confirmed(&mut self, _log: &mut CommandLog) -> Flow {
994 + Flow::Exit
995 + }
996 +
997 + fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
998 + self.error = None;
999 +
1000 + // An open field owns every key, so the reserved map is consulted for
1001 + // the two that are not characters and the rest go to the buffer. This
1002 + // is the obligation `classify` documents, honored at the one place in
1003 + // this view that takes typing.
1004 + if self.editing() {
1005 + match classify(key) {
1006 + Action::Activate => {
1007 + if let Some(form) = self.form_mut() {
1008 + form.commit_edit(log);
1009 + }
1010 + }
1011 + Action::Save => {
1012 + // Commit the field first, then write. Ctrl-S mid-edit means
1013 + // "and save this too", not "save everything except what I
1014 + // am looking at".
1015 + if let Some(form) = self.form_mut() {
1016 + form.commit_edit(log);
1017 + }
1018 + if !self.editing() {
1019 + self.save(log);
1020 + }
1021 + }
1022 + _ => {
1023 + if let Some(form) = self.form_mut() {
1024 + form.type_key(key);
1025 + }
1026 + }
1027 + }
Lines truncated