Skip to main content

max / alloy

Ship `alloy display`, the last unshipped console verb Fronts swaymsg and nothing else. wlr-randr is dropped from the design and kanshi never arrives: 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 only feature kanshi would have been carried for. Read in sway 1.11's source rather than assumed: handle_new_output ends in request_modeset, which reaches apply_stored_output_configs, which merges the `*` wildcard, then the name, then the identifier. One string, two consumers. `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, so a Directive builds them once: the view runs it, then writes the identical text to ~/.config/sway/config.d/50-display.conf. Because sway merges every matching stanza rather than replacing, that file is regenerated whole from the outputs sway reports, not appended to. Quoting is ours to write. swaymsg's argv is joined with spaces and handed to the same parser a config file goes through, and sway's join_args adds no quoting, so a three-word identifier carries its own double quotes in both consumers. The writer refuses to take away the last usable screen: `enable false` on the only active output, and any scale leaving it under 640 logical pixels wide. That is the one edit with no way back, and in a per-user file it would survive the reboot that would otherwise recover it. No mode picker. The only machine that can test this advertises exactly one mode and there is still no multi-output capture, so a mode list would be a UI written against nothing. The parser is written against the verbatim `swaymsg -t get_outputs` capture from the FW12 install (sway 1.11), which is checked in as the fixture: the "Unknown" serial, millihertz refresh, and the logical rect disagreeing with current_mode are all pinned. reads_this_machines_real _outputs re-checks it inside a real session. The sway template gains `include ~/.config/sway/config.d/*` after the system drop-ins, so a scale set in the console beats the one the image ships. 10-fw12.conf stays for now and says why: nothing seeds the per-user file on a fresh install, so deleting it would boot the one validated machine at 1.0x. Retiring it needs the installer to write a 50-display.conf from the panel it detected.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 02:50 UTC
Signed with PGP, not checked
Commit: 612412b2f75f23220e15c8c650459efeebad7371
Parent: 7f6c8dc
6 files changed, +532 insertions, -8 deletions
M docs/CONSOLE.md +3 -3
@@ -18,7 +18,7 @@
18 18 alloy net # network: nmcli front, connect and radio [shipped]
19 19 alloy audio # audio: pactl front, streams and devices [shipped]
20 20 alloy mesh # mesh VPN: tailscale front (alias: tail) [shipped]
21 - alloy display # outputs: swaymsg output / wlr-randr front
21 + alloy display # outputs: swaymsg front, scale and enable [shipped]
22 22 alloy pkg install # what is layered, and where a package belongs
23 23 alloy pkg box # containers: the isolation dial [shipped]
24 24 alloy update # system updates: rpm-ostree front
@@ -98,7 +98,7 @@
98 98 - **System**, first, because it is the one a user goes looking for. General settings held as live state: time (zone, NTP), hostname, locale and keymap, theme. Time, hostname and locale are built, fronting `timedatectl`, `hostnamectl` and `localectl`; theme is the one left. Each writing row runs exactly one command, and every one of those is in the five actions the shipped polkit rule grants, so none of them prompts and none can reach an action deliberately left prompting. A row whose front did not answer, or whose vocabulary came back empty, is shown and not settable and says why: hiding it would answer "where do I set this" with silence, and offering it would promise a command that is not there. Its rows commit as they are edited rather than at Ctrl-S, and the form reads that from the bind rather than from which tab it is on. Each is a handful of rows over one front with machine-readable output, which is the bar `net` (`nmcli`) and `audio` (`pactl -f json`) were already held to.
99 99 - **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.
100 100
101 - Display and power/idle are deliberately not here. `alloy display` is a specced verb of its own and idle behaviour belongs with it; folding them in would duplicate a screen rather than unify one.
101 + 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.
102 102
103 103 `alloy config <path>` stays as the direct-open escape hatch: one file's form, no tab chrome, which is what a script or a `helix` sidecar wants. Same view, entered with a path instead of a tab.
104 104
@@ -208,7 +208,7 @@
208 208
209 209 - **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.
210 210 - **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.
211 - - **Blocked on the target machine.** `alloy display` fronts `swaymsg`/`wlr-randr`, neither of which exists on a non-sway development box, so its parser cannot be checked against real output here — the way the two box-parser bugs got written. The rpm-ostree tabs were in this category until their output was captured from a booted Alloy install in QEMU (2026-07-22); the `install` and `system` parsers are written against that real capture and re-checkable with `parses_this_machines_real_status` on any ostree box. `alloy display` still wants the image or real hardware.
211 + - **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`. 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).
212 212 - **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.
213 213 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
214 214 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
@@ -25,7 +25,7 @@
25 25 ## Display
26 26
27 27 - **Native panel:** 12.2" 1920x1200 (16:10), matte, IPS-class, ~185 PPI. Sits in the awkward middle where 1.0x is too small and 2.0x is too big.
28 - - **Alloy default scale:** 1.25x fractional under sway (`output eDP-1 scale 1.25` in the sway config's per-machine drop-in). sway handles the compositor side; terminal apps scale with the compositor, so there is no per-app fractional-scale work as there would have been for egui. Verify glyph crispness at v0 sanity check.
28 + - **Alloy default scale:** 1.25x fractional under sway (`output eDP-1 scale 1.25`, shipped in `etc/sway/config.d/10-fw12.conf`). `alloy display` overrides it per user without touching that file: it writes the same directive to `~/.config/sway/config.d/50-display.conf`, which the sway config includes afterwards. sway handles the compositor side; terminal apps scale with the compositor, so there is no per-app fractional-scale work as there would have been for egui. Verify glyph crispness at v0 sanity check.
29 29 - **Contrast/color:** validate the DESIGN-LANGUAGE.md dark-mode L stops on this panel specifically (currently deferred in `todo.md`). Matte 400-nit IPS is roughly the panel class most Alloy users are likely to have; if the tokens miss here, they miss for the base audience.
30 30
31 31 Rejected: 1.0x default (glyphs too small at arm's length on 12.2"), 1.5x default (wastes usable columns in a scrollable-tiling model where horizontal density matters).
@@ -9,6 +9,7 @@
9 9 mod audio;
10 10 mod bind;
11 11 mod cli;
12 + mod display;
12 13 mod install;
13 14 mod mesh;
14 15 mod net;
@@ -47,6 +48,8 @@
47 48 Net,
48 49 /// Audio outputs and inputs
49 50 Audio,
51 + /// Displays sway is driving: scale, rotation, and which are on
52 + Display,
50 53 // `tail` stays as an alias: docs/CONSOLE.md named the verb that way, and
51 54 // the muscle memory is worth more than the tidiness of a single name.
52 55 // Deliberately a plain comment, not a doc comment — clap turns those into
@@ -180,6 +183,10 @@
180 183 let mut view = audio::AudioView::new(&mut log);
181 184 shell::run(&theme, &mut view, &mut log)
182 185 }
186 + Command::Display => {
187 + let mut view = display::DisplayView::new(&mut log);
188 + shell::run(&theme, &mut view, &mut log)
189 + }
183 190 Command::Mesh => {
184 191 let mut view = mesh::MeshView::new(&mut log);
185 192 shell::run(&theme, &mut view, &mut log)
@@ -7,9 +7,20 @@
7 7 #
8 8 # SCOPE: this matches eDP-1, which is the internal panel on any laptop,
9 9 # not just the FW12. Alloy has one hardware target today so shipping it
10 - # active is the pragmatic call. When a second device is validated, this
11 - # should either move to a usr/share/alloy/ template the installer copies,
12 - # or match the panel by make/model instead of by connector name.
13 - # Delete this file on hardware where 1.25x is wrong.
10 + # active is the pragmatic call.
11 + #
12 + # It is no longer the last word, which is what the old version of this
13 + # comment asked for. `alloy display` writes the same directive to
14 + # ~/.config/sway/config.d/50-display.conf, and the shipped sway config
15 + # includes that directory AFTER this one. sway merges every stanza that
16 + # matches an output, the later one winning on a property both name, so a
17 + # scale set in the console overrides this file without touching it. That
18 + # is the recovery path on hardware where 1.25x is wrong: open the console
19 + # and press `s`, rather than editing a root-owned file.
20 + #
21 + # Still shipped because nothing seeds the per-user file on a fresh install.
22 + # Deleting this today would boot the one validated machine at 1.0x. Full
23 + # retirement needs the installer to write a 50-display.conf from the panel
24 + # it detected, which is not built.
14 25
15 26 output eDP-1 scale 1.25
@@ -59,6 +59,12 @@
59 59 # }
60 60 output * bg @{surface.page} solid_color
61 61 include /etc/sway/config.d/*
62 + # `alloy display` owns 50-display.conf in here, and this include is what makes
63 + # the console's writes reach sway at the next login. Ordered after the system
64 + # drop-ins deliberately: sway merges every stanza that matches an output, later
65 + # ones winning on a property both name, so what the user set beats what the
66 + # image shipped. Anything else in this directory is theirs and is left alone.
67 + include ~/.config/sway/config.d/*
62 68
63 69 # -------------------------------------------------------------------
64 70 # Layout / chrome
@@ -1,0 +1,1506 @@
1 + //! `alloy display` — the outputs sway is driving, and the two places a change
2 + //! has to land to be worth making.
3 + //!
4 + //! Fronts `swaymsg` and nothing else. Alloy ships sway as *the* compositor, so a
5 + //! generic wlroots tool (`wlr-randr`) would buy portability the distro cannot
6 + //! spend and cost a package plus a second output format to parse. Neither
7 + //! `wlr-randr` nor `kanshi` is in the image, and this verb adds no packages.
8 + //!
9 + //! # There is only ever one string
10 + //!
11 + //! sway's config directive and swaymsg's runtime command are the same words:
12 + //!
13 + //! ```text
14 + //! output eDP-1 scale 1.25 # in a config file: survives reboot
15 + //! swaymsg output eDP-1 scale 1.25 # at runtime: applies now
16 + //! ```
17 + //!
18 + //! So [`Directive`] builds those words once. [`Directive::invocation`] runs them
19 + //! and [`Directive::line`] writes the identical text to
20 + //! `~/.config/sway/config.d/50-display.conf` for the next boot. No translation
21 + //! layer, no second syntax, nothing to keep in agreement, and the line the log
22 + //! pane shows is a line the user can paste into their own sway config.
23 + //!
24 + //! ## Which is why the quotes are ours to write
25 + //!
26 + //! swaymsg does not pass argv through: its `main` ends at
27 + //! `join_args(argv + optind, argc - optind)`, and sway's `join_args` is a plain
28 + //! space join with no quoting of its own (read in sway 1.11's `swaymsg/main.c`
29 + //! and `common/stringop.c`). The joined string is then split by the same parser
30 + //! that reads a config file. So an identifier containing spaces —
31 + //! `BOE NV122WUM-N42 Unknown` is one — has to carry its quotes *in the string*,
32 + //! or sway sees three words where one was meant. Both consumers need the same
33 + //! quoting for the same reason, which is the one-string claim holding rather
34 + //! than an exception to it. See [`quote`].
35 + //!
36 + //! # sway is the persistence layer; there is no kanshi
37 + //!
38 + //! sway re-applies stored `output` config when an output appears, matching by
39 + //! connector name or by the `make model serial` identifier. That is kanshi's
40 + //! core feature, built in. Verified in sway 1.11's source rather than assumed:
41 + //! `handle_new_output` (`sway/desktop/output.c`) ends in `request_modeset`,
42 + //! which reaches `apply_stored_output_configs`, which merges, in order, the `*`
43 + //! wildcard, the connector name, and then the identifier. What kanshi adds on
44 + //! top is group profiles — "when these three monitors are present, arrange them
45 + //! thus" — which is a docking feature, and there is no dock.
46 + //!
47 + //! **Because those matches merge rather than replace, the file is regenerated
48 + //! whole.** Appending a second stanza for an output leaves the first one's
49 + //! properties live, so a scale the user moved away from would keep applying at
50 + //! the next boot. [`config_file`] writes one stanza per output, every time.
51 + //!
52 + //! # Where the file goes, and why not `/etc`
53 + //!
54 + //! `~/.config/sway/config.d/50-display.conf`, owned and regenerated by the
55 + //! console. Per-user and unprivileged: `system.rs`'s theme row already argues
56 + //! that what a session renders in is the person's business and not the
57 + //! machine's, and display scale is the same class of preference. The shipped
58 + //! sway config includes `~/.config/sway/config.d/*` after
59 + //! `/etc/sway/config.d/*`, so the user's file wins on a conflicting property.
60 + //!
61 + //! The path is built from `$HOME` and not from `$XDG_CONFIG_HOME`, which is the
62 + //! one place this deliberately disagrees with the rest of the console. The
63 + //! include line in the shipped config names `~/.config/sway/config.d/*`
64 + //! literally, so honoring a custom `XDG_CONFIG_HOME` here would write a
65 + //! well-formed file that nothing reads.
66 + //!
67 + //! # The one edit that costs the session
68 + //!
69 + //! Everything else this verb does is recoverable by pressing the key again.
70 + //! Disabling or blanking the only active output is not: the screen goes dark
71 + //! with no way back, and in a per-user config file it survives the reboot that
72 + //! would otherwise recover it. [`refuse`] is the gate, and it runs before the
73 + //! runtime apply as well as before the write.
74 + //!
75 + //! # What is not here
76 + //!
77 + //! No mode picker. The only machine that can test this advertises exactly one
78 + //! mode, and no multi-output capture exists yet, so a mode list would be a UI
79 + //! written against nothing. `Output::modes` is parsed and shown so the gap is
80 + //! visible; choosing between them waits for hardware that has a choice.
81 + //!
82 + //! <!-- wiki: alloy-console -->
83 +
84 + // Writing into a String cannot fail, so the `let _ =` at each call site is
85 + // discarding an error that does not exist rather than ignoring a real one. Same
86 + // use as `pkg.rs`'s.
87 + use std::fmt::Write as _;
88 + use std::path::PathBuf;
89 +
90 + use alloy_tui::keys::Action;
91 + use alloy_tui::{
92 + AlloyBlock, AlloyList, Cursor, Hint, KeyGroup, Severity, Theme, binding, hint, text,
93 + unavailable,
94 + };
95 + use anyhow::Result;
96 + use ratatui::Frame;
97 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
98 + use ratatui::layout::Rect;
99 + use ratatui::text::{Line, Span};
100 + use serde::Deserialize;
101 +
102 + use crate::cli::{CommandLog, Effect, Invocation};
103 + use crate::shell::{Flow, View, block_title};
104 +
105 + /// The file this verb owns.
106 + const FILE: &str = ".config/sway/config.d/50-display.conf";
107 +
108 + /// What sway reports for a field the panel does not carry.
109 + ///
110 + /// The literal string, not null and not absent: `output_get_identifier` in
111 + /// sway's `config/output.c` substitutes it for a missing make, model, or serial.
112 + /// The FW12 panel's serial is exactly this, which is why the identifier rule
113 + /// below does not trust a triple to be unique.
114 + const UNKNOWN: &str = "Unknown";
115 +
116 + /// The scales the `s` key walks.
117 + ///
118 + /// A ladder rather than a text field: the useful values on a HiDPI panel are
119 + /// quarter steps, and a field would need its own validation to keep somebody
120 + /// from typing a scale that leaves the session unreadable. Anything off the
121 + /// ladder still displays correctly, and the key moves to the next rung above it.
122 + const SCALES: [f64; 5] = [1.0, 1.25, 1.5, 1.75, 2.0];
123 +
124 + /// One mode an output advertises.
125 + ///
126 + /// `refresh` is millihertz. sway reports `60002` for a 60 Hz panel, so dividing
127 + /// by 1000 in the wrong place is how a mode line comes out as `@60Hz` for a
128 + /// panel that does not have one.
129 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
130 + pub(crate) struct Mode {
131 + pub width: u32,
132 + pub height: u32,
133 + /// Millihertz, as sway reports it.
134 + #[serde(default)]
135 + pub refresh: u32,
136 + }
137 +
138 + impl Mode {
139 + /// The mode as sway's `mode` directive spells it.
140 + ///
141 + /// Three decimals because that is what millihertz carries and because
142 + /// `60.002` is a real refresh rate: rounding it to `60` names a mode the
143 + /// panel does not advertise, and sway matches modes by value.
144 + fn spelled(self) -> String {
145 + format!(
146 + "{}x{}@{:.3}Hz",
147 + self.width,
148 + self.height,
149 + f64::from(self.refresh) / 1000.0
150 + )
151 + }
152 + }
153 +
154 + /// A rectangle in sway's layout, in logical pixels.
155 + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
156 + pub(crate) struct Rectangle {
157 + #[serde(default)]
158 + pub x: i32,
159 + #[serde(default)]
160 + pub y: i32,
161 + #[serde(default)]
162 + pub width: u32,
163 + #[serde(default)]
164 + pub height: u32,
165 + }
166 +
167 + /// One output, as `swaymsg -t get_outputs` describes it.
168 + ///
169 + /// Every field here is one the view or a directive reads. sway sends a good deal
170 + /// more (`percent`, `deco_rect`, `nodes`, the null `floating` and
171 + /// `scratchpad_state`), and serde ignores what is not named — which is the
172 + /// reason this is a derive rather than a hand-rolled reader. sway escapes
173 + /// forward slashes in its JSON strings, and a string reader written for this
174 + /// payload is a string reader that has to know that.
175 + #[derive(Debug, Clone, Deserialize)]
176 + pub(crate) struct Output {
177 + /// The connector name: `eDP-1`, `DP-3`, `HDMI-A-1`.
178 + pub name: String,
179 + #[serde(default)]
180 + pub make: String,
181 + #[serde(default)]
182 + pub model: String,
183 + #[serde(default)]
184 + pub serial: String,
185 + /// Whether sway is driving it. An output that is present but disabled is
186 + /// listed with `active: false`, and it is still ours to re-enable.
187 + #[serde(default)]
188 + pub active: bool,
189 + /// Whether the panel is awake. Distinct from `active`: a `dpms off` output
190 + /// is still in the layout, just dark.
191 + #[serde(default = "yes")]
192 + pub dpms: bool,
193 + #[serde(default)]
194 + pub focused: bool,
195 + /// Logical size and position, which at any scale other than 1 disagrees with
196 + /// `current_mode` by design.
197 + #[serde(default)]
198 + pub rect: Rectangle,
199 + #[serde(default = "one")]
200 + pub scale: f64,
201 + #[serde(default)]
202 + pub transform: String,
203 + #[serde(default)]
204 + pub current_mode: Option<Mode>,
205 + #[serde(default)]
206 + pub modes: Vec<Mode>,
207 + }
208 +
209 + /// `dpms` defaults to true when absent: an output sway lists and does not
210 + /// describe as asleep is awake. Guessing the other way would draw every row as
211 + /// blanked on a sway version that stopped sending the field.
212 + fn yes() -> bool {
213 + true
214 + }
215 +
216 + fn one() -> f64 {
217 + 1.0
218 + }
219 +
220 + impl Output {
221 + /// Whether this is the machine's built-in panel.
222 + ///
223 + /// By connector prefix, which is the only signal in the payload: eDP is the
224 + /// embedded DisplayPort every laptop panel arrives on, LVDS is what older
225 + /// ones used, and DSI is the small-board and tablet case. sway derives these
226 + /// names from DRM, so they are the kernel's vocabulary rather than sway's.
227 + pub(crate) fn built_in(&self) -> bool {
228 + let name = self.name.to_ascii_uppercase();
229 + ["EDP", "LVDS", "DSI"]
230 + .iter()
231 + .any(|prefix| name.starts_with(prefix))
232 + }
233 +
234 + /// The name a config stanza should match this output by.
235 + ///
236 + /// The `make model serial` triple for external outputs, because it survives
237 + /// being replugged into a different port and the connector name does not.
238 + /// The connector name for the built-in panel, because the triple buys
239 + /// nothing there — a laptop panel does not move — and because a serial of
240 + /// `Unknown` makes the triple ambiguous rather than portable: two identical
241 + /// serial-less panels produce the same one.
242 + ///
243 + /// An external output with no make and no model falls back to the connector
244 + /// name for the same reason. `Unknown Unknown Unknown` matches every such
245 + /// output at once, which is the one identifier that could apply a stanza to
246 + /// hardware it was never written for.
247 + pub(crate) fn identifier(&self) -> String {
248 + if self.built_in() || !self.identifiable() {
249 + return self.name.clone();
250 + }
251 + format!("{} {} {}", self.make, self.model, self.serial)
252 + }
253 +
254 + /// Whether the triple says anything specific about this output.
255 + ///
256 + /// Make or model is enough. Serial alone is not: it is the field most often
257 + /// `Unknown`, and a triple carrying only a serial is a triple that has
258 + /// already lost the two fields that identify the panel.
259 + fn identifiable(&self) -> bool {
260 + [&self.make, &self.model]
261 + .into_iter()
262 + .any(|field| !field.is_empty() && field != UNKNOWN)
263 + }
264 +
265 + /// What the row shows for a make and model, when it has one.
266 + fn description(&self) -> String {
267 + let named: Vec<&str> = [self.make.as_str(), self.model.as_str()]
268 + .into_iter()
269 + .filter(|field| !field.is_empty() && *field != UNKNOWN)
270 + .collect();
271 + named.join(" ")
272 + }
273 +
274 + /// Whether this output is contributing a usable screen right now.
275 + ///
276 + /// Both halves, because either one alone leaves the user looking at nothing:
277 + /// an inactive output is out of the layout, and a `dpms off` output is in it
278 + /// and dark. [`refuse`] counts these.
279 + fn usable(&self) -> bool {
280 + self.active && self.dpms
281 + }
282 +
283 + /// The next rung of the scale ladder above the current scale.
284 + ///
285 + /// Wraps, and a scale that is off the ladder lands on the first rung above
286 + /// it rather than snapping to the nearest, so pressing the key always
287 + /// changes something. `1.0` is the wrap target rather than a hard floor,
288 + /// which is what keeps the key from being a dead end at the top.
289 + fn next_scale(&self) -> f64 {
290 + SCALES
291 + .iter()
292 + .copied()
293 + // Not `>` on floats parsed from JSON: sway sends 1.25 and the ladder
294 + // holds 1.25, and an exact-equality assumption between the two is a
295 + // key that stops working on a value that reads identical.
296 + .find(|rung| *rung > self.scale + f64::EPSILON)
297 + .unwrap_or(SCALES[0])
298 + }
299 +
300 + /// The properties the console persists for this output, in the order the
301 + /// stanza spells them.
302 + ///
303 + /// Deliberately short. Everything here is something the console can set and
304 + /// the user can see, and everything omitted is either sway's default or a
305 + /// decision this verb does not make yet: no `position` (that wants a layout
306 + /// UI), no `mode` (the only testable panel advertises one), no `bg` (swww
307 + /// owns the wallpaper).
308 + fn persisted(&self) -> Vec<Directive> {
309 + let mut directives = vec![Directive::new(self, "scale", spell_scale(self.scale))];
310 + // `normal` is sway's default, so writing it says nothing and reads as
311 + // though the console had an opinion about rotation.
312 + if !self.transform.is_empty() && self.transform != "normal" {
313 + directives.push(Directive::new(self, "transform", &self.transform));
314 + }
315 + // An output the user turned off stays off across a reboot, which is the
316 + // whole point of persisting it. `refuse` is what keeps this from being
317 + // the last output standing.
318 + if !self.active {
319 + directives.push(Directive::new(self, "enable", "false"));
320 + }
321 + directives
322 + }
323 + }
324 +
325 + /// Spell a scale the way sway's parser reads it back.
326 + ///
327 + /// Trailing zeros trimmed, because `scale 1.250000` is the same instruction
328 + /// spelled to look machine-generated, and the whole point of the shared string
329 + /// is that a person can read the line and paste it.
330 + fn spell_scale(scale: f64) -> String {
331 + let spelled = format!("{scale:.3}");
332 + let trimmed = spelled.trim_end_matches('0').trim_end_matches('.');
333 + if trimmed.is_empty() {
334 + "1".to_string()
335 + } else {
336 + trimmed.to_string()
337 + }
338 + }
339 +
340 + /// One `output <identifier> <property> <value>` instruction.
341 + ///
342 + /// The words after the command name, which is the whole of what sway needs and
343 + /// exactly what a config file holds. Held as its three parts rather than a
344 + /// finished string so the identifier can be quoted once, in [`words`], for both
345 + /// consumers.
346 + ///
347 + /// [`words`]: Directive::words
348 + #[derive(Debug, Clone, PartialEq, Eq)]
349 + pub(crate) struct Directive {
350 + identifier: String,
351 + property: &'static str,
352 + value: String,
353 + }
354 +
355 + impl Directive {
356 + fn new(output: &Output, property: &'static str, value: impl Into<String>) -> Self {
357 + Self {
358 + identifier: output.identifier(),
359 + property,
360 + value: value.into(),
361 + }
362 + }
363 +
364 + /// The instruction, quoted for sway's parser.
365 + pub(crate) fn words(&self) -> String {
366 + format!(
367 + "output {} {} {}",
368 + quote(&self.identifier),
369 + self.property,
370 + self.value
371 + )
372 + }
373 +
374 + /// Apply it now.
375 + ///
376 + /// One argv element per word, which is what makes the displayed line the one
377 + /// a person would have typed: `swaymsg output eDP-1 scale 1.25`. Passing the
378 + /// whole instruction as a single argument reaches sway identically, since
379 + /// swaymsg joins its arguments with spaces anyway, but the log pane shell-
380 + /// quotes an argument containing spaces and the line comes out as
381 + /// `swaymsg 'output eDP-1 scale 1.25'`.
382 + ///
383 + /// There are two layers of quoting here and each strips its own. The double
384 + /// quotes around a triple identifier are sway's, and they have to survive the
385 + /// shell; the single quotes the log adds are the shell's, and they do not
386 + /// reach sway. A pasted `swaymsg output '"Example Co PA279CV S1"' scale 2` is
387 + /// therefore the same instruction, which is the property the pane advertises.
388 + pub(crate) fn invocation(&self) -> Invocation {
389 + Invocation::new("swaymsg").args([
390 + "output",
391 + &quote(&self.identifier),
392 + self.property,
393 + &self.value,
394 + ])
395 + }
396 +
397 + /// The line that puts it back at the next login. The same words.
398 + pub(crate) fn line(&self) -> String {
399 + self.words()
400 + }
401 + }
402 +
403 + /// Quote an identifier if sway's parser would otherwise split it.
404 + ///
405 + /// The `make model serial` triple is three words, and sway's config parser
406 + /// splits on whitespace before it looks for an output. A connector name never
407 + /// needs this, and quoting it anyway would make the log pane's line noisier than
408 + /// the one a person would have typed.
409 + fn quote(identifier: &str) -> String {
410 + if identifier.contains(char::is_whitespace) {
411 + format!("\"{identifier}\"")
412 + } else {
413 + identifier.to_string()
414 + }
415 + }
416 +
417 + /// Why a change must not be made, or `None` when it may be.
418 + ///
419 + /// One rule, stated once, checked before the runtime apply and before the write:
420 + /// nothing may take away the last usable screen. `enable false` and `dpms off`
421 + /// are the two direct ways, and a scale that leaves no readable geometry is the
422 + /// indirect one — a session at scale 8 on a 1920x1200 panel is 240x150 logical
423 + /// pixels, which is a dark screen with extra steps.
424 + ///
425 + /// Returns the reason rather than a bool so the view can say which rule it hit.
426 + /// "Nothing happened" is the one outcome a console must never produce.
427 + fn refuse(outputs: &[Output], target: &Output, change: &Change) -> Option<String> {
428 + let others_usable = outputs
429 + .iter()
430 + .filter(|other| other.name != target.name)
431 + .any(Output::usable);
432 +
433 + match change {
434 + Change::Enabled(false) => {
435 + (!others_usable).then(|| format!("{} is the only screen left", target.name))
436 + }
437 + Change::Scale(scale) => {
438 + let mode = target
439 + .current_mode
440 + .or_else(|| target.modes.first().copied());
441 + // No mode at all means no geometry to check. Refusing on that would
442 + // block the key on every output sway has not finished describing,
443 + // which is a worse failure than a scale that has to be pressed twice.
444 + let mode = mode?;
445 + let logical = f64::from(mode.width) / scale;
446 + (logical < 640.0).then(|| {
447 + format!(
448 + "scale {} leaves {} at {} logical pixels wide",
449 + spell_scale(*scale),
450 + target.name,
451 + logical.round(),
452 + )
453 + })
454 + }
455 + // Switching an output back on takes nothing away, and it is the key that
456 + // recovers from the state the rules above are guarding against.
457 + Change::Enabled(true) => None,
458 + }
459 + }
460 +
461 + /// A change to one output.
462 + ///
463 + /// Named for the property rather than the key, so the safety rule reads as a
464 + /// rule about outputs instead of a rule about keystrokes.
465 + #[derive(Debug, Clone, Copy, PartialEq)]
466 + pub(crate) enum Change {
467 + Scale(f64),
468 + Enabled(bool),
469 + }
470 +
471 + impl Change {
472 + fn directive(self, output: &Output) -> Directive {
473 + match self {
474 + Change::Scale(scale) => Directive::new(output, "scale", spell_scale(scale)),
475 + Change::Enabled(on) => {
476 + Directive::new(output, "enable", if on { "true" } else { "false" })
477 + }
478 + }
479 + }
480 + }
481 +
482 + /// Regenerate the whole file from the outputs sway is reporting.
483 + ///
484 + /// Whole rather than patched, because sway merges every stanza that matches an
485 + /// output: a leftover `scale 1.25` from a previous write keeps applying even
486 + /// after a newer stanza sets something else. Regenerating is also what makes the
487 + /// file honest — it describes the outputs as they are now, which is what the
488 + /// user just looked at.
489 + ///
490 + /// A hand edit is lost, and the header says so rather than leaving somebody to
491 + /// find out. This is the one file in the skeleton the console claims outright;
492 + /// `theme_apply.rs` keeps user edits because those files are seeds, and a seed
493 + /// is not the same thing as a generated artifact.
494 + pub(crate) fn config_file(outputs: &[Output]) -> String {
495 + let mut out = String::from(
496 + "# Generated by `alloy display`. Regenerated whole on every change, so\n\
497 + # hand edits here are lost. Put your own output config in another file\n\
498 + # in this directory: sway merges every stanza that matches an output,\n\
499 + # and the shipped config includes all of ~/.config/sway/config.d/*.\n",
500 + );
Lines truncated