Skip to main content

max / alloy

Open a config with no form as text, and put sway in the list docs/CONSOLE.md has promised since it was written that an app the form engine cannot render still appears in the Applications list and opens the text-edit fallback, "because someone looking for 'where do I configure sway' should find an answer rather than an absence". Neither half was built: the catalog is read from `.schema` files, so sway was in no list, and nothing in the repo or in alloy_tui could edit multi-line text. Both halves now exist. The catalog stays one mechanism. `syntax = ` in the schema 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. sway reaches the list through a header-only schema rather than through a second registry for the apps the first one cannot describe. Declaring the language rather than declaring "no form" is what lets a file change tiers without being rewritten -- when the roundtrip-safe-KDL question resolves, every `syntax = "kdl"` schema starts rendering a form on its own. A formless schema must be a header alone and must name a target_path, both reported rather than ignored. `Pane` is a peer of the form, not a third arm of the error: sway has no form to fail at. The route that changes a behavior rather than adding one is the other: a config the toml_edit bind refused 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. It now opens with the parse error above it. TextArea lives in the binary for the reason alloy_tui's own input.rs gives about TextField: the crate is a separate published repo, so putting something in it is a release. It moves when there is a second consumer. The pane is not an editor and does not try to be one -- no undo, no selection, no search; the image ships helix. Its modality matches the form's, so q and Tab still work in Navigate, and Esc leaves the buffer 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 carried state, stated in the module header: a TOML multi-line string gets its body colored as code, which beats re-scanning from the top of the file to draw any line, and sway has no multi-line construct at all. Saving goes through the same Effect::Write the form engine uses, so the log line, the mode and the round-trip promise are one implementation rather than two.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 01:48 UTC
Signed with PGP, not checked
Commit: 90e5b97edab7e533ed2b6165e31ffadfa6772894
Parent: 88f7e4e
8 files changed, +1051 insertions, -47 deletions
M Cargo.lock +12 -12
@@ -2643,6 +2643,10 @@
2643 2643 "winnow",
2644 2644 ]
2645 2645
2646 + [[patch.unused]]
2647 + name = "docengine"
2648 + version = "0.7.0"
2649 +
2646 2650 [[patch.unused]]
2647 2651 name = "kberg"
2648 2652 version = "0.1.0"
@@ -2659,18 +2663,6 @@
2659 2663 name = "tagtree"
2660 2664 version = "0.4.0"
2661 2665
2662 - [[patch.unused]]
2663 - name = "synckit-client"
2664 - version = "0.8.0"
2665 -
2666 - [[patch.unused]]
2667 - name = "synckit-config"
2668 - version = "0.2.0"
2669 -
2670 - [[patch.unused]]
2671 - name = "docengine"
2672 - version = "0.7.0"
2673 -
2674 2666 [[patch.unused]]
2675 2667 name = "quasi-axum"
2676 2668 version = "0.18.0"
@@ -2702,3 +2694,11 @@
2702 2694 [[patch.unused]]
2703 2695 name = "quasi-webview"
2704 2696 version = "0.18.0"
2697 +
2698 + [[patch.unused]]
2699 + name = "synckit-client"
2700 + version = "0.8.0"
2701 +
2702 + [[patch.unused]]
2703 + name = "synckit-config"
2704 + version = "0.2.0"
M docs/CONSOLE.md +5 -3
@@ -217,7 +217,7 @@
217 217 The thesis at the top of this document, built. One view, two tabs, and the same form under both:
218 218
219 219 - **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, and whether gopass has an age identity. 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. The grant is for an active *local* session in `wheel`, which an ssh login is not, so those same rows do prompt over ssh — correctly, since "the person sitting at the machine" is the whole of the argument for granting them. The console answers that with polkit's own text agent rather than by widening the grant: a setter that comes back saying interactive authentication is required is run again with `pkttyagent` alive beside it, which means the screen tears down, polkit asks in the terminal's own colors, and the console rebuilds. It is the honest fallback and looks like one. 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. A vocabulary of one closes the row on the same grounds, since the single entry is whatever is already in force and picking it changes nothing. The locale row is the case that reaches users: the image carries no glibc langpacks, so `localectl` has one locale to list, and the row names it and says that adding a langpack adds a choice. The gate is the count rather than a langpack probe, so the row reopens by itself once one is layered in. The secrets row applies the same idiom to something absent rather than to a front that stayed quiet: Alloy ships gopass and provisions no age identity for it, so the row names the directory gopass will look in and says the store will not decrypt until a key is placed there. It is shown and closed in both states, because writing an identity from a settings form is exactly what the provisioning decision ruled out, and it reports whether a file is there without ever reading it. 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.
220 - - **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.
220 + - **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 *form* (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. It reaches the list through a header-only schema declaring its syntax, so there is one catalog and one search path rather than a second registry for the apps the first one cannot describe.
221 221
222 222 Display and power/idle are deliberately not here. `alloy display` is a verb of its own, now shipped, and idle behaviour belongs with it; folding them in would duplicate a screen rather than unify one.
223 223
@@ -247,7 +247,9 @@
247 247 - **Presets apply atomically.** A `[[preset]]` writes its whole `values` map into the document in one pass: one dirty increment, one undo entry. Undo is single-level in v1. Applying logs as `apply preset "..."`.
248 248 - **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.
249 249 - **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.
250 - - **Fallback.** No schema, an unknown `schema_version`, or an unknown field type routes the whole file to the syntax-highlighted text-edit pane with a diagnostic explaining why. KDL configs take this route until the open roundtrip-safe-KDL question below resolves.
250 + - **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.
251 + - **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.
252 + - **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.
251 253
252 254 ## Schema strategy
253 255
@@ -330,7 +332,7 @@
330 332 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.
331 333
332 334 - **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.
333 - - **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.
335 + - **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) presets and live diagnostics, the collapsible sections having landed with the tab; (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.
334 336 - **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).
335 337 - **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.
336 338 - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). The first-boot flow (see [CONTINUITY.md](CONTINUITY.md)) shipped as `alloy setup`: two rows over the same `mesh` and `sync` backend seams, so there is no third enrollment path to keep in agreement with them. It survives the teardown mesh enrollment costs by refreshing on the first tick after a suspend rather than on its poll counter. The shell's post-suspend refresh arrives as one `tick` call, and a counter would swallow four out of five of them, leaving the row the user just enrolled reading "not enrolled" for several seconds. The screen appears once: the session autostart runs `alloy setup --if-first-boot`, and the console records that it asked in the same config store the theme lives in.
@@ -55,10 +55,16 @@
55 55
56 56 File-backed forms save with `Ctrl+S`. Live-state rows have nothing to save.
57 57
58 - The list holds the apps a schema ships for, which today is `shop`. Configs with
59 - no schema are not in it yet, sway's among them: its i3-style syntax is neither
60 - TOML nor KDL, so it takes a text-edit pane the console has not built. Edit
61 - `~/.config/sway/config` in your editor for now.
58 + Not every config gets a form. sway's is neither TOML nor KDL, so it opens as
59 + plain text instead: syntax-highlighted, with line numbers, saved with the same
60 + `Ctrl+S`. It is in the list for the same reason everything else is, because
61 + "where do I configure sway" should have an answer.
62 +
63 + The text pane is a fallback and not an editor. There is no undo and no search;
64 + the machine ships `helix` for that. Press Enter to start typing in it and Esc to
65 + stop, which keeps what you typed rather than throwing it away. A config the
66 + console could not parse opens the same way, with the parse error above it, so a
67 + file that is broken is still a file you can fix here.
62 68
63 69 `alloy config <path>` opens one file's form directly, with no tabs and no app
64 70 list. The path has to be one a schema names as its target; anything else is
@@ -13,6 +13,7 @@
13 13 mod credits;
14 14 mod disk;
15 15 mod display;
16 + mod editor;
16 17 mod image;
17 18 mod install;
18 19 mod mesh;
@@ -71,9 +71,69 @@
71 71 /// without one describes a shape and not a location, so it cannot be
72 72 /// listed as an app.
73 73 pub(crate) target_path: Option<String>,
74 + /// What language the target file is written in.
75 + pub(crate) syntax: Syntax,
74 76 pub(crate) unknown_keys: UnknownKeys,
75 77 }
76 78
79 + /// The language of the file a schema targets.
80 + ///
81 + /// One key, because "what language is this" and "can it have a form" are the
82 + /// same question asked twice. docs/CONSOLE.md's schema strategy names sway as
83 + /// the case: its i3-style syntax is neither TOML nor KDL, so it takes the
84 + /// text-edit fallback rather than a form. Declaring the syntax rather than
85 + /// declaring "no form" means the day KDL gains a roundtrip-safe bind, every
86 + /// KDL schema already says what it is and moves tiers without being rewritten.
87 + ///
88 + /// It is also what a schema-less app is *for*: a header-only schema is how an
89 + /// app the console cannot render a form for still reaches the Applications
90 + /// list, which CONSOLE.md requires and a catalog built only from form schemas
91 + /// could not do. One catalog, one search path, one precedence rule.
92 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93 + pub(crate) enum Syntax {
94 + #[default]
95 + Toml,
96 + /// Declared, and still a fallback: the roundtrip-safe KDL bind is an open
97 + /// question in CONSOLE.md, and until it is answered a KDL config opens as
98 + /// text like any other file the console cannot edit in place.
99 + Kdl,
100 + /// sway and i3.
101 + Sway,
102 + /// Anything else. Editable, not colored.
103 + Text,
104 + }
105 +
106 + impl Syntax {
107 + fn parse(name: &str) -> Result<Self> {
108 + Ok(match name {
109 + "toml" => Self::Toml,
110 + "kdl" => Self::Kdl,
111 + "sway" => Self::Sway,
112 + "text" => Self::Text,
113 + other => bail!("syntax is \"{other}\", expected toml, kdl, sway or text"),
114 + })
115 + }
116 +
117 + /// Whether the console can render a form for this syntax.
118 + ///
119 + /// Only TOML today. The bind layer is `toml_edit`, and roundtrip-safety is
120 + /// the promise the form engine makes, so a syntax with no in-place editor
121 + /// behind it cannot keep it.
122 + pub(crate) const fn forms(self) -> bool {
123 + matches!(self, Self::Toml)
124 + }
125 +
126 + /// The `syntax = ` spelling, for diagnostics.
127 + pub(crate) const fn name(self) -> &'static str {
128 + match self {
129 + Self::Toml => "toml",
130 + Self::Kdl => "kdl",
131 + Self::Sway => "sway",
132 + Self::Text => "text",
133 + }
134 + }
135 + }
136 +
77 137 /// What to do about keys in the target file that no field declares.
78 138 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
79 139 pub(crate) enum UnknownKeys {
@@ -256,11 +316,40 @@
256 316 Some(other) => bail!("unknown_keys is \"{other}\", expected \"preserve\" or \"error\""),
257 317 };
258 318
319 + let syntax = raw
320 + .schema
321 + .syntax
322 + .as_deref()
323 + .map_or(Ok(Syntax::default()), Syntax::parse)?;
324 +
325 + // A schema for a syntax with no form engine behind it describes rows
326 + // nothing will ever draw. Caught here rather than ignored, because the
327 + // author who wrote them is owed the reason: silently opening their file
328 + // as text would read as the schema not having been found at all.
329 + if !syntax.forms() {
330 + ensure!(
331 + raw.section.is_empty()
332 + && raw.field.is_empty()
333 + && raw.group.is_empty()
334 + && raw.preset.is_empty(),
335 + "syntax is \"{}\", which opens the text-edit fallback, so this schema's \
336 + fields and sections would never be rendered — declare the header alone",
337 + syntax.name(),
338 + );
339 + ensure!(
340 + raw.schema.target_path.is_some(),
341 + "syntax is \"{}\", so this schema exists to list an app and open its file, \
342 + and without target_path it names no file to open",
343 + syntax.name(),
344 + );
345 + }
346 +
259 347 let header = Header {
260 348 target: raw.schema.target,
261 349 target_tool: raw.schema.target_tool,
262 350 target_version: raw.schema.target_version,
263 351 target_path: raw.schema.target_path,
352 + syntax,
264 353 unknown_keys,
265 354 };
266 355
@@ -373,6 +462,7 @@
373 462 target_version: Option<String>,
374 463 target_path: Option<String>,
375 464 schema_version: String,
465 + syntax: Option<String>,
376 466 unknown_keys: Option<String>,
377 467 }
378 468
@@ -1181,6 +1271,82 @@
1181 1271 assert!(error.contains("discard"), "{error}");
1182 1272 }
1183 1273
1274 + #[test]
1275 + fn syntax_defaults_to_toml_and_toml_is_the_one_that_forms() {
1276 + let schema = Schema::parse(
1277 + "[schema]\n\
1278 + target = \"t.toml\"\n\
1279 + target_tool = \"t\"\n\
1280 + schema_version = \"1\"\n",
1281 + )
1282 + .unwrap();
1283 + assert_eq!(schema.header.syntax, Syntax::Toml);
1284 + assert!(Syntax::Toml.forms());
1285 + for syntax in [Syntax::Kdl, Syntax::Sway, Syntax::Text] {
1286 + assert!(!syntax.forms(), "{} has no bind behind it", syntax.name());
1287 + }
1288 + }
1289 +
1290 + // A header alone is the whole of a schema-less app's declaration, and it
1291 + // has to parse: it is what puts sway in the Applications list.
1292 + #[test]
1293 + fn a_header_only_schema_declares_an_app_with_no_form() {
1294 + let schema = Schema::parse(
1295 + "[schema]\n\
1296 + target = \"config\"\n\
1297 + target_path = \"$XDG_CONFIG_HOME/sway/config\"\n\
1298 + target_tool = \"sway\"\n\
1299 + schema_version = \"1\"\n\
1300 + syntax = \"sway\"\n",
1301 + )
1302 + .unwrap();
1303 + assert_eq!(schema.header.syntax, Syntax::Sway);
1304 + assert!(schema.fields.is_empty());
1305 + }
1306 +
1307 + // Rows nothing will ever draw, reported rather than ignored: opening the
1308 + // file as text without a word would read as the schema not being found.
1309 + #[test]
1310 + fn fields_under_a_formless_syntax_are_an_error_not_a_silent_drop() {
1311 + let error = error(Schema::parse(
1312 + "[schema]\n\
1313 + target = \"config\"\n\
1314 + target_path = \"~/.config/sway/config\"\n\
1315 + target_tool = \"sway\"\n\
1316 + schema_version = \"1\"\n\
1317 + syntax = \"sway\"\n\
1318 + [[field]]\n\
1319 + path = \"gaps\"\n\
1320 + type = \"int\"\n",
1321 + ));
1322 + assert!(error.contains("never be rendered"), "{error}");
1323 + }
1324 +
1325 + // Without a path it names no file, and its only job is to open one.
1326 + #[test]
1327 + fn a_formless_schema_must_say_where_the_file_is() {
1328 + let error = error(Schema::parse(
1329 + "[schema]\n\
1330 + target = \"config\"\n\
1331 + target_tool = \"sway\"\n\
1332 + schema_version = \"1\"\n\
1333 + syntax = \"sway\"\n",
1334 + ));
1335 + assert!(error.contains("target_path"), "{error}");
1336 + }
1337 +
1338 + #[test]
1339 + fn an_unknown_syntax_names_the_ones_that_exist() {
1340 + let error = error(Schema::parse(
1341 + "[schema]\n\
1342 + target = \"t\"\n\
1343 + target_tool = \"t\"\n\
1344 + schema_version = \"1\"\n\
1345 + syntax = \"yaml\"\n",
1346 + ));
1347 + assert!(error.contains("yaml") && error.contains("toml"), "{error}");
1348 + }
1349 +
1184 1350 #[test]
1185 1351 fn lists_do_not_nest() {
1186 1352 let error = error(parse(
@@ -60,6 +60,7 @@
60 60
61 61 use crate::bind::{Bind, FileBind};
62 62 use crate::cli::{CommandLog, contract_home};
63 + use crate::editor::Editor;
63 64 use crate::schema::{self, Field, Schema, Section};
64 65 use crate::shell::{Confirm, Flow, View};
65 66 use crate::system::SystemBind;
@@ -165,7 +166,47 @@
165 166 /// error, kept as a field of its own because `alloy config <path>` matches
166 167 /// on it and cannot reach into a `Form` that was never built.
167 168 target: Option<PathBuf>,
168 - state: Result<Form, String>,
169 + state: Result<Pane, String>,
170 + }
171 +
172 + /// What an opened app shows: a form, or the file as text.
173 + ///
174 + /// The second is docs/CONSOLE.md's text-edit fallback, and it is a peer of the
175 + /// form rather than an error state. Two of the four routes into it are not
176 + /// failures at all — sway declares `syntax = "sway"` and has no form to fail at
177 + /// — so modelling it as a third arm of `Result` would have said "broken" about
178 + /// an app working exactly as designed.
179 + enum Pane {
180 + /// Boxed only because a `Form` is three times the size of an `Editor` and
181 + /// an unboxed pair would make every app in the catalog pay the larger one.
182 + Form(Box<Form>),
183 + Text(Editor),
184 + }
185 +
186 + impl Pane {
187 + /// Whether this pane is holding edits that are not on disk.
188 + fn dirty(&self) -> bool {
189 + match self {
190 + Self::Form(form) => form.bind.dirty(),
191 + Self::Text(editor) => editor.dirty(),
192 + }
193 + }
194 +
195 + /// The file or front the pane is over, for the pane title.
196 + fn origin(&self) -> String {
197 + match self {
198 + Self::Form(form) => form.bind.origin(),
199 + Self::Text(editor) => editor.path().display().to_string(),
200 + }
201 + }
202 +
203 + /// Whether a buffer inside this pane has the keyboard.
204 + fn typing(&self) -> bool {
205 + match self {
206 + Self::Form(form) => form.typing(),
207 + Self::Text(editor) => editor.editing(),
208 + }
209 + }
169 210 }
170 211
171 212 /// An opened app's form: the bind, and where the user is in it.
@@ -281,14 +322,29 @@
281 322 };
282 323
283 324 let name = schema.header.target_tool.clone();
325 + let syntax = schema.header.syntax;
284 326 let target = schema.header.target_path.as_deref().and_then(expand);
285 327 let state = match (schema.header.target_path.as_deref(), &target) {
286 328 (None, _) => Err("the schema does not say where the file lives".to_string()),
287 329 (Some(declared), None) => Err(format!("cannot resolve `{declared}`")),
288 - (Some(_), Some(path)) => FileBind::open(schema, path).map_or_else(
289 - |error| Err(format!("{error:#}")),
290 - |bind| Ok(Form::new(Box::new(bind))),
291 - ),
330 + // No form engine for this syntax, and the schema said so. Not a
331 + // diagnostic: this is the app appearing in the list with something
332 + // a person can edit, which is the whole point of declaring it.
333 + (Some(_), Some(path)) if !syntax.forms() => {
334 + Editor::open(path, syntax, None).map(Pane::Text)
335 + }
336 + // The schema is fine and the file is not. docs/CONSOLE.md routes
337 + // this to the same pane carrying the reason, which is strictly
338 + // better than the red paragraph it used to get: a config the bind
339 + // refused to parse is a config someone has to open to fix, and
340 + // until now the console could show it to them and not let them
341 + // touch it.
342 + (Some(_), Some(path)) => match FileBind::open(schema, path) {
343 + Ok(bind) => Ok(Pane::Form(Box::new(Form::new(Box::new(bind))))),
344 + Err(error) => {
345 + Editor::open(path, syntax, Some(format!("{error:#}"))).map(Pane::Text)
346 + }
347 + },
292 348 };
293 349 Self {
294 350 name,
@@ -954,9 +1010,15 @@
954 1010 ///
955 1011 /// Failing rather than falling back to an editor. Handing the file to
956 1012 /// `$EDITOR` would be a second, silent answer to `alloy config` that looks
957 - /// like the first until it is not a form, and the text-edit fallback the
958 - /// Applications tab wants is its own decision to make. So this says which
959 - /// file it could not find a schema for and where it looked.
1013 + /// like the first until it is not a form. So this says which file it could
1014 + /// not find a schema for and where it looked.
1015 + ///
1016 + /// The text-edit fallback does not change that, and the distinction is
1017 + /// worth keeping straight: a file whose schema declares a formless syntax
1018 + /// opens here as text, because a schema names it. A file no schema names at
1019 + /// all is still refused, because the fallback is Alloy's answer for the
1020 + /// configs it curates and not a general-purpose editor for any path handed
1021 + /// to it.
960 1022 pub(crate) fn config(path: &Path) -> anyhow::Result<Self> {
961 1023 let wanted =
962 1024 std::path::absolute(path).with_context(|| format!("resolving {}", path.display()))?;
@@ -1018,27 +1080,67 @@
1018 1080 fn form(&self) -> Option<&Form> {
1019 1081 match self.tab() {
1020 1082 Tab::System => self.system.as_ref(),
1083 + Tab::Applications => match self.app()?.state.as_ref().ok()? {
1084 + Pane::Form(form) => Some(form),
1085 + Pane::Text(_) => None,
1086 + },
1087 + }
1088 + }
1089 +
1090 + /// The selected app's pane, of either kind.
1091 + ///
1092 + /// The System tab has no pane in this sense: it is a form and only ever a
1093 + /// form, since a command front has no file to fall back to.
1094 + fn pane(&self) -> Option<&Pane> {
1095 + match self.tab() {
1096 + Tab::System => None,
1021 1097 Tab::Applications => self.app()?.state.as_ref().ok(),
1022 1098 }
1023 1099 }
1024 1100
1101 + fn pane_mut(&mut self) -> Option<&mut Pane> {
1102 + match self.tab() {
1103 + Tab::System => None,
1104 + Tab::Applications => {
1105 + let index = self.cursor.selected()?;
1106 + self.apps.get_mut(index)?.state.as_mut().ok()
1107 + }
1108 + }
1109 + }
1110 +
1111 + /// The selected app's text pane, when that is what it is.
1112 + fn editor_mut(&mut self) -> Option<&mut Editor> {
1113 + match self.pane_mut()? {
1114 + Pane::Text(editor) => Some(editor),
1115 + Pane::Form(_) => None,
1116 + }
1117 + }
1118 +
1025 1119 /// Whether any app is holding edits that are not on disk.
1026 1120 fn unsaved(&self) -> bool {
1027 1121 self.apps
1028 1122 .iter()
1029 1123 .filter_map(|app| app.state.as_ref().ok())
1030 - .any(|form| form.bind.dirty())
1124 + .any(Pane::dirty)
1031 1125 }
1032 1126
1033 1127 /// Whether a text buffer, of either kind, has the keyboard.
1034 1128 fn typing(&self) -> bool {
1035 - self.form().is_some_and(Form::typing)
1129 + match self.tab() {
1130 + Tab::System => self.system.as_ref().is_some_and(Form::typing),
1131 + Tab::Applications => self.pane().is_some_and(Pane::typing),
1132 + }
1036 1133 }
1037 1134
1038 1135 fn picking(&self) -> bool {
1039 1136 self.form().is_some_and(Form::picking)
1040 1137 }
1041 1138
1139 + /// Whether the selected app opened as text rather than as a form.
1140 + fn on_text(&self) -> bool {
1141 + matches!(self.pane(), Some(Pane::Text(_)))
1142 + }
1143 +
1042 1144 /// Whether the keys act on a form rather than on the app list.
1043 1145 ///
1044 1146 /// Always, on the System tab: there is no list beside it to move through.
@@ -1098,13 +1200,16 @@
1098 1200 let Some(index) = self.cursor.selected() else {
1099 1201 return;
1100 1202 };
1101 - let Some(Ok(form)) = self.apps.get_mut(index).map(|app| &mut app.state) else {
1203 + let Some(Ok(pane)) = self.apps.get_mut(index).map(|app| &mut app.state) else {
1102 1204 return;
1103 1205 };
1104 - if !form.bind.dirty() {
1206 + if !pane.dirty() {
1105 1207 return;
1106 1208 }
1107 - self.error = form.save(log).err();
1209 + self.error = match pane {
1210 + Pane::Form(form) => form.save(log).err(),
1211 + Pane::Text(editor) => editor.save(log).err(),
1212 + };
1108 1213 }
1109 1214
1110 1215 fn tab(&self) -> Tab {
@@ -1136,10 +1241,10 @@
1136 1241 fn form_mut(&mut self) -> Option<&mut Form> {
1137 1242 match self.tab() {
1138 1243 Tab::System => self.system.as_mut(),
1139 - Tab::Applications => {
1140 - let index = self.cursor.selected()?;
1141 - self.apps.get_mut(index)?.state.as_mut().ok()
1142 - }
1244 + Tab::Applications => match self.pane_mut()? {
1245 + Pane::Form(form) => Some(form),
1246 + Pane::Text(_) => None,
1247 + },
1143 1248 }
1144 1249 }
1145 1250
@@ -1209,7 +1314,7 @@
1209 1314 );
1210 1315
1211 1316 let title = self.app().map_or_else(String::new, |app| match &app.state {
1212 - Ok(form) => format!(" {} ", contract_home(Path::new(&form.bind.origin()))),
1317 + Ok(pane) => format!(" {} ", contract_home(Path::new(&pane.origin()))),
1213 1318 Err(_) => format!(" {} ", contract_home(&app.source)),
1214 1319 });
1215 1320 let form_block = AlloyBlock::new(theme)
@@ -1231,7 +1336,8 @@
1231 1336 .wrap(ratatui::widgets::Wrap { trim: true }),
1232 1337 form_inner,
1233 1338 ),
1234 - Some(Ok(form)) => Self::render_form(frame, form_inner, theme, form),
1339 + Some(Ok(Pane::Form(form))) => Self::render_form(frame, form_inner, theme, form),
1340 + Some(Ok(Pane::Text(editor))) => editor.render(frame, form_inner, theme),
1235 1341 None => {}
1236 1342 }
1237 1343
@@ -1334,6 +1440,16 @@
1334 1440 // that would land in it as characters. The overlay lists its own two,
1335 1441 // so the footer covers the movement the overlay cannot spare a row for.
1336 1442 if self.typing() {
1443 + // The text pane's Esc keeps what was typed rather than discarding
1444 + // it, so it is not offered under the same word as a field's. See
1445 + // `Editor::end_edit` for why the two differ.
1446 + if self.on_text() {
1447 + let mut hints = vec![hint("esc", "done")];
1448 + if self.pane().is_some_and(Pane::dirty) {
1449 + hints.push(hint("ctrl-s", "save"));
1450 + }
1451 + return hints;
1452 + }
1337 1453 let mut hints = vec![hint("enter", "commit"), hint("esc", "discard")];
1338 1454 if self.picking() {
1339 1455 hints.push(hint("up/down", "choose"));
@@ -1351,13 +1467,22 @@
1351 1467 hints.push(hint("tab", "pane"));
1352 1468 }
1353 1469 }
1354 - hints.push(hint("j/k", "select"));
1470 + let on_text = self.on_text();
1471 + hints.push(hint("j/k", if on_text { "line" } else { "select" }));
1355 1472 if self.on_form() {
1356 - hints.extend([hint("enter", "edit"), hint("space", "fold/flip")]);
1473 + if on_text {
1474 + hints.push(hint("enter", "edit"));
1475 + } else {
1476 + hints.extend([hint("enter", "edit"), hint("space", "fold/flip")]);
1477 + }
1357 1478 // Only a bind that holds edits back has anything to save. A command
1358 1479 // front writes when the row is edited, so offering Ctrl-S there
1359 1480 // would advertise a key that does nothing.
1360 - if self.form().is_some_and(|form| form.bind.dirty()) {
1481 + let dirty = match self.tab() {
1482 + Tab::System => self.form().is_some_and(|form| form.bind.dirty()),
1483 + Tab::Applications => self.pane().is_some_and(Pane::dirty),
1484 + };
1485 + if dirty {
1361 1486 hints.push(hint("ctrl-s", "save"));
1362 1487 }
1363 1488 }
@@ -1413,7 +1538,8 @@
1413 1538 // nothing, and the title already names the file.
1414 1539 if self.surface == Surface::File {
1415 1540 match self.apps.first().map(|app| &app.state) {
1416 - Some(Ok(form)) => Self::render_form(frame, inner, theme, form),
1541 + Some(Ok(Pane::Form(form))) => Self::render_form(frame, inner, theme, form),
1542 + Some(Ok(Pane::Text(editor))) => editor.render(frame, inner, theme),
1417 1543 Some(Err(reason)) => frame.render_widget(
1418 1544 Paragraph::new(Line::from(Span::styled(
1419 1545 reason.clone(),
@@ -1463,7 +1589,9 @@
1463 1589 /// and `q` reach; asking it here as well would ask twice for one key.
1464 1590 fn cancel(&mut self) -> Flow {
1465 1591 if self.typing() {
1466 - if let Some(form) = self.form_mut() {
1592 + if let Some(editor) = self.editor_mut() {
1593 + editor.end_edit();
1594 + } else if let Some(form) = self.form_mut() {
1467 1595 form.cancel_edit();
1468 1596 form.cancel_pick();
1469 1597 }
@@ -1526,6 +1654,19 @@
1526 1654 // is the obligation `classify` documents, honored at the one place in
1527 1655 // this view that takes typing.
1528 1656 if self.typing() {
1657 + // The text pane keeps only Ctrl-S. Enter is a newline and every
1658 + // other key is a character, which is the whole difference between
1659 + // editing a value and editing a file.
1660 + if let Some(editor) = self.editor_mut() {
1661 + if classify(key) == Action::Save {
1662 + self.save(log);
1663 + } else {
1664 + let page = editor.page();
1665 + editor.key(key, page);
1666 + }
1667 + return Flow::Continue;
1668 + }
1669 +
1529 1670 let picking = self.picking();
1530 1671
1531 1672 match classify(key) {
@@ -1594,7 +1735,9 @@
1594 1735 }
1595 1736 Action::Activate => {
1596 1737 if self.on_form() {
1597 - if let Some(form) = self.form_mut() {
1738 + if let Some(editor) = self.editor_mut() {
1739 + editor.begin_edit();
1740 + } else if let Some(form) = self.form_mut() {
1598 1741 // A closed vocabulary picks; anything else edits.
1599 1742 if !form.begin_pick() {
1600 1743 form.begin_edit();
@@ -1613,9 +1756,14 @@
1613 1756
1614 1757 let on_form = self.on_form();
1615 1758 match key.code {
1759 + // On a text pane these move the caret's line rather than a
1760 + // selection, so a config can be read through without entering the
1761 + // buffer and taking the reserved keys with it.
1616 1762 KeyCode::Char('j') | KeyCode::Down => {
1617 1763 if on_form {
1618 - if let Some(form) = self.form_mut() {
1764 + if let Some(editor) = self.editor_mut() {
1765 + editor.area_mut().down();
1766 + } else if let Some(form) = self.form_mut() {
1619 1767 form.cursor.next();
1620 1768 }
1621 1769 } else {
@@ -1624,7 +1772,9 @@
1624 1772 }
1625 1773 KeyCode::Char('k') | KeyCode::Up => {
1626 1774 if on_form {
1627 - if let Some(form) = self.form_mut() {
1775 + if let Some(editor) = self.editor_mut() {
1776 + editor.area_mut().up();
1777 + } else if let Some(form) = self.form_mut() {
1628 1778 form.cursor.prev();
1629 1779 }
1630 1780 } else {
@@ -2200,7 +2350,7 @@
2200 2350 name: "rio".into(),
2201 2351 source: PathBuf::from("rio.toml.schema"),
2202 2352 target: Some(PathBuf::from("/tmp/rio.toml")),
2203 - state: Ok(Form::new(Box::new(bind))),
2353 + state: Ok(Pane::Form(Box::new(Form::new(Box::new(bind))))),
2204 2354 })
2205 2355 }
2206 2356
@@ -2508,6 +2658,193 @@
2508 2658 assert!(dev.is_some(), "searched {:?}", search_path());
2509 2659 }
2510 2660
2661 + // The whole point of the sway schema, asserted the way a user would see
2662 + // it: the app is in the catalog and it opens something editable.
2663 + #[test]
2664 + fn the_shipped_sway_schema_lists_the_app_and_opens_it_as_text() {
2665 + let dir = search_path()
2666 + .into_iter()
2667 + .find(|dir| dir.join("sway.schema").is_file())
2668 + .expect("the repo ships a sway schema");
2669 +
2670 + let app = App::open(&dir.join("sway.schema"));
2671 + assert_eq!(app.name, "sway");
2672 + assert!(
2673 + matches!(app.state, Ok(Pane::Text(_))),
2674 + "sway has no form and must not read as broken: {:?}",
2675 + app.state.as_ref().err()
2676 + );
2677 + }
2678 +
2679 + // A file the bind refused is the case this replaced: it used to render as a
2680 + // red paragraph, so the one thing a user could not do with a config that
2681 + // failed to parse was open it and fix it.
2682 + #[test]
2683 + fn an_unparseable_target_opens_as_text_carrying_the_reason() {
2684 + let dir = std::env::temp_dir().join("alloy-settings-fallback");
2685 + std::fs::create_dir_all(&dir).unwrap();
2686 + let target = dir.join("broken.toml");
2687 + std::fs::write(&target, "this is not = = toml\n").unwrap();
2688 + let path = dir.join("broken.toml.schema");
2689 + std::fs::write(
2690 + &path,
2691 + format!(
2692 + "[schema]\ntarget = \"broken.toml\"\ntarget_tool = \"broken\"\n\
2693 + target_path = \"{}\"\nschema_version = \"1\"\n",
2694 + target.display()
2695 + ),
2696 + )
2697 + .unwrap();
2698 +
2699 + let app = App::open(&path);
2700 + match &app.state {
2701 + Ok(Pane::Text(editor)) => {
2702 + assert_eq!(editor.path(), target);
2703 + }
2704 + other => panic!("expected the text fallback, got {:?}", other.as_ref().err()),
2705 + }
2706 +
2707 + std::fs::remove_file(&path).ok();
2708 + std::fs::remove_file(&target).ok();
2709 + }
2710 +
2711 + // A target that is not there yet is an empty buffer rather than an error,
2712 + // matching what the form engine does with the same absence.
2713 + #[test]
2714 + fn a_missing_target_opens_as_an_empty_buffer() {
2715 + let dir = std::env::temp_dir().join("alloy-settings-absent");
2716 + std::fs::create_dir_all(&dir).unwrap();
2717 + let path = dir.join("nothing.schema");
2718 + std::fs::write(
2719 + &path,
2720 + format!(
2721 + "[schema]\ntarget = \"config\"\ntarget_tool = \"nothing\"\n\
2722 + target_path = \"{}\"\nschema_version = \"1\"\nsyntax = \"sway\"\n",
2723 + dir.join("does-not-exist").display()
2724 + ),
2725 + )
2726 + .unwrap();
2727 +
2728 + let app = App::open(&path);
2729 + assert!(matches!(app.state, Ok(Pane::Text(_))));
2730 + assert!(
2731 + !app.state.as_ref().unwrap().dirty(),
2732 + "and opening one is not an edit"
2733 + );
2734 +
2735 + std::fs::remove_file(&path).ok();
2736 + }
2737 +
2738 + /// Draw a view into a `width` x `height` buffer and hand back its lines.
2739 + fn drawn(view: &SettingsView, width: u16, height: u16) -> String {
2740 + let theme = crate::theme::load(Some(crate::theme::DEFAULT_LIGHT)).unwrap();
2741 + let mut terminal =
2742 + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
2743 + terminal
2744 + .draw(|frame| view.render(frame, frame.area(), &theme))
2745 + .unwrap();
2746 + terminal
2747 + .backend()
2748 + .buffer()
2749 + .content()
2750 + .chunks(width as usize)
2751 + .map(|row| {
2752 + row.iter()
2753 + .map(ratatui::buffer::Cell::symbol)
2754 + .collect::<String>()
2755 + .trim_end()
2756 + .to_string()
2757 + })
2758 + .collect::<Vec<_>>()
2759 + .join("\n")
2760 + }
2761 +
2762 + // The done condition, on screen: a sway config opens as something a person
2763 + // can read and edit, with its lines numbered and its text where they are.
2764 + #[test]
2765 + fn a_sway_config_draws_its_own_lines() {
2766 + let dir = std::env::temp_dir().join("alloy-settings-render");
2767 + std::fs::create_dir_all(&dir).unwrap();
2768 + let target = dir.join("sway-config");
2769 + std::fs::write(
2770 + &target,
2771 + "set $mod Mod4\n# a comment\nbindsym $mod+Return exec shop\n",
2772 + )
2773 + .unwrap();
2774 + let path = dir.join("render.schema");
2775 + std::fs::write(
2776 + &path,
2777 + format!(
2778 + "[schema]\ntarget = \"config\"\ntarget_tool = \"sway\"\n\
2779 + target_path = \"{}\"\nschema_version = \"1\"\nsyntax = \"sway\"\n",
2780 + target.display()
2781 + ),
2782 + )
2783 + .unwrap();
2784 +
2785 + let screen = drawn(&SettingsView::file(App::open(&path)), 70, 10);
2786 + assert!(screen.contains("set $mod Mod4"), "{screen}");
2787 + assert!(screen.contains("# a comment"), "{screen}");
2788 + assert!(screen.contains("bindsym $mod+Return exec shop"), "{screen}");
2789 + assert!(
2790 + screen.contains(" 1 ") && screen.contains(" 3 "),
2791 + "numbered: {screen}"
2792 + );
2793 +
2794 + std::fs::remove_file(&path).ok();
2795 + std::fs::remove_file(&target).ok();
2796 + }
2797 +
2798 + // Typing goes into the buffer and lands on disk, through the same
2799 + // `Effect::Write` the form engine's save uses.
2800 + #[test]
2801 + fn the_text_pane_types_and_saves() {
2802 + let dir = std::env::temp_dir().join("alloy-settings-write");
2803 + std::fs::create_dir_all(&dir).unwrap();
2804 + let target = dir.join("typed-config");
2805 + std::fs::write(&target, "output * scale 1\n").unwrap();
2806 + let path = dir.join("typed.schema");
2807 + std::fs::write(
2808 + &path,
2809 + format!(
2810 + "[schema]\ntarget = \"config\"\ntarget_tool = \"typed\"\n\
Lines truncated
@@ -1,0 +1,1025 @@
1 + //! The text-edit fallback: a config the form engine cannot render, as text.
2 + //!
3 + //! docs/CONSOLE.md routes four cases here — a file whose syntax has no form
4 + //! (sway's i3-style config is neither TOML nor KDL), a target the bind could
5 + //! not parse, an unknown field type, and list-of-tables editing. The first two
6 + //! are what this module serves today.
7 + //!
8 + //! The point of it is stated in CONSOLE.md and is worth repeating, because it
9 + //! is the reason a plain text pane counts as a feature at all: someone looking
10 + //! for "where do I configure sway" should find an answer rather than an
11 + //! absence. An app that appears in the list and opens something editable is
12 + //! that answer; an app that is silently missing is not.
13 + //!
14 + //! ## Why this is in the binary and not in `alloy_tui`
15 + //!
16 + //! The same reason [`TextField`](alloy_tui::TextField) was in the binary until
17 + //! 7.1.0, and its module header says so plainly: the crate is a separate
18 + //! published repo, so putting something in it is a release. [`TextArea`] is the
19 + //! multi-line counterpart and will make the same move when there is a second
20 + //! consumer, which today there is not — the console is the only thing in the
21 + //! tree that edits a buffer of text.
22 + //!
23 + //! ## What it deliberately is not
24 + //!
25 + //! Not an editor. There is no undo, no selection, no search, no clipboard: the
26 + //! machine ships `helix`, and a fallback pane that grew those would be a worse
27 + //! copy of it sitting inside a settings screen. What this is for is reading a
28 + //! config in place and changing a line of it, which is the gap between "no
29 + //! form" and "go find another program".
30 + //!
31 + //! <!-- wiki: alloy-settings -->
32 +
33 + use alloy_tui::{Severity, Theme, list_offset, text};
34 + use ratatui::Frame;
35 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
36 + use ratatui::layout::Rect;
37 + use ratatui::style::{Modifier, Style};
38 + use ratatui::text::{Line, Span};
39 + use ratatui::widgets::Paragraph;
40 +
41 + use crate::cli::{CommandLog, Effect};
42 + use crate::schema::Syntax;
43 +
44 + /// Permissions a written config takes. The same mode [`FileBind`] writes with,
45 + /// named here rather than shared because the two arrive at it independently
46 + /// and a config the form wrote should not differ from one the pane wrote.
47 + ///
48 + /// [`FileBind`]: crate::bind::FileBind
49 + const CONFIG_MODE: u32 = 0o644;
50 +
51 + /// What a page key moves by before the first frame has said otherwise. A
52 + /// conservative screenful: too small only means two presses.
53 + const DEFAULT_VIEWPORT: usize = 20;
54 +
55 + // ---------------------------------------------------------------------------
56 + // The buffer
57 + // ---------------------------------------------------------------------------
58 +
59 + /// Lines of text with a caret in them.
60 + ///
61 + /// Indices are in `char`s, never bytes, for the reason
62 + /// [`TextField`](alloy_tui::TextField) gives: a config file is not guaranteed
63 + /// ASCII, and `String::insert` at an offset that lands mid-codepoint panics.
64 + #[derive(Debug, Clone)]
65 + pub(crate) struct TextArea {
66 + /// Never empty. A file of `""` is one empty line, which is where a caret
67 + /// has to be able to sit.
68 + lines: Vec<String>,
69 + row: usize,
70 + /// Caret column in `char`s, clamped to the current line's length.
71 + col: usize,
72 + /// The column vertical movement is aiming for.
73 + ///
74 + /// Moving down through a short line and back out should return to where
75 + /// the caret started, rather than being trimmed to the shortest line it
76 + /// passed over. Every horizontal move rewrites this; vertical ones read it.
77 + goal: usize,
78 + /// Edits since the last [`mark_saved`](Self::mark_saved).
79 + edits: usize,
80 + }
81 +
82 + impl TextArea {
83 + /// Read a file's contents into a buffer.
84 + ///
85 + /// Split on `\n` and rejoined the same way, so a trailing newline survives
86 + /// the round trip as a final empty line rather than being lost or doubled.
87 + /// That matters more here than it looks: this pane exists for files nothing
88 + /// else in the console can parse, so it has no schema to reconstruct from
89 + /// and its only promise is that what it did not touch comes back unchanged.
90 + pub(crate) fn new(text: &str) -> Self {
91 + Self {
92 + lines: text.split('\n').map(str::to_string).collect(),
93 + row: 0,
94 + col: 0,
95 + goal: 0,
96 + edits: 0,
97 + }
98 + }
99 +
100 + /// The buffer as it would be written.
101 + pub(crate) fn text(&self) -> String {
102 + self.lines.join("\n")
103 + }
104 +
105 + pub(crate) fn lines(&self) -> &[String] {
106 + &self.lines
107 + }
108 +
109 + /// Caret position, as (line, column), both zero-based and in `char`s.
110 + pub(crate) fn caret(&self) -> (usize, usize) {
111 + (self.row, self.col)
112 + }
113 +
114 + pub(crate) fn dirty(&self) -> bool {
115 + self.edits > 0
116 + }
117 +
118 + /// Called by whoever wrote the file, not by an edit.
119 + pub(crate) fn mark_saved(&mut self) {
120 + self.edits = 0;
121 + }
122 +
123 + fn line(&self) -> &str {
124 + &self.lines[self.row]
125 + }
126 +
127 + fn line_chars(&self) -> usize {
128 + self.line().chars().count()
129 + }
130 +
131 + /// Byte offset of char index `index` in the current line.
132 + ///
133 + /// `char_indices` stops at the last character, so an index one past the end
134 + /// — the caret appending at the tail — falls through to the line's length
135 + /// rather than off it.
136 + fn byte_of(&self, index: usize) -> usize {
137 + self.line()
138 + .char_indices()
139 + .nth(index)
140 + .map_or(self.line().len(), |(byte, _)| byte)
141 + }
142 +
143 + pub(crate) fn insert(&mut self, c: char) {
144 + let at = self.byte_of(self.col);
145 + self.lines[self.row].insert(at, c);
146 + self.col += 1;
147 + self.goal = self.col;
148 + self.edits += 1;
149 + }
150 +
151 + /// Split the line at the caret.
152 + pub(crate) fn newline(&mut self) {
153 + let at = self.byte_of(self.col);
154 + let tail = self.lines[self.row].split_off(at);
155 + self.lines.insert(self.row + 1, tail);
156 + self.row += 1;
157 + self.col = 0;
158 + self.goal = 0;
159 + self.edits += 1;
160 + }
161 +
162 + /// Delete the character before the caret, joining lines at a line start.
163 + pub(crate) fn backspace(&mut self) {
164 + if self.col > 0 {
165 + self.col -= 1;
166 + let at = self.byte_of(self.col);
167 + self.lines[self.row].remove(at);
168 + } else if self.row > 0 {
169 + let line = self.lines.remove(self.row);
170 + self.row -= 1;
171 + self.col = self.line_chars();
172 + self.lines[self.row].push_str(&line);
173 + } else {
174 + // Start of the first line. Nothing to take, and nothing to mark
175 + // dirty for: a keypress that changed no bytes should not make the
176 + // footer offer a save.
177 + return;
178 + }
179 + self.goal = self.col;
180 + self.edits += 1;
181 + }
182 +
183 + /// Delete the character under the caret, pulling the next line up at a line
184 + /// end.
185 + pub(crate) fn delete(&mut self) {
186 + if self.col < self.line_chars() {
187 + let at = self.byte_of(self.col);
188 + self.lines[self.row].remove(at);
189 + } else if self.row + 1 < self.lines.len() {
190 + let next = self.lines.remove(self.row + 1);
191 + self.lines[self.row].push_str(&next);
192 + } else {
193 + return;
194 + }
195 + self.edits += 1;
196 + }
197 +
198 + /// Clamped at both ends of the buffer, and stepping between lines at each
199 + /// end of a line, which is where a caret in a multi-line buffer differs
200 + /// from one in a field.
201 + pub(crate) fn left(&mut self) {
202 + if self.col > 0 {
203 + self.col -= 1;
204 + } else if self.row > 0 {
205 + self.row -= 1;
206 + self.col = self.line_chars();
207 + }
208 + self.goal = self.col;
209 + }
210 +
211 + pub(crate) fn right(&mut self) {
212 + if self.col < self.line_chars() {
213 + self.col += 1;
214 + } else if self.row + 1 < self.lines.len() {
215 + self.row += 1;
216 + self.col = 0;
217 + }
218 + self.goal = self.col;
219 + }
220 +
221 + pub(crate) fn up(&mut self) {
222 + if self.row > 0 {
223 + self.row -= 1;
224 + self.col = self.goal.min(self.line_chars());
225 + }
226 + }
227 +
228 + pub(crate) fn down(&mut self) {
229 + if self.row + 1 < self.lines.len() {
230 + self.row += 1;
231 + self.col = self.goal.min(self.line_chars());
232 + }
233 + }
234 +
235 + pub(crate) fn home(&mut self) {
236 + self.col = 0;
237 + self.goal = 0;
238 + }
239 +
240 + pub(crate) fn end(&mut self) {
241 + self.col = self.line_chars();
242 + self.goal = self.col;
243 + }
244 +
245 + /// A screenful up or down, for a config long enough that line-at-a-time is
246 + /// not a way to reach the bottom of it.
247 + pub(crate) fn page_up(&mut self, height: usize) {
248 + self.row = self.row.saturating_sub(height.max(1));
249 + self.col = self.goal.min(self.line_chars());
250 + }
251 +
252 + pub(crate) fn page_down(&mut self, height: usize) {
253 + self.row = (self.row + height.max(1)).min(self.lines.len() - 1);
254 + self.col = self.goal.min(self.line_chars());
255 + }
256 + }
257 +
258 + // ---------------------------------------------------------------------------
259 + // Highlighting
260 + // ---------------------------------------------------------------------------
261 +
262 + /// What a run of characters is, for color.
263 + ///
264 + /// Deliberately short. DESIGN-LANGUAGE.md allows color here — "a
265 + /// syntax-highlighted token carries semantic weight worth the eye's attention"
266 + /// — and allows it on the condition that every hue encodes something. Six
267 + /// classes over three accent hues is what a config file has to distinguish;
268 + /// a token set with a hue for each keyword would be decoration wearing the
269 + /// argument for information.
270 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
271 + pub(crate) enum Token {
272 + Comment,
273 + /// A TOML key or table header, a sway command word: the thing being set.
274 + Key,
275 + Str,
276 + Number,
277 + /// A sway `$variable`.
278 + Var,
279 + Plain,
280 + }
281 +
282 + impl Token {
283 + fn style(self, theme: &Theme) -> Style {
284 + let color = match self {
285 + // Quiet on purpose. A comment is the one run in a config that is
286 + // not addressed to the machine, and a reader scanning for a key
287 + // should be able to skip it without reading it.
288 + Self::Comment => theme.makeover.content_muted,
289 + Self::Key => theme.makeover.content_primary,
290 + Self::Str => theme.makeover.category[0],
291 + Self::Number => theme.makeover.category[1],
292 + Self::Var => theme.makeover.category[2],
293 + Self::Plain => theme.makeover.content_secondary,
294 + };
295 + Style::default().fg(color)
296 + }
297 + }
298 +
299 + /// Split one line into colored runs.
300 + ///
301 + /// **Line by line, with no state carried between lines**, which is the one
302 + /// thing to know before reading a wrongly-colored file and filing a bug. A TOML
303 + /// multi-line basic string (`"""`) has its body colored as though it were code.
304 + /// The trade is deliberate: carrying lexer state would mean re-scanning from
305 + /// the top of the file to draw any line, and this pane exists to make a config
306 + /// legible enough to fix a line in, where a mis-hued heredoc body costs a
307 + /// reader nothing they cannot see through. Sway's syntax has no multi-line
308 + /// construct at all, and that is the syntax this pane was built for.
309 + pub(crate) fn tokens(syntax: Syntax, line: &str) -> Vec<(Token, String)> {
310 + match syntax {
311 + Syntax::Toml | Syntax::Kdl => toml_tokens(line),
312 + Syntax::Sway => sway_tokens(line),
313 + Syntax::Text => vec![(Token::Plain, line.to_string())],
314 + }
315 + }
316 +
317 + /// Push a run, merging it into the previous one when the class matches, so the
318 + /// renderer gets one span per color change rather than one per character.
319 + fn push(out: &mut Vec<(Token, String)>, token: Token, s: &str) {
320 + if s.is_empty() {
321 + return;
322 + }
323 + match out.last_mut() {
324 + Some((last, text)) if *last == token => text.push_str(s),
325 + _ => out.push((token, s.to_string())),
326 + }
327 + }
328 +
329 + fn toml_tokens(line: &str) -> Vec<(Token, String)> {
330 + let mut out = Vec::new();
331 + let trimmed = line.trim_start();
332 + let indent = &line[..line.len() - trimmed.len()];
333 + push(&mut out, Token::Plain, indent);
334 +
335 + if trimmed.starts_with('#') {
336 + push(&mut out, Token::Comment, trimmed);
337 + return out;
338 + }
339 + // A table header is the whole line and is the strongest structural mark in
340 + // a TOML file, so it takes the key color rather than being split at the
341 + // bracket.
342 + if trimmed.starts_with('[') {
343 + push(&mut out, Token::Key, trimmed);
344 + return out;
345 + }
346 +
347 + // Everything up to the first `=` is the key. A `=` inside a value is
348 + // therefore not a second split, which is what scanning left to right buys.
349 + let (key, rest) = match trimmed.split_once('=') {
350 + Some((key, rest)) => (key, Some(rest)),
351 + None => (trimmed, None),
352 + };
353 + push(&mut out, Token::Key, key);
354 + let Some(rest) = rest else {
355 + return out;
356 + };
357 + push(&mut out, Token::Plain, "=");
358 + scan_value(&mut out, rest);
359 + out
360 + }
361 +
362 + /// Color a value run: strings, numbers, booleans, and a trailing comment.
363 + fn scan_value(out: &mut Vec<(Token, String)>, rest: &str) {
364 + let mut chars = rest.char_indices().peekable();
365 + while let Some((start, c)) = chars.next() {
366 + match c {
367 + '#' => {
368 + push(out, Token::Comment, &rest[start..]);
369 + return;
370 + }
371 + '"' | '\'' => {
372 + let quote = c;
373 + let mut end = rest.len();
374 + let mut escaped = false;
375 + for (index, inner) in chars.by_ref() {
376 + if escaped {
377 + escaped = false;
378 + continue;
379 + }
380 + // Only basic strings honour `\`; a literal string has no
381 + // escape at all, which is the whole of what makes it
382 + // literal.
383 + if inner == '\\' && quote == '"' {
384 + escaped = true;
385 + } else if inner == quote {
386 + end = index + inner.len_utf8();
387 + break;
388 + }
389 + }
390 + push(out, Token::Str, &rest[start..end]);
391 + }
392 + _ if c.is_ascii_digit() => {
393 + let mut end = start + c.len_utf8();
394 + while let Some(&(index, next)) = chars.peek() {
395 + // Dates and versions ride through on the same run: a config
396 + // reader wants `2026-08-16` to read as one value rather
397 + // than as three numbers with punctuation between them.
398 + if next.is_ascii_alphanumeric() || matches!(next, '.' | '-' | ':' | '+' | '_') {
399 + end = index + next.len_utf8();
400 + chars.next();
401 + } else {
402 + break;
403 + }
404 + }
405 + push(out, Token::Number, &rest[start..end]);
406 + }
407 + _ if c.is_ascii_alphabetic() => {
408 + let mut end = start + c.len_utf8();
409 + while let Some(&(index, next)) = chars.peek() {
410 + if next.is_ascii_alphanumeric() || next == '_' {
411 + end = index + next.len_utf8();
412 + chars.next();
413 + } else {
414 + break;
415 + }
416 + }
417 + let word = &rest[start..end];
418 + let token = if matches!(word, "true" | "false") {
419 + Token::Number
420 + } else {
421 + Token::Plain
422 + };
423 + push(out, token, word);
424 + }
425 + _ => push(out, Token::Plain, &rest[start..start + c.len_utf8()]),
426 + }
427 + }
428 + }
429 +
430 + /// sway's i3-style syntax: a command word, then arguments.
431 + ///
432 + /// No parser, and not trying to be one. The first word of a line is what a
433 + /// reader scans for (`bindsym`, `output`, `exec`), `$variables` are the other
434 + /// thing worth finding by eye, and everything else is an argument.
435 + fn sway_tokens(line: &str) -> Vec<(Token, String)> {
436 + let mut out = Vec::new();
437 + let trimmed = line.trim_start();
438 + let indent = &line[..line.len() - trimmed.len()];
439 + push(&mut out, Token::Plain, indent);
440 +
441 + if trimmed.starts_with('#') {
442 + push(&mut out, Token::Comment, trimmed);
443 + return out;
444 + }
445 +
446 + let mut first = true;
447 + let mut chars = trimmed.char_indices().peekable();
448 + while let Some((start, c)) = chars.next() {
449 + match c {
450 + '#' => {
451 + push(&mut out, Token::Comment, &trimmed[start..]);
452 + return out;
453 + }
454 + '"' => {
455 + let mut end = trimmed.len();
456 + for (index, inner) in chars.by_ref() {
457 + if inner == '"' {
458 + end = index + inner.len_utf8();
459 + break;
460 + }
461 + }
462 + push(&mut out, Token::Str, &trimmed[start..end]);
463 + first = false;
464 + }
465 + c if c.is_whitespace() => push(
466 + &mut out,
467 + Token::Plain,
468 + &trimmed[start..start + c.len_utf8()],
469 + ),
470 + _ => {
471 + let mut end = start + c.len_utf8();
472 + while let Some(&(index, next)) = chars.peek() {
473 + if next.is_whitespace() || next == '"' {
474 + break;
475 + }
476 + end = index + next.len_utf8();
477 + chars.next();
478 + }
479 + let word = &trimmed[start..end];
480 + let token = if word.starts_with('$') {
481 + Token::Var
482 + } else if first {
483 + Token::Key
484 + } else {
485 + Token::Plain
486 + };
487 + push(&mut out, token, word);
488 + first = false;
489 + }
490 + }
491 + }
492 + out
493 + }
494 +
495 + // ---------------------------------------------------------------------------
496 + // The pane
497 + // ---------------------------------------------------------------------------
498 +
499 + /// A file open as text: the buffer, how to color it, and why the form engine
500 + /// did not take it.
Lines truncated
@@ -1,0 +1,29 @@
1 + # Alloy schema for sway's config
2 + #
3 + # Target file: ~/.config/sway/config
4 + # Alloy ships defaults at templates/etc/skel/.config/sway/config.in.
5 + #
6 + # A header and nothing else, deliberately. sway's config is i3-style: not TOML,
7 + # not KDL, and with no roundtrip-safe in-place editor behind it, so there is no
8 + # form to render and `syntax = "sway"` says so. The console opens it in the
9 + # text-edit fallback instead.
10 + #
11 + # The file exists so sway is IN THE LIST. docs/CONSOLE.md: "An app with no
12 + # schema still appears in the list and opens the text-edit fallback, because
13 + # someone looking for 'where do I configure sway' should find an answer rather
14 + # than an absence." The Applications catalog is built from this directory, so
15 + # without an entry here that promise cannot be kept, and the answer to "where
16 + # do I configure sway" is a list that does not mention sway.
17 + #
18 + # Adding one for another schema-less app is this file with three strings
19 + # changed. Declaring the syntax rather than declaring "no form" is what lets a
20 + # file move tiers later without being rewritten: the day a roundtrip-safe KDL
21 + # bind lands, every `syntax = "kdl"` schema starts rendering a form on its own.
22 +
23 + [schema]
24 + target = "config"
25 + target_path = "$XDG_CONFIG_HOME/sway/config"
26 + target_tool = "sway"
27 + target_version = ">=1.10"
28 + schema_version = "1"
29 + syntax = "sway"