Skip to main content

max / alloy

settings: the hostname and locale rows Three fronts under one form now: timedatectl, hostnamectl, localectl. Five writing commands between them and every one is in the five actions the polkit rule grants, so none prompts and none can reach an action left prompting. `--static` on the hostname setter is load-bearing, not tidiness. Without it hostnamectl also sets the pretty name, which needs set-machine-info, which is deliberately not granted, and the row would prompt. The rules file says so in its own header and now the code matches it. The hostname rule is the installer's, called rather than restated. Both screens write the same file, and a name one takes and the other refuses would be a bug in whichever the user reached second. localectl grew no --json, so its labelled block is parsed by label: one this does not recognise is skipped and `(unset)` reads as nothing rather than as a keymap named that. The failure mode of an unfamiliar build is a row that reads unset, never one that reads wrong. One rule for rows that cannot be written, applied everywhere: shown, not settable, and says why. A machine with no timesyncd, a hostnamectl that did not answer, a localectl built without console keymaps — this box is the last of those, listing zero keymaps and offering no set-keymap verb at all, which is what the case was written against.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 19:33 UTC
Signed with PGP, not checked
Commit: ebdd3c74a89a4daa6c373787da57acc126c178b1
Parent: bc7d637
4 files changed, +345 insertions, -96 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. 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.
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, hostname and locale are built, fronting `timedatectl`, `hostnamectl` and `localectl`; theme is the one left. Each writing row runs exactly one command, and every one of those is in the five actions the shipped polkit rule grants, so none of them prompts and none can reach an action deliberately left prompting. A row whose front did not answer, or whose vocabulary came back empty, is shown and not settable and says why: hiding it would answer "where do I set this" with silence, and offering it would promise a command that is not there. 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, **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.
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, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (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.)
@@ -201,7 +201,7 @@
201 201 ///
202 202 /// Returns the message shown to the user, so each case says what to do rather
203 203 /// than that something is wrong.
204 - fn validate_hostname(name: &str) -> Result<(), String> {
204 + pub(crate) fn validate_hostname(name: &str) -> Result<(), String> {
205 205 if name.is_empty() {
206 206 return Err("a hostname is required".into());
207 207 }
@@ -1968,12 +1968,40 @@
1968 1968 let form = system_form();
1969 1969 assert_eq!(
1970 1970 labels(&form),
1971 - ["[time]", "time.zone", "time.ntp", "time.clock"],
1971 + [
1972 + "[time]",
1973 + "time.zone",
1974 + "time.ntp",
1975 + "time.clock",
1976 + "[hostname]",
1977 + "hostname.static",
1978 + "[locale]",
1979 + "locale.lang",
1980 + "locale.keymap",
1981 + ],
1982 + "three fronts, one form, sections in declared order",
1972 1983 );
1973 1984 assert_eq!(
1974 1985 cell(form.bind.as_ref(), form.bind.field("time.zone").unwrap()).text,
1975 1986 "America/Los_Angeles",
1976 1987 );
1988 + assert_eq!(
1989 + cell(
1990 + form.bind.as_ref(),
1991 + form.bind.field("hostname.static").unwrap()
1992 + )
1993 + .text,
1994 + "fw13",
1995 + );
1996 + // The fixture's console keymap is `(unset)`, which reads as unset
1997 + // rather than as a keymap named that.
1998 + assert!(
1999 + cell(
2000 + form.bind.as_ref(),
2001 + form.bind.field("locale.keymap").unwrap()
2002 + )
2003 + .unset
2004 + );
1977 2005 }
1978 2006
1979 2007 // A command front writes when the row is edited, so there is never anything
@@ -1,7 +1,7 @@
1 - //! The System tab's bind: live state, fronted by a command.
1 + //! The System tab's bind: live state, fronted by commands.
2 2 //!
3 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
4 + //! reads a `DocumentMut` and writes at save; this reads three `*ctl` tools and
5 5 //! hands back a setter per row. The form above them cannot tell which it is
6 6 //! holding, which is the whole point of [[alloy-settings]] putting two tabs
7 7 //! over one engine.
@@ -9,21 +9,45 @@
9 9 //! ## Why the field list is hand-written
10 10 //!
11 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.
12 + //! handful of rows fronting different CLIs, each with its own vocabulary and
13 + //! its own setter, and a schema DSL general enough to describe `timedatectl`
14 + //! would be a second language to maintain for no reader. So the table is Rust,
15 + //! and the only thing it shares with a schema is the [`Field`] it produces.
16 16 //!
17 17 //! ## Permissions are narrow on principle
18 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`.
19 + //! Five writing commands, no more:
20 + //!
21 + //! | row | command | granted action |
22 + //! |---|---|---|
23 + //! | zone | `timedatectl set-timezone` | `timedate1.set-timezone` |
24 + //! | ntp | `timedatectl set-ntp` | `timedate1.set-ntp` |
25 + //! | static | `hostnamectl --static hostname` | `hostname1.set-static-hostname` |
26 + //! | lang | `localectl set-locale` | `locale1.set-locale` |
27 + //! | keymap | `localectl set-keymap` | `locale1.set-keyboard` |
28 + //!
29 + //! Every one is in the five `usr/share/polkit-1/rules.d/50-alloy-settings.rules`
30 + //! grants, so none prompts, and nothing here can reach an action deliberately
31 + //! left prompting. Two consequences that look like quirks and are not:
32 + //!
33 + //! - **`--static` is load-bearing.** `hostnamectl hostname NAME` with no scope
34 + //! also sets the pretty name, which needs `set-machine-info`, which is not
35 + //! granted. Widening the grant to save a flag would be the wrong trade, and
36 + //! the rules file says so in its own header.
37 + //! - **The clock is shown and never set.** `set-time` is not granted, moving a
38 + //! system clock invalidates certificates and reorders logs, and the row
39 + //! exists to show what changing the timezone did.
40 + //!
41 + //! ## A front that did not answer
42 + //!
43 + //! One rule, applied the same way everywhere: a row whose front did not answer,
44 + //! or whose vocabulary came back empty, is **shown and not settable, and says
45 + //! why**. A machine with no `timesyncd` still sees the network-time row; a
46 + //! `localectl` built without console keymaps still sees the keymap row. Hiding
47 + //! it would answer "where do I set this" with silence, and offering it would
48 + //! promise a command that is not there.
49 + //!
50 + //! See wiki note `alloy-privilege`.
27 51 //!
28 52 //! <!-- wiki: alloy-settings -->
29 53
@@ -34,125 +58,238 @@
34 58
35 59 use crate::bind::Bind;
36 60 use crate::cli::{CommandLog, Effect, Invocation};
37 - use crate::schema::{EnumValue, Field, FieldKind, Section};
61 + use crate::install::validate_hostname;
62 + use crate::schema::{EnumValue, Field, FieldKind, Section, check_value};
38 63
39 64 /// Paths the rows are addressed by. Dotted like a schema's, so the form groups
40 65 /// them under a section header the same way.
41 66 const ZONE: &str = "time.zone";
42 67 const NTP: &str = "time.ntp";
43 68 const CLOCK: &str = "time.clock";
69 + const HOSTNAME: &str = "hostname.static";
70 + const LOCALE: &str = "locale.lang";
71 + const KEYMAP: &str = "locale.keymap";
44 72
45 - /// Live system settings, fronted by `timedatectl`.
73 + /// Live system settings, fronted by `timedatectl`, `hostnamectl`, `localectl`.
46 74 pub(crate) struct SystemBind {
47 75 sections: Vec<Section>,
48 76 fields: Vec<Field>,
49 - /// Last `timedatectl show`, as key=value.
50 - state: Vec<(String, String)>,
77 + time: Vec<(String, String)>,
78 + host: Host,
79 + locale: Locale,
80 + }
81 +
82 + /// What `hostnamectl` reported.
83 + #[derive(Default)]
84 + struct Host {
85 + /// `/etc/hostname`, or `None` on a machine that has never had one set.
86 + fixed: Option<String>,
87 + /// The name the machine is actually answering to.
88 + effective: Option<String>,
89 + /// `static`, `transient`, or `default`.
90 + source: Option<String>,
91 + answered: bool,
92 + }
93 +
94 + /// What `localectl` reported.
95 + #[derive(Default)]
96 + struct Locale {
97 + lang: Option<String>,
98 + keymap: Option<String>,
99 + answered: bool,
51 100 }
52 101
53 102 impl SystemBind {
54 103 /// Read the machine's state and build the rows, or `None` when there is no
55 104 /// `timedatectl` answering.
56 105 ///
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.
106 + /// `timedatectl` is the anchor: no systemd, no live state, no tab. The
107 + /// other two are read best-effort, and rows whose front stayed quiet are
108 + /// marked in [`annotate`](Self::annotate) rather than dropped.
109 + ///
110 + /// The probe runs the real command rather than looking for the binary, for
111 + /// the reason `net` gives: a tool that cannot reach its daemon is worse
112 + /// than one that is absent, and only running it reveals that.
60 113 pub(crate) fn detect(log: &mut CommandLog) -> Option<Self> {
61 - let state = parse_show(&show().run(log).ok()?);
62 - if state.is_empty() {
114 + let time = parse_show(&show().run(log).ok()?);
115 + if time.is_empty() {
63 116 return None;
64 117 }
65 118
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();
119 + // 598 zones and several hundred locales on a current systemd, which is
120 + // the size that forced the pick overlay to filter as you type.
121 + let zones = list(log, &Invocation::new("timedatectl").arg("list-timezones"));
122 + let locales = list(log, &Invocation::new("localectl").arg("list-locales"));
123 + let keymaps = list(log, &Invocation::new("localectl").arg("list-keymaps"));
73 124
74 125 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,
126 + sections: sections(),
127 + fields: rows(zones, locales, keymaps),
128 + time,
129 + host: read_host(log),
130 + locale: read_locale(log),
81 131 };
82 132 bind.annotate();
83 133 Some(bind)
84 134 }
85 135
86 - fn get(&self, key: &str) -> Option<&str> {
87 - self.state
136 + fn time(&self, key: &str) -> Option<&str> {
137 + self.time
88 138 .iter()
89 139 .find(|(name, _)| name == key)
90 140 .map(|(_, value)| value.as_str())
91 141 }
92 142
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 - };
143 + fn field_mut(&mut self, path: &str) -> Option<&mut Field> {
144 + self.fields.iter_mut().find(|field| field.path == path)
145 + }
111 146
112 - if let Some(field) = self.fields.iter_mut().find(|field| field.path == NTP) {
113 - field.description = Some(note);
114 - field.readonly = !can;
147 + /// How many choices a row's enum carries.
148 + fn choices(&self, path: &str) -> &[EnumValue] {
149 + match self.fields.iter().find(|field| field.path == path) {
150 + Some(Field {
151 + kind: FieldKind::Enum { values, .. },
152 + ..
153 + }) => values,
154 + _ => &[],
115 155 }
116 156 }
157 +
158 + /// Fold the state that is *about* a row into that row's help line, and
159 + /// close any row whose front cannot take a write.
160 + ///
161 + /// Run after every read, because all of it moves: `NTPSynchronized` flips
162 + /// some seconds after boot, and a hostname's source changes the moment this
163 + /// screen sets one.
164 + fn annotate(&mut self) {
165 + // Whether network time is switched on and whether it has actually
166 + // agreed with a server are two different facts. A row showing only the
167 + // first would say "on" beside a clock that is still wrong.
168 + let can_ntp = self.time("CanNTP") != Some("no");
169 + let synced = self.time("NTPSynchronized") == Some("yes");
170 + let note = if can_ntp {
171 + if synced {
172 + "Network time. The clock has agreed with a server."
173 + } else {
174 + "Network time. The clock has not synchronized yet."
175 + }
176 + } else {
177 + "No network time service on this machine."
178 + };
179 + set(self.field_mut(NTP), note, !can_ntp);
180 +
181 + // `--static` is what this row writes, so it is what the row is named
182 + // for. Saying which name the machine is currently answering to is the
183 + // part a user cannot see anywhere else on the screen.
184 + let note = match (self.host.answered, self.host.source.as_deref()) {
185 + (false, _) => "hostnamectl did not answer.".to_string(),
186 + (true, Some(source)) if source != "static" => format!(
187 + "The machine's persistent name. Currently {source}; setting it here makes it stick."
188 + ),
189 + (true, _) => "The machine's persistent name.".to_string(),
190 + };
191 + let quiet = !self.host.answered;
192 + set(self.field_mut(HOSTNAME), &note, quiet);
193 +
194 + let (note, closed) = if !self.locale.answered {
195 + ("localectl did not answer.", true)
196 + } else if self.choices(LOCALE).is_empty() {
197 + ("This machine lists no locales.", true)
198 + } else {
199 + ("System locale. Applies to new sessions.", false)
200 + };
201 + set(self.field_mut(LOCALE), note, closed);
202 +
203 + let (note, closed) = if !self.locale.answered {
204 + ("localectl did not answer.", true)
205 + } else if self.choices(KEYMAP).is_empty() {
206 + // Observed on a systemd built without console keymap support: the
207 + // list is empty and `set-keymap` is not even a verb.
208 + ("This machine lists no virtual console keymaps.", true)
209 + } else {
210 + (
211 + "Virtual console keymap. The graphical session has its own.",
212 + false,
213 + )
214 + };
215 + set(self.field_mut(KEYMAP), note, closed);
216 + }
217 + }
218 +
219 + /// Apply a help line and a read-only flag to a row.
220 + fn set(field: Option<&mut Field>, note: &str, readonly: bool) {
221 + if let Some(field) = field {
222 + field.description = Some(note.to_string());
223 + field.readonly = readonly;
224 + }
117 225 }
118 226
119 227 fn show() -> Invocation {
120 228 Invocation::new("timedatectl").arg("show")
121 229 }
122 230
123 - /// The three time rows.
124 - fn rows(zones: Vec<EnumValue>) -> Vec<Field> {
231 + /// Run a list-the-vocabulary command, or nothing if it will not.
232 + fn list(log: &mut CommandLog, invocation: &Invocation) -> Vec<EnumValue> {
233 + invocation
234 + .run(log)
235 + .map(|raw| parse_list(&raw))
236 + .unwrap_or_default()
237 + }
238 +
239 + fn sections() -> Vec<Section> {
125 240 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 - },
241 + Section {
242 + path: "time".to_string(),
243 + description: Some("Clock, timezone, and network time.".to_string()),
135 244 },
245 + Section {
246 + path: "hostname".to_string(),
247 + description: Some("What this machine calls itself.".to_string()),
248 + },
249 + Section {
250 + path: "locale".to_string(),
251 + description: Some("Language and keyboard.".to_string()),
252 + },
253 + ]
254 + }
255 +
256 + /// Every row. Help lines and read-only flags are [`SystemBind::annotate`]'s.
257 + fn rows(zones: Vec<EnumValue>, locales: Vec<EnumValue>, keymaps: Vec<EnumValue>) -> Vec<Field> {
258 + let enumerated = |path: &str, values: Vec<EnumValue>| Field {
259 + path: path.to_string(),
260 + description: None,
261 + required: false,
262 + readonly: false,
263 + kind: FieldKind::Enum {
264 + default: None,
265 + values,
266 + },
267 + };
268 + let text = |path: &str, readonly: bool| Field {
269 + path: path.to_string(),
270 + description: None,
271 + required: false,
272 + readonly,
273 + kind: FieldKind::Str {
274 + default: None,
275 + pattern: None,
276 + },
277 + };
278 +
279 + vec![
280 + enumerated(ZONE, zones),
136 281 Field {
137 282 path: NTP.to_string(),
138 - // Replaced by `annotate` with what the machine actually reports.
139 283 description: None,
140 284 required: false,
141 285 readonly: false,
142 286 kind: FieldKind::Bool { default: None },
143 287 },
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 - },
288 + // Not a setting. See the module docs.
289 + text(CLOCK, true),
290 + text(HOSTNAME, false),
291 + enumerated(LOCALE, locales),
292 + enumerated(KEYMAP, keymaps),
156 293 ]
157 294 }
158 295
@@ -168,21 +305,104 @@
168 305 .collect()
169 306 }
170 307
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> {
308 + /// One entry per line, labelling itself.
309 + ///
310 + /// `America/Denver` and `en_GB.UTF-8` are already what a person would look for,
311 + /// and inventing a prettier label would break the pick overlay's filter, which
312 + /// matches on what is shown as well as on the raw value.
313 + fn parse_list(raw: &str) -> Vec<EnumValue> {
175 314 raw.lines()
176 315 .map(str::trim)
177 316 .filter(|line| !line.is_empty())
178 - .map(|zone| EnumValue {
179 - value: zone.to_string(),
180 - label: zone.to_string(),
317 + .map(|entry| EnumValue {
318 + value: entry.to_string(),
319 + label: entry.to_string(),
181 320 description: None,
182 321 })
183 322 .collect()
184 323 }
185 324
325 + fn read_host(log: &mut CommandLog) -> Host {
326 + Invocation::new("hostnamectl")
327 + .arg("--json=short")
328 + .run(log)
329 + .map_or_else(|_| Host::default(), |raw| parse_host(&raw))
330 + }
331 +
332 + /// Parse `hostnamectl --json=short`.
333 + ///
334 + /// JSON rather than `hostnamectl status`, which is laid out for reading. Both
335 + /// `StaticHostname` and `Hostname` are taken: the first is what this row writes
336 + /// and is null on a machine that has never had one set, and the second is what
337 + /// the machine is answering to meanwhile, which is what the row should show
338 + /// rather than an empty cell on a machine that plainly has a name.
339 + fn parse_host(raw: &str) -> Host {
340 + let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) else {
341 + return Host::default();
342 + };
343 + let text = |key: &str| {
344 + json.get(key)
345 + .and_then(serde_json::Value::as_str)
346 + .map(str::to_string)
347 + };
348 + Host {
349 + fixed: text("StaticHostname"),
350 + effective: text("Hostname"),
351 + source: text("HostnameSource"),
352 + answered: true,
353 + }
354 + }
355 +
356 + fn read_locale(log: &mut CommandLog) -> Locale {
357 + Invocation::new("localectl")
358 + .arg("status")
359 + .run(log)
360 + .map_or_else(|_| Locale::default(), |raw| parse_locale(&raw))
361 + }
362 +
363 + /// Parse `localectl status`, which has no machine-readable mode.
364 + ///
365 + /// `localectl` grew no `--json`, so this reads the labelled block it prints:
366 + ///
367 + /// ```text
368 + /// System Locale: LANG=en_US.UTF-8
369 + /// VC Keymap: (unset)
370 + /// X11 Layout: us
371 + /// ```
372 + ///
373 + /// Two things keep that honest. A label this does not recognize is skipped
374 + /// rather than guessed at, and `(unset)` is read as nothing rather than as a
375 + /// keymap literally named that. So the failure mode of an unfamiliar build is a
376 + /// row that reads unset, never one that reads wrong.
377 + fn parse_locale(raw: &str) -> Locale {
378 + let mut locale = Locale {
379 + answered: true,
380 + ..Locale::default()
381 + };
382 + for line in raw.lines() {
383 + let Some((key, value)) = line.split_once(':') else {
384 + continue;
385 + };
386 + let value = value.trim();
387 + if value.is_empty() || value == "(unset)" {
388 + continue;
389 + }
390 + match key.trim() {
391 + // The block can carry several locale variables; LANG is the one
392 + // this screen offers, so it is the one picked out.
393 + "System Locale" => {
394 + locale.lang = value
395 + .split_whitespace()
396 + .find_map(|token| token.strip_prefix("LANG="))
397 + .map(str::to_string);
398 + }
399 + "VC Keymap" => locale.keymap = Some(value.to_string()),
400 + _ => {}
401 + }
402 + }
403 + locale
404 + }
405 +
186 406 /// systemd spells its booleans `yes` and `no`.
187 407 fn yes(raw: Option<&str>) -> bool {
188 408 raw == Some("yes")
@@ -190,7 +410,7 @@
190 410
191 411 impl Bind for SystemBind {
192 412 fn origin(&self) -> String {
193 - "timedatectl".to_string()
413 + "timedatectl, hostnamectl, localectl".to_string()
194 414 }
195 415
196 416 fn sections(&self) -> &[Section] {
@@ -202,23 +422,50 @@
202 422 }
203 423
204 424 fn read(&self, path: &str) -> Option<Value> {
425 + let text = |value: Option<&String>| value.map(|text| Value::String(text.clone()));
205 426 match path {
206 427 ZONE => self
207 - .get("Timezone")
428 + .time("Timezone")
Lines truncated