max / alloy
6 files changed,
+657 insertions,
-60 deletions
| @@ -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 | + | } |