max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+1051 insertions,
-47 deletions
| @@ -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" |
| @@ -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" |