Skip to main content

max / alloy

Open a list row in the text pane, and come back with Esc A record set is more rows than a form row has, and the pane that can already show them is the text-edit fallback, so Enter on a list swaps the app's pane over the same file. Reading and editing arrive together, and neither needs AlloyTable, which renders nothing and would need a cursor, an offset and a selection first. The route reads the field's declared kind. The count row is keyed on Value::Array, so an unset list falls to its "none" arm, and hanging the route there would have left it dead for an empty list - which is when a user most wants the file open, to add the first record. The swap creates a state this view could not have: two buffers over one file. The file is the handoff in both directions instead. The form writes its pending edits before the text pane opens, the form is rebuilt from the file on the way back, and Esc refuses a dirty buffer rather than dropping it, because a file's buffer is every line changed since it was opened.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 18:09 UTC
Commit: f1d46a8790edd61cbb05d0f92f457493847feb9a
Parent: 5232a75
2 files changed, +281 insertions, -7 deletions
M docs/CONSOLE.md +3 -3
@@ -295,7 +295,7 @@
295 295 - **The pick overlay filters as you type.** It was first specced against enums the size of rio's `cursor.shape`, three values. The System tab's zone row is an enum over `timedatectl list-timezones`, which is about 600 entries, and locales are worse. So the overlay owns a `TextField` and matches on a plain substring. No fuzzy match in v1: zone names are already terse and hierarchical, and a ranker over 600 strings is a scoring function to tune for no gain a substring does not give. Typing `den` and pressing enter is also fully offline, needs no database and no license, and cannot be confused by a VPN, which is what retired the idea of geolocating the installer's timezone.
296 296 - **Sections are collapsible.** One pane per schema `[[section]]`, folded with Space on its header, so the 29-slot colors group defaults to a single collapsed row rather than dominating the form. The binary flattens the visible rows each frame (section headers plus the fields of open sections); the `Cursor` rides that.
297 297 - **Save and quit.** Ctrl-S serializes the document back to the target path, logged as `write <path>` (the same `Effect::Write` shape the `alloy pkg` export wrapper established). Quitting with unsaved edits confirms through `AlloyModal`, reusing the Cancel-that-is-not-Quit machinery `alloy pkg` forced into the shell.
298 - - **List-of-tables is read-only in v1.** `type = "list"` records (rio's `bindings.keys`) render through a display-only `AlloyTable`; add, remove, and cell-edit route to the text-edit fallback. Cheap to defer because rio ships bindings empty (Sway owns the global binds). Full table editing is v1.1.
298 + - **List-of-tables opens the file.** Ruled 2026-08-27: `type = "list"` records (rio's `bindings.keys`) show as a count on their form row, and Enter on that row swaps the pane from the form to the text-edit fallback over the same file. Esc comes back. Reading and editing arrive together and neither needs `AlloyTable`, which still renders nothing and would need a cursor, an offset and a selection before it could. The route is on the field's *declared* kind, not on the value, so an empty list opens too — which is when a user most wants the file, to add the first record. The dirty-state answer is that the file is the handoff in both directions: the form writes its pending edits before the text pane opens, the form is rebuilt from the file on the way back, and Esc refuses to leave a dirty buffer rather than discarding it.
299 299 - **Fallback.** A file the form engine cannot render opens in the syntax-highlighted text-edit pane instead, and a diagnostic heads the pane when there is one to show. Two routes into it are built: a schema declaring a syntax with no bind behind it (`sway`, `kdl`, `text`), and a target the `toml_edit` bind refused to parse. The second is the one that changed a behavior rather than adding one — a config that failed to parse used to render as a red paragraph, so the one thing a user could not do with a broken config was open it and fix it.
300 300 - **The syntax is declared, not inferred.** `syntax = ` in the header names the file's language, defaults to `toml`, and answers both questions at once: what to highlight, and whether a form is possible at all. Declaring the language rather than declaring "no form" is what lets a file change tiers without being rewritten — the day the roundtrip-safe-KDL question below resolves, every `syntax = "kdl"` schema starts rendering a form on its own. A schema whose syntax has no form engine must be a header alone, and must name a `target_path`: fields nobody will draw are an authoring error worth reporting, and a schema that opens no file has no reason to be in the catalog.
301 301 - **The pane is not an editor.** No undo, no selection, no search, no clipboard. The image ships `helix`, and a fallback that grew those would be a worse copy of it living inside a settings screen. Its modality matches the form's, for the reason `classify` documents: in Navigate the reserved keymap holds, so `q` and Tab still work; Enter takes the buffer; Esc leaves it *keeping* what was typed, because discarding a file's worth of edits is not the same act as discarding one retyped value. Highlighting is line by line with no state carried between lines, so a TOML multi-line string has its body colored as code — a deliberate trade against re-scanning from the top of the file to draw any line, and sway, the syntax this was built for, has no multi-line construct at all.
@@ -362,7 +362,7 @@
362 362 Shipped with `alloy settings`, as `alloy_tui` 1.2:
363 363 - `AlloyForm`, the form chrome, over rows that are one line each without exception. That is what lets it scroll through the same stateless `list_offset` an `AlloyList` does, and it is why the focused row's help and diagnostic render on a reserved line at the foot of the form rather than under the row. Sections are header rows and an indent, not nested boxes: a box inside a pane spends two columns a side per level to say what the fold marker already says.
364 364 - A single `AlloyField` widget carrying a `FieldKind` value-cell enum (Toggle / Text / Number / Enum / Color), not the five separate field widgets earlier drafts rostered. Those differed only in how the value cell paints once state and validation moved to the binary, and `AlloyForm`'s heterogeneous row list forces an enum regardless; the rationale is worked in full in [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md). It also carries an `unset` flag the roster did not have, which is what lets a row show the difference between a key set to 12 and a key absent from a file that defaults to 12.
365 - - `AlloyPicker`, the filterable overlay an enum opens, and a display-only `AlloyTable` for list-of-tables records. Nothing renders through the table yet; a form row is one line and a table is more than that, so it waits on the detail surface that will hold it.
365 + - `AlloyPicker`, the filterable overlay an enum opens, and a display-only `AlloyTable`. Nothing renders through the table, and list-of-tables no longer waits on it: those records open in the text pane instead. If a read-only table view is ever wanted it is a separate decision, against the cost of giving `AlloyTable` a cursor and an offset it does not have.
366 366 - `TextField` promoted from the console binary's `field.rs` as the caret buffer both the field and the picker's filter use.
367 367
368 368 Not in `alloy_tui`, contrary to earlier drafts: mock-or-real backend detection. It lives per view in the console binary, because what counts as "the real backend" is `nmcli` for one view and `pactl` for another. The pattern carries over from `sysop-tui`; the code does not.
@@ -381,7 +381,7 @@
381 381 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.
382 382
383 383 - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, `alloy setup`, 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.
384 - - **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, and `schemas/sway.schema` is the header that declares it). 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) live diagnostics, the collapsible sections having landed with the tab, presets having been removed from the DSL; (9) the text-edit fallback, **shipped**, which is also what puts a schema-less app in the list at all: the catalog is built from `.schema` files, so sway reaches it through a header-only schema declaring `syntax = "sway"` rather than through a second catalog; (10) `AlloyTable` read-only.
384 + - **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, and `schemas/sway.schema` is the header that declares it). 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) live diagnostics, the collapsible sections having landed with the tab, presets having been removed from the DSL; (9) the text-edit fallback, **shipped**, which is also what puts a schema-less app in the list at all: the catalog is built from `.schema` files, so sway reaches it through a header-only schema declaring `syntax = "sway"` rather than through a second catalog; (10) `AlloyTable` read-only, no longer on the path to anything: list-of-tables routes to the text pane.
385 385 - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode).
386 386 - **Shipped, and the only screen that is not a question.** `alloy install`'s credits page, added after the summary as a sixth step. It names each project Alloy ships, its SPDX identifier and its URL, off `crates/alloy/credits.toml` embedded in the binary. The install now starts from this screen rather than from the summary, which is the ordering the page argues for: the last screen before a disk is repartitioned should be the one that says whose work is about to be written to it. Curated rather than generated, so it drifts by design and keeping it current is a release step (see [IMAGE.md](IMAGE.md)). Not a source offer and not a license-text viewer: the full texts stay in `/usr/share/licenses` on the installed system.
387 387 - **Shipped as a view, with its enforcement half still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. usbguard itself is in both profiles as of 2026-08-22 and its daemon is not enabled, which is the deliberate state rather than an oversight: the package's stock policy is an empty rule file plus `ImplicitPolicyTarget=block`, so arming it before the policy exists deauthorizes the keyboard at boot. The keyboard gate landed the same day and is the part that makes deny-unknown safe: `usr/bin/alloy-usb-gate` drops enforcement whenever the machine has zero usable keyboards, continuously rather than at boot, on the client profile only. It counts `ID_INPUT_KEYBOARD` and not `ID_INPUT_KEY`, which is what keeps a power button from reading as a keyboard, and it reads the input subsystem rather than the USB bus, which is what makes the Framework's i8042 keyboard count without a special case. What is left is the policy itself: deny-unknown, with an activation the user performs from this screen.
@@ -61,7 +61,7 @@
61 61 use crate::bind::{Bind, FileBind};
62 62 use crate::cli::{CommandLog, contract_home};
63 63 use crate::editor::Editor;
64 - use crate::schema::{self, Field, Schema, Section};
64 + use crate::schema::{self, Field, Schema, Section, Syntax};
65 65 use crate::shell::{Confirm, Flow, View};
66 66 use crate::system::SystemBind;
67 67
@@ -166,7 +166,17 @@
166 166 /// error, kept as a field of its own because `alloy config <path>` matches
167 167 /// on it and cannot reach into a `Form` that was never built.
168 168 target: Option<PathBuf>,
169 + /// The syntax the schema declared, kept so a form row can open the same
170 + /// file as text without re-reading the schema to find out how to colour it.
171 + syntax: Syntax,
169 172 state: Result<Pane, String>,
173 + /// Whether the text pane is standing in for this app's form.
174 + ///
175 + /// Set only by the list-row route below, and it is what makes Esc mean
176 + /// "back to the form" there and "close the view" everywhere else. An app
177 + /// whose schema declares a syntax with no form engine is a text pane
178 + /// permanently, and Esc must not try to build it a form it never had.
179 + text_over_form: bool,
170 180 }
171 181
172 182 /// What an opened app shows: a form, or the file as text.
@@ -327,7 +337,9 @@
327 337 name: fallback,
328 338 source: source.to_path_buf(),
329 339 target: None,
340 + syntax: Syntax::default(),
330 341 state: Err(reason),
342 + text_over_form: false,
331 343 };
332 344 };
333 345 let target = expand(&recovered.target_path);
@@ -341,7 +353,9 @@
341 353 name: recovered.target_tool.unwrap_or(fallback),
342 354 source: source.to_path_buf(),
343 355 target,
356 + syntax: recovered.syntax,
344 357 state,
358 + text_over_form: false,
345 359 };
346 360 }
347 361 };
@@ -375,7 +389,9 @@
375 389 name,
376 390 source: source.to_path_buf(),
377 391 target,
392 + syntax,
378 393 state,
394 + text_over_form: false,
379 395 }
380 396 }
381 397 }
@@ -884,9 +900,9 @@
884 900 };
885 901
886 902 let text = match (&value, &field.kind) {
887 - // A v1 list renders read-only, and an AlloyTable is more rows than a
888 - // form row has. Summarising is what fits until the table lands; the
889 - // count is the part a user scanning the form wants.
903 + // A list is more rows than a form row has, so the row carries the
904 + // count and Enter opens the file in the text pane (`open_as_text`).
905 + // The count is the part a user scanning the form wants.
890 906 (Some(Value::Array(rows)), _) => match rows.len() {
891 907 1 => "1 entry".to_string(),
892 908 n => format!("{n} entries"),
@@ -1141,6 +1157,99 @@
1141 1157 }
1142 1158 }
1143 1159
1160 + /// Whether the row under the cursor is a list, by the kind the schema
1161 + /// declared.
1162 + ///
1163 + /// On the declared kind and not on the value, which is the trap here: the
1164 + /// form's own count row reads `Value::Array`, so an unset list falls to its
1165 + /// "none" arm. Hanging the route off that predicate would leave it dead for
1166 + /// an empty list, which is exactly when a user most wants the file open to
1167 + /// add the first record.
1168 + fn on_list_row(&self) -> bool {
1169 + self.tab() == Tab::Applications
1170 + && self
1171 + .form()
1172 + .and_then(Form::selected_field)
1173 + .is_some_and(|field| matches!(field.kind, schema::FieldKind::List { .. }))
1174 + }
1175 +
1176 + /// Open the selected app's file as text, over its form.
1177 + ///
1178 + /// docs/CONSOLE.md's answer to list-of-tables in v1: a record set is more
1179 + /// rows than a form row has, and the pane that can already show them is the
1180 + /// text-edit fallback. Ruled 2026-08-28 (GoingsOn alloy `bd021f04`).
1181 + ///
1182 + /// **The form's pending edits are written first**, and that is the whole of
1183 + /// the dirty-state story. Two buffers over one file is a state this view
1184 + /// could not have before the swap existed, and the only version with one
1185 + /// source of truth at every instant is the one where the file *is* the
1186 + /// handoff: the form flushes on the way in, the form is rebuilt from the
1187 + /// file on the way out, and neither direction can discard an edit. The
1188 + /// write is an ordinary `Effect::Write` and lands in the command log, so it
1189 + /// is a visible save rather than a quiet one.
1190 + fn open_as_text(&mut self, log: &mut CommandLog) {
1191 + let Some(index) = self.cursor.selected() else {
1192 + return;
1193 + };
1194 + let Some(app) = self.apps.get_mut(index) else {
1195 + return;
1196 + };
1197 + let Some(path) = app.target.clone() else {
1198 + return;
1199 + };
1200 +
1201 + if let Ok(Pane::Form(form)) = &mut app.state
1202 + && form.bind.dirty()
1203 + && let Err(error) = form.save(log)
1204 + {
1205 + // Nothing swaps. Opening the text pane over a file the form could
1206 + // not write would show the user the old contents and lose the
1207 + // edits behind them.
1208 + self.error = Some(error);
1209 + return;
1210 + }
1211 +
1212 + match Editor::open(&path, app.syntax, None) {
1213 + Ok(editor) => {
1214 + app.state = Ok(Pane::Text(editor));
1215 + app.text_over_form = true;
1216 + }
1217 + Err(reason) => self.error = Some(reason),
1218 + }
1219 + }
1220 +
1221 + /// Go back to the form the text pane is standing in for.
1222 + ///
1223 + /// Rebuilt from the file rather than kept aside, for the reason above: the
1224 + /// text pane may have changed anything, including the shape of what the
1225 + /// form is bound to, so a form held in memory across the swap would be
1226 + /// describing a file that has moved under it.
1227 + ///
1228 + /// A dirty buffer refuses. Esc discards a field's buffer because retyping
1229 + /// one value is cheap; a file's buffer is every line the user has changed
1230 + /// since they opened it, and [`Editor::end_edit`] already says that is not
1231 + /// the same act. So the way back is Ctrl-S, and the message says so.
1232 + fn return_to_form(&mut self) -> bool {
1233 + let Some(index) = self.cursor.selected() else {
1234 + return false;
1235 + };
1236 + let Some(app) = self.apps.get_mut(index) else {
1237 + return false;
1238 + };
1239 + if !app.text_over_form {
1240 + return false;
1241 + }
1242 + if app.state.as_ref().is_ok_and(Pane::dirty) {
1243 + self.error = Some(
1244 + "the file has unwritten edits; Ctrl-S writes them, then Esc returns to the form"
1245 + .to_string(),
1246 + );
1247 + return true;
1248 + }
1249 + *app = App::open(&app.source.clone());
1250 + true
1251 + }
1252 +
1144 1253 /// Whether any app is holding edits that are not on disk.
1145 1254 fn unsaved(&self) -> bool {
1146 1255 self.apps
@@ -1622,6 +1731,12 @@
1622 1731 }
1623 1732 return Flow::Continue;
1624 1733 }
1734 + // One more layer than there used to be, and it is only a layer when a
1735 + // list row put it there: a text pane the schema asked for is the app,
1736 + // not a detour, and Esc closes the view from it as it always did.
1737 + if self.return_to_form() {
1738 + return Flow::Continue;
1739 + }
1625 1740 self.quit()
1626 1741 }
1627 1742
@@ -1762,6 +1877,10 @@
1762 1877 if self.on_form() {
1763 1878 if let Some(editor) = self.editor_mut() {
1764 1879 editor.begin_edit();
1880 + } else if self.on_list_row() {
1881 + // A list has no single value to type, so Enter opens
1882 + // the file instead of a buffer.
1883 + self.open_as_text(log);
1765 1884 } else if let Some(form) = self.form_mut() {
1766 1885 // A closed vocabulary picks; anything else edits.
1767 1886 if !form.begin_pick() {
@@ -2375,10 +2494,165 @@
2375 2494 name: "rio".into(),
2376 2495 source: PathBuf::from("rio.toml.schema"),
2377 2496 target: Some(PathBuf::from("/tmp/rio.toml")),
2497 + syntax: Syntax::default(),
2378 2498 state: Ok(Pane::Form(Box::new(Form::new(Box::new(bind))))),
2499 + text_over_form: false,
2379 2500 })
2380 2501 }
2381 2502
2503 + // ---- a list row opens the file as text ----
2504 +
2505 + /// A schema declaring a list, over a file this test owns.
2506 + ///
2507 + /// The list field is `bindings.keys` from `testdata/rio.toml.schema`, which
2508 + /// is the only list-of-tables in the tree and the reason that fixture is
2509 + /// kept whole. Copied here with an absolute `target_path` rather than read
2510 + /// from disk: what is being tested is the route, and the route needs a
2511 + /// target the test can write and read back.
2512 + fn list_app(label: &str) -> (App, PathBuf, PathBuf) {
2513 + let dir = std::env::temp_dir().join(format!("alloy-list-{}-{label}", std::process::id()));
2514 + std::fs::create_dir_all(&dir).expect("a temp directory");
2515 + let target = dir.join("rio.toml");
2516 + let source = dir.join("rio.toml.schema");
2517 + std::fs::write(
2518 + &source,
2519 + format!(
2520 + "[schema]\n\
2521 + target = \"rio.toml\"\n\
2522 + target_path = \"{}\"\n\
2523 + target_tool = \"rio\"\n\
2524 + schema_version = \"1\"\n\
2525 + \n\
2526 + [[field]]\n\
2527 + path = \"cursor.blinking\"\n\
2528 + type = \"bool\"\n\
2529 + default = false\n\
2530 + \n\
2531 + [[field]]\n\
2532 + path = \"bindings.keys\"\n\
2533 + type = \"list\"\n\
2534 + default = []\n\
2535 + element = {{ type = \"table\", fields = [\n\
2536 + {{ path = \"key\", type = \"string\", required = true }},\n\
2537 + {{ path = \"action\", type = \"string\", required = true }},\n\
2538 + ] }}\n",
2539 + target.display()
2540 + ),
2541 + )
2542 + .expect("the schema is writable");
2543 + std::fs::write(&target, "").expect("the target is writable");
2544 + (App::open(&source), target, dir)
2545 + }
2546 +
2547 + /// Move the cursor to the list row and press Enter.
2548 + fn enter_list_row(view: &mut SettingsView, log: &mut CommandLog) {
2549 + let row = view
2550 + .form()
2551 + .expect("a form")
2552 + .rows()
2553 + .iter()
2554 + .position(|row| matches!(row, Row::Field(field) if field.path == "bindings.keys"))
2555 + .expect("the list row is in the form");
2556 + for _ in 0..row {
2557 + view.handle(key(KeyCode::Char('j')), log);
2558 + }
2559 + view.handle(key(KeyCode::Enter), log);
2560 + }
2561 +
2562 + // A record set is more rows than a form row has, so Enter on one opens the
2563 + // pane that can already show them. The predicate is the declared kind, not
2564 + // the value: this list is empty, which is when a user most wants the file.
2565 + #[test]
2566 + fn enter_on_an_empty_list_opens_the_file_as_text() {
2567 + let (app, _target, dir) = list_app("empty");
2568 + let mut view = SettingsView::file(app);
2569 + let mut log = CommandLog::new();
2570 +
2571 + assert!(
2572 + matches!(view.pane(), Some(Pane::Form(_))),
2573 + "starts on a form"
2574 + );
2575 + enter_list_row(&mut view, &mut log);
2576 + assert!(
2577 + matches!(view.pane(), Some(Pane::Text(_))),
2578 + "the list row did not open the text pane"
2579 + );
2580 +
2581 + std::fs::remove_dir_all(dir).ok();
2582 + }
2583 +
2584 + // The dirty-state answer: the file is the handoff in both directions, so
2585 + // the form writes before the text pane opens over it. A text buffer showing
2586 + // the pre-edit file would be showing the user something already untrue.
2587 + #[test]
2588 + fn the_forms_pending_edits_are_written_before_the_text_pane_opens() {
2589 + let (app, target, dir) = list_app("flush");
2590 + let mut view = SettingsView::file(app);
2591 + let mut log = CommandLog::new();
2592 +
2593 + // Space flips the bool row, which is one pending edit and no typing.
2594 + view.handle(key(KeyCode::Char(' ')), &mut log);
2595 + assert!(view.unsaved(), "the flip is pending");
2596 +
2597 + enter_list_row(&mut view, &mut log);
2598 + let on_disk = std::fs::read_to_string(&target).expect("the target is readable");
2599 + assert!(
2600 + on_disk.contains("blinking"),
2601 + "the pending edit did not reach the file: {on_disk:?}"
2602 + );
2603 +
2604 + std::fs::remove_dir_all(dir).ok();
2605 + }
2606 +
2607 + // Esc is one layer at a time, and the swap added a layer. The form comes
2608 + // back from the file rather than from memory, since the text pane may have
2609 + // changed anything, including what the form is bound to.
2610 + #[test]
2611 + fn esc_returns_from_the_text_pane_to_the_form() {
2612 + let (app, _target, dir) = list_app("back");
2613 + let mut view = SettingsView::file(app);
2614 + let mut log = CommandLog::new();
2615 +
2616 + enter_list_row(&mut view, &mut log);
2617 + assert!(
2618 + matches!(view.cancel(), Flow::Continue),
2619 + "Esc closed the view"
2620 + );
2621 + assert!(
2622 + matches!(view.pane(), Some(Pane::Form(_))),
2623 + "Esc did not come back to the form"
2624 + );
2625 + // And from the form it closes, as it always did.
2626 + assert!(matches!(view.cancel(), Flow::Exit));
2627 +
2628 + std::fs::remove_dir_all(dir).ok();
2629 + }
2630 +
2631 + // No path may silently discard an edit, and a file's buffer is every line
2632 + // the user has changed since they opened it. So Esc refuses rather than
2633 + // dropping them, and says which key writes them.
2634 + #[test]
2635 + fn esc_refuses_to_leave_a_dirty_text_buffer_and_says_what_to_press() {
2636 + let (app, _target, dir) = list_app("dirty");
2637 + let mut view = SettingsView::file(app);
2638 + let mut log = CommandLog::new();
2639 +
2640 + enter_list_row(&mut view, &mut log);
2641 + view.handle(key(KeyCode::Enter), &mut log);
2642 + view.handle(key(KeyCode::Char('x')), &mut log);
2643 + assert!(view.unsaved(), "the buffer is dirty");
2644 + // One layer at a time: the first Esc leaves the buffer, keeping what
2645 + // was typed, and the second is the one that would go back.
2646 + view.cancel();
2647 + view.cancel();
2648 +
2649 + assert!(matches!(view.pane(), Some(Pane::Text(_))), "left anyway");
2650 + let error = view.error.clone().expect("a reason on screen");
2651 + assert!(error.contains("Ctrl-S"), "got: {error}");
2652 +
2653 + std::fs::remove_dir_all(dir).ok();
2654 + }
2655 +
2382 2656 // The title carries the file, because nothing else on the screen does:
2383 2657 // there is no tab bar under it saying which of two things is showing.
2384 2658 #[test]