Skip to main content

max / alloy

settings: the System tab, fronting timedatectl The other half of the seam. FileBind reads a document and writes at save; SystemBind reads `timedatectl show` and hands back a setter per row. Form now holds a Box<dyn Bind> and cannot tell which it has, which is the claim the two-tab design rested on and is now the code rather than the note. Three rows: zone, an enum over the machine's own 598 timezones, which is the number that forced the pick overlay to filter; ntp, a bool; and the clock, shown and never set. Fields gained `readonly` for that last one, and `validate` refuses a write to one centrally rather than at each of the three routes that open a row, so a route that forgets to ask cannot write anyway. Permissions stay narrow on principle. This bind runs exactly two writing commands, set-timezone and set-ntp, both of them in the five the polkit rule grants. Setting the clock by hand is not offered at all: set-time is deliberately not granted, and a row that tried would produce a password prompt rather than a change. NTPSynchronized and CanNTP go on the row's help line rather than being dropped. Whether network time is switched on and whether it has agreed with a server are different facts, and a machine with no timesyncd is better told why the row is not a switch than shown nothing. The seam gained `reload`, defaulting to nothing. A file is its own truth; a command front cannot know what happened until it looks again, and the row it wrote is not the only one that moved, since setting a zone changes the clock beside it. The tab also re-reads on a tick, because a clock advances and a sync flips without anyone pressing a key.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 19:15 UTC
Signed with PGP, not checked
Commit: bc7d637b4e0d83fb721ac7170879db1dae8356b7
Parent: 3180420
6 files changed, +657 insertions, -60 deletions
M docs/CONSOLE.md +2 -2
@@ -83,7 +83,7 @@
83 83
84 84 The thesis at the top of this document, built. One view, two tabs, and the same form under both:
85 85
86 - - **System**, first, because it is the one a user goes looking for. General settings held as live state: time (zone, NTP), hostname, locale and keymap, theme. Each is a handful of rows over one front with machine-readable output, which is the bar `net` (`nmcli`) and `audio` (`pactl -f json`) were already held to.
86 + - **System**, first, because it is the one a user goes looking for. General settings held as live state: time (zone, NTP), hostname, locale and keymap, theme. Time is built, fronting `timedatectl`; the rest follow. Its rows commit as they are edited rather than at Ctrl-S, and the form reads that from the bind rather than from which tab it is on. Each is a handful of rows over one front with machine-readable output, which is the bar `net` (`nmcli`) and `audio` (`pactl -f json`) were already held to.
87 87 - **Applications**, the adopted stack's config files, grouped behind the app each one configures rather than presented as file paths. The user picks **rio**, not `~/.config/rio/config.toml`; left pane lists the apps a schema ships for, right pane is the form for the selected one, reusing `audio`'s two-pane routing layout. An app with no schema (sway, whose i3-style syntax is neither TOML nor KDL) still appears in the list and opens the text-edit fallback, because someone looking for "where do I configure sway" should find an answer rather than an absence.
88 88
89 89 Display and power/idle are deliberately not here. `alloy display` is a specced verb of its own and idle behaviour belongs with it; folding them in would duplicate a screen rather than unify one.
@@ -195,7 +195,7 @@
195 195 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.
196 196
197 197 - **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.
198 - - **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, **shipped**, which was the last field type that could not be changed; (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.
198 + - **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, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (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.
199 199 - **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.
200 200 - **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.
201 201 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
@@ -78,6 +78,15 @@
78 78 /// Emit the effects that make the accumulated edits durable.
79 79 fn save(&mut self) -> Result<Vec<Effect>>;
80 80
81 + /// Re-read the source of truth.
82 + ///
83 + /// Default is nothing, which is right for a file: the document *is* the
84 + /// truth and an edit is already in it. A command front is the asymmetric
85 + /// case — it hands back a setter for the caller to run, so it cannot know
86 + /// what happened until it looks again. The caller calls this after applying
87 + /// what [`commit`](Bind::commit) returned, and on whatever tick it keeps.
88 + fn reload(&mut self, _log: &mut crate::cli::CommandLog) {}
89 +
81 90 /// The field at a path, if this bind has one.
82 91 fn field(&self, path: &str) -> Option<&Field> {
83 92 self.fields().iter().find(|field| field.path == path)
@@ -92,6 +101,10 @@
92 101 let field = self
93 102 .field(path)
94 103 .with_context(|| format!("`{path}` is not a field of {}", self.origin()))?;
104 + // Checked here rather than at each of the three call sites that open a
105 + // row for editing, so a bind that marks a row read-only cannot have it
106 + // written by a route that forgot to ask.
107 + ensure!(!field.readonly, "`{path}` is shown, not set");
95 108 check_value(&field.kind, value).with_context(|| format!("`{path}`"))
96 109 }
97 110 }
@@ -17,6 +17,7 @@
17 17 mod schema;
18 18 mod settings;
19 19 mod shell;
20 + mod system;
20 21 mod theme;
21 22 mod wizard;
22 23
@@ -113,7 +114,9 @@
113 114 shell::run(&theme, &mut view, &mut log)
114 115 }
115 116 Command::Settings => {
116 - let mut view = settings::SettingsView::new(settings::Tab::Applications);
117 + // Opens on System, which [[alloy-settings]] puts first because it
118 + // is the tab a user goes looking for.
119 + let mut view = settings::SettingsView::new(settings::Tab::System, &mut log);
117 120 shell::run(&theme, &mut view, &mut log)
118 121 }
119 122 Command::Install => {
@@ -97,6 +97,14 @@
97 97 pub(crate) path: String,
98 98 pub(crate) description: Option<String>,
99 99 pub(crate) required: bool,
100 + /// Shown, never edited.
101 + ///
102 + /// Always false for a schema-parsed field: a `.schema` file describes keys
103 + /// the console may write, and one it may not has no business being in it.
104 + /// The System tab's hand-written table is where this is true — a clock is a
105 + /// reading, and so is a setting whose front says it cannot be changed on
106 + /// this machine.
107 + pub(crate) readonly: bool,
100 108 pub(crate) kind: FieldKind,
101 109 }
102 110
@@ -432,6 +440,7 @@
432 440 path: self.path,
433 441 description: self.description,
434 442 required: self.required.unwrap_or(false),
443 + readonly: false,
435 444 kind,
436 445 })
437 446 }
@@ -42,6 +42,7 @@
42 42 use crate::cli::{CommandLog, contract_home};
43 43 use crate::schema::{self, Field, Schema, Section};
44 44 use crate::shell::{Confirm, Flow, View};
45 + use crate::system::SystemBind;
45 46
46 47 /// Extension every schema file carries.
47 48 const SCHEMA_EXT: &str = "schema";
@@ -141,7 +142,7 @@
141 142 /// app and coming back lands where you left, which matters most for the one
142 143 /// app whose form is long enough to scroll.
143 144 struct Form {
144 - bind: FileBind,
145 + bind: Box<dyn Bind>,
145 146 /// Section paths that are currently collapsed.
146 147 folded: BTreeSet<String>,
147 148 cursor: Cursor,
@@ -241,7 +242,7 @@
241 242 None => Err(format!("cannot resolve `{target}`")),
242 243 Some(path) => FileBind::open(schema, &path).map_or_else(
243 244 |error| Err(format!("{error:#}")),
244 - |bind| Ok(Form::new(bind)),
245 + |bind| Ok(Form::new(Box::new(bind))),
245 246 ),
246 247 },
247 248 };
@@ -254,7 +255,7 @@
254 255 }
255 256
256 257 impl Form {
257 - fn new(bind: FileBind) -> Self {
258 + fn new(bind: Box<dyn Bind>) -> Self {
258 259 // Big sections start folded. Computed once, at open: a fold state that
259 260 // recomputed itself would spring back open the moment a user closed a
260 261 // small section.
@@ -402,6 +403,9 @@
402 403 let schema::FieldKind::Enum { values, .. } = &field.kind else {
403 404 return false;
404 405 };
406 + if field.readonly {
407 + return false;
408 + }
405 409
406 410 // Opens on the current value rather than at the top, so a pick that
407 411 // changes nothing is Enter twice and the list says what is set.
@@ -514,7 +518,10 @@
514 518 let Some(field) = self.selected_field() else {
515 519 return false;
516 520 };
517 - if matches!(field.kind, schema::FieldKind::Bool { .. }) {
521 + // A row the bind shows but does not set never opens. `validate` would
522 + // refuse the write anyway; refusing to start is the difference between
523 + // a field that will not take and one that was never offered.
524 + if field.readonly || matches!(field.kind, schema::FieldKind::Bool { .. }) {
518 525 return false;
519 526 }
520 527
@@ -523,7 +530,7 @@
523 530 // point of `TextField::set`, and committing one is an explicit Enter,
524 531 // so a default only reaches disk because the user put it there.
525 532 let mut buffer = TextField::new();
526 - buffer.set(raw(&self.bind, field));
533 + buffer.set(raw(self.bind.as_ref(), field));
527 534 self.mode = Mode::Editing {
528 535 row,
529 536 buffer,
@@ -540,6 +547,9 @@
540 547 let schema::FieldKind::Bool { .. } = field.kind else {
541 548 return false;
542 549 };
550 + if field.readonly {
551 + return false;
552 + }
543 553 let path = field.path.clone();
544 554 // An unset bool reads as its default, so the first press flips away
545 555 // from what the row shows rather than from `false`.
@@ -601,6 +611,10 @@
601 611 return;
602 612 }
603 613 }
614 + // A command front cannot know what happened until it looks
615 + // again, and the row it just wrote is not the only one that
616 + // moved: setting a timezone changes the clock beside it.
617 + log.quiet(|log| self.bind.reload(log));
604 618 }
605 619 Err(error) => self.fail_edit(format!("{error:#}")),
606 620 }
@@ -661,7 +675,7 @@
661 675 /// The raw value, not the rendered one: an enum's label and a list's summary
662 676 /// are for reading, and seeding an edit with either would have the user typing
663 677 /// over something that was never in the file.
664 - fn raw(bind: &FileBind, field: &Field) -> String {
678 + fn raw(bind: &dyn Bind, field: &Field) -> String {
665 679 match bind.read(&field.path).or_else(|| field.default_value()) {
666 680 Some(Value::String(text)) => text,
667 681 Some(Value::Integer(number)) => number.to_string(),
@@ -719,7 +733,7 @@
719 733 }
720 734
721 735 /// Format a field's current value.
722 - fn cell(bind: &FileBind, field: &Field) -> Cell {
736 + fn cell(bind: &dyn Bind, field: &Field) -> Cell {
723 737 let read = bind.read(&field.path);
724 738 let unset = read.is_none();
725 739 let value = read.or_else(|| field.default_value());
@@ -804,9 +818,19 @@
804 818 const PANE_APPS: usize = 0;
805 819 const PANE_FORM: usize = 1;
806 820
807 - /// What the System tab says until it exists.
808 - const SYSTEM_PENDING: &str =
809 - "time, hostname, locale and theme land here. Not built yet: see docs/CONSOLE.md.";
821 + /// What the System tab says when there is no `timedatectl` answering.
822 + ///
823 + /// Not an empty pane: this screen is about what is really on the machine, and
824 + /// a development box in a container is not an Alloy install.
825 + const SYSTEM_ABSENT: &str = "no timedatectl answering; there is no live state to show";
826 +
827 + /// Shell ticks between System-tab re-reads.
828 + ///
829 + /// The shell ticks about once a second. Five is often enough that a clock is
830 + /// never far wrong and a sync flipping is noticed, and rare enough that a
831 + /// screen left open is not spawning a process every second for a row nobody is
832 + /// looking at.
833 + const SYSTEM_POLL_TICKS: u64 = 5;
810 834
811 835 /// The `alloy settings` screen.
812 836 pub(crate) struct SettingsView {
@@ -814,14 +838,19 @@
814 838 panes: FocusRing,
815 839 apps: Vec<App>,
816 840 cursor: Cursor,
841 + /// The System tab's form, or `None` off a machine with systemd.
842 + system: Option<Form>,
843 + /// Ticks since construction, for the periodic re-read the System tab wants.
844 + ticks: u64,
817 845 /// The last failure that was not a field's own, so it has no row to sit
818 846 /// under. A failed save is the one that matters.
819 847 error: Option<String>,
820 848 }
821 849
822 850 impl SettingsView {
823 - pub(crate) fn new(tab: Tab) -> Self {
851 + pub(crate) fn new(tab: Tab, log: &mut CommandLog) -> Self {
824 852 let apps = App::catalog();
853 + let system = SystemBind::detect(log).map(|bind| Form::new(Box::new(bind)));
825 854 let mut cursor = Cursor::new();
826 855 cursor.resize(apps.len());
827 856
@@ -833,10 +862,24 @@
833 862 panes: FocusRing::new(2),
834 863 apps,
835 864 cursor,
865 + system,
866 + ticks: 0,
836 867 error: None,
837 868 }
838 869 }
839 870
871 + /// The form the keys act on: the System tab's, or the selected app's.
872 + ///
873 + /// One accessor rather than two paths through `handle`, which is what makes
874 + /// "both tabs drive the same form" true in the code and not only in the
875 + /// design note.
876 + fn form(&self) -> Option<&Form> {
877 + match self.tab() {
878 + Tab::System => self.system.as_ref(),
879 + Tab::Applications => self.app()?.state.as_ref().ok(),
880 + }
881 + }
882 +
840 883 /// Whether any app is holding edits that are not on disk.
841 884 fn unsaved(&self) -> bool {
842 885 self.apps
@@ -847,14 +890,26 @@
847 890
848 891 /// Whether a text buffer, of either kind, has the keyboard.
849 892 fn typing(&self) -> bool {
850 - self.app()
851 - .and_then(|app| app.state.as_ref().ok())
852 - .is_some_and(Form::typing)
893 + self.form().is_some_and(Form::typing)
894 + }
895 +
896 + fn picking(&self) -> bool {
897 + self.form().is_some_and(Form::picking)
898 + }
899 +
900 + /// Whether the keys act on a form rather than on the app list.
901 + ///
902 + /// Always, on the System tab: there is no list beside it to move through.
903 + fn on_form(&self) -> bool {
904 + match self.tab() {
905 + Tab::System => true,
906 + Tab::Applications => self.panes.is_focused(PANE_FORM),
907 + }
853 908 }
854 909
855 910 /// Draw the pick overlay over the whole screen area, if one is open.
856 911 fn render_pick(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
857 - let Some(form) = self.app().and_then(|app| app.state.as_ref().ok()) else {
912 + let Some(form) = self.form() else {
858 913 return;
859 914 };
860 915 let Mode::Picking {
@@ -917,15 +972,32 @@
917 972 }
918 973
919 974 fn form_mut(&mut self) -> Option<&mut Form> {
920 - let index = self.cursor.selected()?;
921 - self.apps.get_mut(index)?.state.as_mut().ok()
975 + match self.tab() {
976 + Tab::System => self.system.as_mut(),
977 + Tab::Applications => {
978 + let index = self.cursor.selected()?;
979 + self.apps.get_mut(index)?.state.as_mut().ok()
980 + }
981 + }
922 982 }
923 983
924 - fn render_system(frame: &mut Frame, area: Rect, theme: &Theme) {
925 - frame.render_widget(
926 - Paragraph::new(Line::from(text::muted(theme, SYSTEM_PENDING))),
927 - area,
928 - );
984 + fn render_system(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
985 + let Some(form) = &self.system else {
986 + frame.render_widget(
987 + Paragraph::new(Line::from(text::muted(theme, SYSTEM_ABSENT))),
988 + area,
989 + );
990 + return;
991 + };
992 + // One pane and no app list: there is one source of truth here, and the
993 + // header already names the front it is reading.
994 + let block = AlloyBlock::new(theme)
995 + .focused(true)
996 + .build()
997 + .title(format!(" {} ", form.bind.origin()));
998 + let inner = block.inner(area);
999 + frame.render_widget(block, area);
1000 + Self::render_form(frame, inner, theme, form);
929 1001 }
930 1002
931 1003 fn render_applications(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
@@ -975,7 +1047,7 @@
975 1047 );
976 1048
977 1049 let title = self.app().map_or_else(String::new, |app| match &app.state {
978 - Ok(form) => format!(" {} ", contract_home(form.bind.path())),
1050 + Ok(form) => format!(" {} ", contract_home(Path::new(&form.bind.origin()))),
979 1051 Err(_) => format!(" {} ", contract_home(&app.source)),
980 1052 });
981 1053 let form_block = AlloyBlock::new(theme)
@@ -1019,7 +1091,7 @@
1019 1091 let cells: Vec<Option<Cell>> = rows
1020 1092 .iter()
1021 1093 .map(|row| match row {
1022 - Row::Field(field) => Some(cell(&form.bind, field)),
1094 + Row::Field(field) => Some(cell(form.bind.as_ref(), field)),
1023 1095 Row::Section(_) => None,
1024 1096 })
1025 1097 .collect();
@@ -1091,11 +1163,7 @@
1091 1163 // so the footer covers the movement the overlay cannot spare a row for.
1092 1164 if self.typing() {
1093 1165 let mut hints = vec![hint("enter", "commit"), hint("esc", "discard")];
1094 - if self
1095 - .app()
1096 - .and_then(|app| app.state.as_ref().ok())
1097 - .is_some_and(Form::picking)
1098 - {
1166 + if self.picking() {
1099 1167 hints.push(hint("up/down", "choose"));
1100 1168 }
1101 1169 return hints;
@@ -1104,13 +1172,15 @@
1104 1172 let mut hints = vec![hint("h/l", "tab")];
1105 1173 if self.tab() == Tab::Applications {
1106 1174 hints.push(hint("tab", "pane"));
1107 - hints.push(hint("j/k", "select"));
1108 - if self.panes.is_focused(PANE_FORM) {
1109 - hints.extend([
1110 - hint("enter", "edit"),
1111 - hint("space", "fold/flip"),
1112 - hint("ctrl-s", "save"),
1113 - ]);
1175 + }
1176 + hints.push(hint("j/k", "select"));
1177 + if self.on_form() {
1178 + hints.extend([hint("enter", "edit"), hint("space", "fold/flip")]);
1179 + // Only a bind that holds edits back has anything to save. A command
1180 + // front writes when the row is edited, so offering Ctrl-S there
1181 + // would advertise a key that does nothing.
1182 + if self.form().is_some_and(|form| form.bind.dirty()) {
1183 + hints.push(hint("ctrl-s", "save"));
1114 1184 }
1115 1185 }
1116 1186 hints
@@ -1120,8 +1190,11 @@
1120 1190 if let Some(error) = &self.error {
1121 1191 return Some((Severity::Error, error.clone()));
1122 1192 }
1123 - if self.tab() != Tab::Applications {
1124 - return None;
1193 + if self.tab() == Tab::System {
1194 + return self
1195 + .system
1196 + .is_none()
1197 + .then(|| (Severity::Warn, SYSTEM_ABSENT.to_string()));
1125 1198 }
1126 1199 match &self.app()?.state {
1127 1200 Err(reason) => Some((Severity::Error, reason.clone())),
@@ -1157,7 +1230,7 @@
1157 1230 );
1158 1231
1159 1232 match self.tab() {
1160 - Tab::System => Self::render_system(frame, body, theme),
1233 + Tab::System => self.render_system(frame, body, theme),
1161 1234 Tab::Applications => self.render_applications(frame, body, theme),
1162 1235 }
1163 1236
@@ -1201,6 +1274,23 @@
1201 1274 Flow::Exit
1202 1275 }
1203 1276
1277 + /// Re-read the System tab's state periodically.
1278 + ///
1279 + /// Both facts on that tab move without the user: the clock advances, and
1280 + /// `NTPSynchronized` flips from no to yes some seconds after a machine
1281 + /// comes up. Quiet, because this is the console looking rather than the
1282 + /// user asking, and skipped while a row is open so a re-read cannot pull
1283 + /// the ground out from under an edit in progress.
1284 + fn tick(&mut self, log: &mut CommandLog) {
1285 + self.ticks += 1;
1286 + if self.ticks % SYSTEM_POLL_TICKS != 0 || self.typing() {
1287 + return;
1288 + }
1289 + if let Some(form) = self.system.as_mut() {
1290 + log.quiet(|log| form.bind.reload(log));
1291 + }
1292 + }
1293 +
1204 1294 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
1205 1295 self.error = None;
1206 1296
@@ -1209,10 +1299,7 @@
1209 1299 // is the obligation `classify` documents, honored at the one place in
1210 1300 // this view that takes typing.
1211 1301 if self.typing() {
1212 - let picking = self
1213 - .app()
1214 - .and_then(|app| app.state.as_ref().ok())
1215 - .is_some_and(Form::picking);
1302 + let picking = self.picking();
1216 1303
1217 1304 match classify(key) {
1218 1305 Action::Activate => {
@@ -1274,7 +1361,7 @@
1274 1361 return Flow::Continue;
1275 1362 }
1276 1363 Action::Activate => {
1277 - if self.panes.is_focused(PANE_FORM) {
1364 + if self.on_form() {
1278 1365 if let Some(form) = self.form_mut() {
1279 1366 // A closed vocabulary picks; anything else edits.
1280 1367 if !form.begin_pick() {
@@ -1292,11 +1379,7 @@
1292 1379 _ => {}
1293 1380 }
1294 1381
1295 - if self.tab() != Tab::Applications {
1296 - return Flow::Continue;
1297 - }
1298 -
1299 - let on_form = self.panes.is_focused(PANE_FORM);
1382 + let on_form = self.on_form();
1300 1383 match key.code {
1301 1384 KeyCode::Char('j') | KeyCode::Down => {
1302 1385 if on_form {
@@ -1382,7 +1465,9 @@
1382 1465
1383 1466 fn form(file: &str) -> Form {
1384 1467 let schema = Schema::parse(SCHEMA).expect("the test schema parses");
1385 - Form::new(FileBind::new(schema, PathBuf::from("rio.toml"), file).expect("the file parses"))
1468 + Form::new(Box::new(
1469 + FileBind::new(schema, PathBuf::from("rio.toml"), file).expect("the file parses"),
1470 + ))
1386 1471 }
1387 1472
1388 1473 fn labels(form: &Form) -> Vec<String> {
@@ -1460,11 +1545,11 @@
1460 1545 let shape = form.bind.field("cursor.shape").unwrap();
1461 1546 let blinking = form.bind.field("cursor.blinking").unwrap();
1462 1547
1463 - let shown = cell(&form.bind, shape);
1548 + let shown = cell(form.bind.as_ref(), shape);
1464 1549 assert_eq!(shown.text, "Beam", "an enum shows its label");
1465 1550 assert!(!shown.unset);
1466 1551
1467 - let shown = cell(&form.bind, blinking);
1552 + let shown = cell(form.bind.as_ref(), blinking);
1468 1553 assert_eq!(shown.toggle, Some(false));
1469 1554 assert!(shown.unset, "the file does not set it; the schema does");
1470 1555 }
@@ -1482,7 +1567,7 @@
1482 1567 )
1483 1568 .unwrap();
1484 1569 let bind = FileBind::new(schema, PathBuf::from("t.toml"), "").unwrap();
1485 - let shown = cell(&bind, bind.field("a").unwrap());
1570 + let shown = cell(&bind as &dyn Bind, bind.field("a").unwrap());
1486 1571 assert_eq!(shown.text, "unset");
1487 1572 assert!(shown.unset);
1488 1573 }
@@ -1507,7 +1592,7 @@
1507 1592 )
1508 1593 .unwrap();
1509 1594 assert_eq!(
1510 - cell(&bind, bind.field("bindings.keys").unwrap()).text,
1595 + cell(&bind as &dyn Bind, bind.field("bindings.keys").unwrap()).text,
1511 1596 "2 entries"
1512 1597 );
1513 1598 }
@@ -1567,7 +1652,9 @@
1567 1652 default = \"IosevkaTerm\"\n",
1568 1653 )
1569 1654 .unwrap();
1570 - let mut form = Form::new(FileBind::new(schema, PathBuf::from("t.toml"), "").unwrap());
1655 + let mut form = Form::new(Box::new(
1656 + FileBind::new(schema, PathBuf::from("t.toml"), "").unwrap(),
1657 + ));
1571 1658 focus(&mut form, "family");
1572 1659 assert!(form.begin_edit());
1573 1660 let Mode::Editing { buffer, .. } = &form.mode else {
@@ -1621,7 +1708,9 @@
1621 1708 default = 12.0\n",
1622 1709 )
1623 1710 .unwrap();
1624 - let mut form = Form::new(FileBind::new(schema, PathBuf::from("t.toml"), "").unwrap());
1711 + let mut form = Form::new(Box::new(
1712 + FileBind::new(schema, PathBuf::from("t.toml"), "").unwrap(),
1713 + ));
1625 1714 focus(&mut form, "size");
1626 1715 form.begin_edit();
1627 1716
@@ -1846,7 +1935,7 @@
1846 1935 std::fs::write(&path, "[cursor]\nshape = \"beam\"\n").unwrap();
1847 1936
1848 1937 let schema = Schema::parse(SCHEMA).unwrap();
1849 - let mut form = Form::new(FileBind::open(schema, &path).unwrap());
1938 + let mut form = Form::new(Box::new(FileBind::open(schema, &path).unwrap()));
1850 1939 focus(&mut form, "cursor.blinking");
1851 1940 form.toggle_bool(&mut log);
1852 1941 assert!(form.bind.dirty());
@@ -1863,6 +1952,67 @@
1863 1952 std::fs::remove_file(&path).ok();
1864 1953 }
1865 1954
1955 + // ---- the seam, from the view's side ----
1956 +
1957 + /// A form over the System tab's bind, built from a fixed `timedatectl show`
1958 + /// so the test does not depend on the machine running it.
1959 + fn system_form() -> Form {
1960 + Form::new(Box::new(crate::system::SystemBind::fixture()))
1961 + }
1962 +
1963 + // The claim [[alloy-settings]] rests on: the form does not know which kind
1964 + // of bind it is holding. Same rows, same folding, same cursor, over a
1965 + // command front instead of a document.
1966 + #[test]
1967 + fn the_same_form_renders_a_command_front() {
1968 + let form = system_form();
1969 + assert_eq!(
1970 + labels(&form),
1971 + ["[time]", "time.zone", "time.ntp", "time.clock"],
1972 + );
1973 + assert_eq!(
1974 + cell(form.bind.as_ref(), form.bind.field("time.zone").unwrap()).text,
1975 + "America/Los_Angeles",
1976 + );
1977 + }
1978 +
1979 + // A command front writes when the row is edited, so there is never anything
1980 + // for Ctrl-S to flush and the footer does not offer it.
1981 + #[test]
1982 + fn a_command_front_is_never_dirty() {
1983 + let mut log = CommandLog::new();
1984 + let mut form = system_form();
1985 + focus(&mut form, "time.ntp");
1986 + assert!(form.toggle_bool(&mut log));
1987 + assert!(!form.bind.dirty());
1988 + }
1989 +
1990 + // The clock is a reading. It does not open as a field, and it does not flip.
1991 + #[test]
1992 + fn a_readonly_row_does_not_open() {
1993 + let mut log = CommandLog::new();
1994 + let mut form = system_form();
1995 + focus(&mut form, "time.clock");
1996 + assert!(!form.begin_edit(), "no text field");
1997 + assert!(!form.begin_pick(), "no overlay");
1998 + assert!(!form.toggle_bool(&mut log), "and nothing to flip");
1999 + assert!(!form.editing() && !form.picking());
2000 + }
2001 +
2002 + // 598 zones is what forced the overlay to filter, and it is the same
2003 + // overlay the file side opens for a three-value enum.
2004 + #[test]
2005 + fn the_zone_row_picks_from_the_machines_own_list() {
2006 + let mut form = system_form();
2007 + focus(&mut form, "time.zone");
2008 + assert!(form.begin_pick());
2009 + assert_eq!(form.choices().len(), 3, "the fixture ships three zones");
2010 +
2011 + type_filter(&mut form, "den");
2012 + assert_eq!(form.choices().len(), 1);
2013 + assert_eq!(form.choices()[0].0, "America/Denver");
2014 + }
2015 +
1866 2016 #[test]
1867 2017 fn a_row_is_labelled_by_the_last_segment_of_its_path() {
1868 2018 assert_eq!(leaf("fonts.regular.family"), "family");
@@ -1,0 +1,422 @@
1 + //! The System tab's bind: live state, fronted by a command.
2 + //!
3 + //! The other half of the [`Bind`] seam. [`FileBind`](crate::bind::FileBind)
4 + //! reads a `DocumentMut` and writes at save; this reads `timedatectl show` and
5 + //! hands back a setter per row. The form above them cannot tell which it is
6 + //! holding, which is the whole point of [[alloy-settings]] putting two tabs
7 + //! over one engine.
8 + //!
9 + //! ## Why the field list is hand-written
10 + //!
11 + //! The Applications side parses ten schemas and will parse more. This side is a
12 + //! dozen rows fronting different CLIs, each with its own vocabulary and its own
13 + //! setter, and a schema DSL general enough to describe `timedatectl` would be a
14 + //! second language to maintain for no reader. So the table is Rust, and the
15 + //! only thing it shares with a schema is the [`Field`] it produces.
16 + //!
17 + //! ## Permissions are narrow on principle
18 + //!
19 + //! This bind runs exactly two writing commands: `timedatectl set-timezone` and
20 + //! `timedatectl set-ntp`. Both are in the five actions
21 + //! `usr/share/polkit-1/rules.d/50-alloy-settings.rules` grants, so neither
22 + //! prompts, and nothing here can reach an action that was deliberately left
23 + //! prompting. Setting the clock by hand is not offered at all: `set-time` is
24 + //! not granted, moving a system clock invalidates certificates and reorders
25 + //! logs, and the row exists to show what the timezone did rather than to be
26 + //! typed into. See wiki note `alloy-privilege`.
27 + //!
28 + //! <!-- wiki: alloy-settings -->
29 +
30 + #![allow(dead_code)]
31 +
32 + use anyhow::Result;
33 + use toml::Value;
34 +
35 + use crate::bind::Bind;
36 + use crate::cli::{CommandLog, Effect, Invocation};
37 + use crate::schema::{EnumValue, Field, FieldKind, Section};
38 +
39 + /// Paths the rows are addressed by. Dotted like a schema's, so the form groups
40 + /// them under a section header the same way.
41 + const ZONE: &str = "time.zone";
42 + const NTP: &str = "time.ntp";
43 + const CLOCK: &str = "time.clock";
44 +
45 + /// Live system settings, fronted by `timedatectl`.
46 + pub(crate) struct SystemBind {
47 + sections: Vec<Section>,
48 + fields: Vec<Field>,
49 + /// Last `timedatectl show`, as key=value.
50 + state: Vec<(String, String)>,
51 + }
52 +
53 + impl SystemBind {
54 + /// Read the machine's state and build the rows, or `None` when there is no
55 + /// `timedatectl` answering.
56 + ///
57 + /// A probe that runs the real command rather than looking for the binary,
58 + /// for the reason `net` gives: a `timedatectl` that cannot reach a systemd
59 + /// is worse than none, and only running it reveals that.
60 + pub(crate) fn detect(log: &mut CommandLog) -> Option<Self> {
61 + let state = parse_show(&show().run(log).ok()?);
62 + if state.is_empty() {
63 + return None;
64 + }
65 +
66 + // 598 entries on a current systemd, which is the number that forced the
67 + // pick overlay to filter as you type.
68 + let zones: Vec<EnumValue> = Invocation::new("timedatectl")
69 + .arg("list-timezones")
70 + .run(log)
71 + .map(|raw| parse_zones(&raw))
72 + .unwrap_or_default();
73 +
74 + let mut bind = Self {
75 + sections: vec![Section {
76 + path: "time".to_string(),
77 + description: Some("Clock, timezone, and network time.".to_string()),
78 + }],
79 + fields: rows(zones),
80 + state,
81 + };
82 + bind.annotate();
83 + Some(bind)
84 + }
85 +
86 + fn get(&self, key: &str) -> Option<&str> {
87 + self.state
88 + .iter()
89 + .find(|(name, _)| name == key)
90 + .map(|(_, value)| value.as_str())
91 + }
92 +
93 + /// Fold the state that is *about* a row into that row's help line.
94 + ///
95 + /// `NTPSynchronized` is the case the design named: whether network time is
96 + /// switched on and whether it has actually agreed with a server yet are two
97 + /// different facts, and a row that showed only the first would say "on"
98 + /// beside a clock that is still wrong. `CanNTP` is the other: a machine
99 + /// with no timesyncd can be shown the row and told why it is not a switch,
100 + /// which is more use than hiding it.
101 + fn annotate(&mut self) {
102 + let can = self.get("CanNTP") != Some("no");
103 + let synced = self.get("NTPSynchronized") == Some("yes");
104 + let note = if !can {
105 + "No network time service on this machine.".to_string()
106 + } else if synced {
107 + "Network time. The clock has agreed with a server.".to_string()
108 + } else {
109 + "Network time. The clock has not synchronized yet.".to_string()
110 + };
111 +
112 + if let Some(field) = self.fields.iter_mut().find(|field| field.path == NTP) {
113 + field.description = Some(note);
114 + field.readonly = !can;
115 + }
116 + }
117 + }
118 +
119 + fn show() -> Invocation {
120 + Invocation::new("timedatectl").arg("show")
121 + }
122 +
123 + /// The three time rows.
124 + fn rows(zones: Vec<EnumValue>) -> Vec<Field> {
125 + vec![
126 + Field {
127 + path: ZONE.to_string(),
128 + description: Some("The machine's timezone.".to_string()),
129 + required: false,
130 + readonly: false,
131 + kind: FieldKind::Enum {
132 + default: None,
133 + values: zones,
134 + },
135 + },
136 + Field {
137 + path: NTP.to_string(),
138 + // Replaced by `annotate` with what the machine actually reports.
139 + description: None,
140 + required: false,
141 + readonly: false,
142 + kind: FieldKind::Bool { default: None },
143 + },
144 + Field {
145 + path: CLOCK.to_string(),
146 + description: Some("Local time, as of the last read.".to_string()),
147 + required: false,
148 + // Not a setting. `set-time` is deliberately not granted, and this
149 + // row exists to show what changing the zone did.
150 + readonly: true,
151 + kind: FieldKind::Str {
152 + default: None,
153 + pattern: None,
154 + },
155 + },
156 + ]
157 + }
158 +
159 + /// Parse `timedatectl show`, which is `Key=value` a line at a time.
160 + ///
161 + /// Values are taken verbatim after the first `=`. `TimeUSec` arrives already
162 + /// formatted for a human (`Fri 2026-07-24 12:11:22 PDT`) despite the name, so
163 + /// there is nothing to convert and no timezone database to carry.
164 + fn parse_show(raw: &str) -> Vec<(String, String)> {
165 + raw.lines()
166 + .filter_map(|line| line.split_once('='))
167 + .map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
168 + .collect()
169 + }
170 +
171 + /// One zone per line. They label themselves: `America/Denver` is already what a
172 + /// person would look for, and inventing a prettier label would break the
173 + /// filter, which matches on what is shown as well as on the raw value.
174 + fn parse_zones(raw: &str) -> Vec<EnumValue> {
175 + raw.lines()
176 + .map(str::trim)
177 + .filter(|line| !line.is_empty())
178 + .map(|zone| EnumValue {
179 + value: zone.to_string(),
180 + label: zone.to_string(),
181 + description: None,
182 + })
183 + .collect()
184 + }
185 +
186 + /// systemd spells its booleans `yes` and `no`.
187 + fn yes(raw: Option<&str>) -> bool {
188 + raw == Some("yes")
189 + }
190 +
191 + impl Bind for SystemBind {
192 + fn origin(&self) -> String {
193 + "timedatectl".to_string()
194 + }
195 +
196 + fn sections(&self) -> &[Section] {
197 + &self.sections
198 + }
199 +
200 + fn fields(&self) -> &[Field] {
201 + &self.fields
202 + }
203 +
204 + fn read(&self, path: &str) -> Option<Value> {
205 + match path {
206 + ZONE => self
207 + .get("Timezone")
208 + .map(|zone| Value::String(zone.to_string())),
209 + NTP => Some(Value::Boolean(yes(self.get("NTP")))),
210 + CLOCK => self
211 + .get("TimeUSec")
212 + .map(|time| Value::String(time.to_string())),
213 + _ => None,
214 + }
215 + }
216 +
217 + fn commit(&mut self, path: &str, value: Value) -> Result<Vec<Effect>> {
218 + self.validate(path, &value)?;
219 +
220 + // Exactly two writing commands, both of them granted actions. A path
221 + // that reaches here without one is a caller mistake, not a user one.
222 + let invocation = match (path, &value) {
223 + (ZONE, Value::String(zone)) => {
224 + Invocation::new("timedatectl").args(["set-timezone", zone])
225 + }
226 + (NTP, Value::Boolean(on)) => {
227 + Invocation::new("timedatectl").args(["set-ntp", if *on { "true" } else { "false" }])
228 + }
229 + _ => anyhow::bail!("`{path}` has no setter"),
230 + };
231 + Ok(vec![Effect::Run(invocation)])
232 + }
233 +
234 + /// Never. Every row is written when it is edited, so there is nothing held
235 + /// back for a save to flush, and the form asks this rather than being told
236 + /// which kind of tab it is on.
237 + fn dirty(&self) -> bool {
238 + false
239 + }
240 +
241 + fn save(&mut self) -> Result<Vec<Effect>> {
242 + Ok(Vec::new())
243 + }
244 +
245 + fn reload(&mut self, log: &mut CommandLog) {
246 + if let Ok(raw) = show().run(log) {
247 + self.state = parse_show(&raw);
248 + }
249 + self.annotate();
250 + }
251 + }
252 +
253 + impl SystemBind {
254 + /// A bind over fixed state, for tests that want the System tab without a
255 + /// systemd under them.
256 + ///
257 + /// Built from the same captured output the tests below parse, so the view's
258 + /// tests and this module's cannot disagree about what a row holds.
259 + #[cfg(test)]
260 + pub(crate) fn fixture() -> Self {
261 + let mut bind = Self {
262 + sections: vec![Section {
263 + path: "time".to_string(),
264 + description: None,
265 + }],
266 + fields: rows(parse_zones("America/Los_Angeles\nAmerica/Denver\nUTC\n")),
267 + state: parse_show(tests::SHOW),
268 + };
269 + bind.annotate();
270 + bind
271 + }
272 + }
273 +
274 + #[cfg(test)]
275 + mod tests {
276 + use super::*;
277 +
278 + // Verbatim from `timedatectl show` on a systemd 257 box.
279 + pub(crate) const SHOW: &str = "\
280 + Timezone=America/Los_Angeles
281 + LocalRTC=no
282 + CanNTP=yes
283 + NTP=yes
284 + NTPSynchronized=yes
285 + TimeUSec=Fri 2026-07-24 12:11:22 PDT
286 + RTCTimeUSec=Fri 2026-07-24 12:11:22 PDT
287 + ";
288 +
289 + fn bind(show: &str) -> SystemBind {
290 + let mut bind = SystemBind::fixture();
291 + bind.state = parse_show(show);
292 + bind.annotate();
293 + bind
294 + }
295 +
296 + #[test]
297 + fn the_rows_read_what_the_machine_reports() {
298 + let bind = bind(SHOW);
299 + assert_eq!(
300 + bind.read(ZONE),
301 + Some(Value::String("America/Los_Angeles".into())),
302 + );
303 + assert_eq!(bind.read(NTP), Some(Value::Boolean(true)));
304 + assert_eq!(
305 + bind.read(CLOCK),
306 + Some(Value::String("Fri 2026-07-24 12:11:22 PDT".into())),
307 + "TimeUSec is already formatted for a person, despite the name",
308 + );
309 + }
310 +
311 + // The two writing commands this bind is allowed, and no others. Both are in
312 + // the polkit grant; if these argv ever change, the grant has to change with
313 + // them.
314 + #[test]
315 + fn the_setters_are_the_two_granted_actions() {
316 + let mut bind = bind(SHOW);
317 + let effects = bind
318 + .commit(ZONE, Value::String("America/Denver".into()))
319 + .unwrap();
320 + let [Effect::Run(invocation)] = effects.as_slice() else {
321 + panic!("one command, got {effects:?}")
322 + };
323 + assert_eq!(
324 + invocation.display(),
325 + "timedatectl set-timezone America/Denver",
326 + );
327 +
328 + let effects = bind.commit(NTP, Value::Boolean(false)).unwrap();
329 + let [Effect::Run(invocation)] = effects.as_slice() else {
330 + panic!("one command")
331 + };
332 + assert_eq!(invocation.display(), "timedatectl set-ntp false");
333 + }
334 +
335 + // A zone the machine does not list is refused before any command is built,
336 + // by the same `check_value` the file side uses.
337 + #[test]
338 + fn a_zone_the_machine_does_not_know_is_refused() {
339 + let mut bind = bind(SHOW);
340 + let error = format!(
341 + "{:#}",
342 + bind.commit(ZONE, Value::String("Mars/Olympus".into()))
343 + .unwrap_err()
344 + );
345 + assert!(error.contains("Mars/Olympus"), "{error}");
346 + }
347 +
348 + // The clock is a reading. `set-time` is deliberately not in the polkit
349 + // grant, so a route that tried to write this row would produce a password
350 + // prompt rather than a change, which is worse than a refusal.
351 + #[test]
352 + fn the_clock_cannot_be_written() {
353 + let mut bind = bind(SHOW);
354 + assert!(bind.field(CLOCK).unwrap().readonly);
355 + let error = format!(
356 + "{:#}",
357 + bind.commit(CLOCK, Value::String("Fri 2026-07-24".into()))
358 + .unwrap_err()
359 + );
360 + assert!(error.contains("shown, not set"), "{error}");
361 + }
362 +
363 + // Switched on and actually agreeing with a server are different facts. A
364 + // row that showed only the first would say "on" beside a clock still wrong.
365 + #[test]
366 + fn the_sync_state_is_reported_beside_the_switch() {
367 + let synced = bind(SHOW);
368 + let help = synced.field(NTP).unwrap().description.clone().unwrap();
369 + assert!(help.contains("agreed with a server"), "{help}");
370 +
371 + let waiting = bind(&SHOW.replace("NTPSynchronized=yes", "NTPSynchronized=no"));
372 + let help = waiting.field(NTP).unwrap().description.clone().unwrap();
373 + assert!(help.contains("not synchronized yet"), "{help}");
374 + }
375 +
376 + // A machine with no timesyncd is shown the row and told why it is not a
377 + // switch, which is more use than hiding it.
378 + #[test]
379 + fn a_machine_that_cannot_do_network_time_says_so_instead_of_offering_it() {
380 + let mut bind = bind(&SHOW.replace("CanNTP=yes", "CanNTP=no"));
381 + let field = bind.field(NTP).unwrap();
382 + assert!(field.readonly);
383 + assert!(
384 + field
385 + .description
386 + .as_ref()
387 + .unwrap()
388 + .contains("No network time"),
389 + "{:?}",
390 + field.description,
391 + );
392 + assert!(bind.commit(NTP, Value::Boolean(true)).is_err());
393 + }
394 +
395 + #[test]
396 + fn nothing_is_ever_held_back_for_a_save() {
397 + let mut bind = bind(SHOW);
398 + assert!(!bind.dirty());
399 + bind.commit(NTP, Value::Boolean(false)).unwrap();
400 + assert!(!bind.dirty(), "a command front writes when it is edited");
401 + assert!(bind.save().unwrap().is_empty());
402 + }
403 +
404 + #[test]
405 + fn a_key_value_line_splits_on_the_first_equals_only() {
406 + let parsed = parse_show("Timezone=America/Los_Angeles\nOdd=a=b\nBare\n");
407 + assert_eq!(parsed[0], ("Timezone".into(), "America/Los_Angeles".into()));
408 + assert_eq!(parsed[1], ("Odd".into(), "a=b".into()));
409 + assert_eq!(parsed.len(), 2, "a line with no `=` carries nothing");
410 + }
411 +
412 + // Zones label themselves. A prettier label would break the pick overlay's
413 + // filter, which matches on what is shown as well as on the raw value.
414 + #[test]
415 + fn zones_label_themselves() {
416 + let zones = parse_zones("America/Denver\n\nUTC\n");
417 + assert_eq!(zones.len(), 2, "blank lines are not zones");
418 + assert_eq!(zones[0].value, "America/Denver");
419 + assert_eq!(zones[0].label, "America/Denver");
420 + assert!(zones[0].description.is_none());
421 + }
422 + }