Skip to main content

max / alloy

Give display a module layer, so its three programs stop sharing a file display.rs held the sway front, the headless reconcile main.rs runs before the theme loads, and the installer's panel seed, over one output vocabulary. Those are files now: model, config, backend, reconcile, panel, view. The vocabulary stays in one file on purpose. Output::persisted returns directives and config_file reads outputs back, so a boundary between Output and Directive would be a two-way edge; keeping them together leaves every other module depending on model and model depending on nothing but std, serde, alloy_tui and Invocation.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 17:16 UTC
Signed with PGP, not checked
Commit: c50c57a6cbfafcccde0b882617e479fd0dc22b3c
Parent: d1fa565
15 files changed, +2381 insertions, -1516 deletions
@@ -110,25 +110,6 @@
110 110 // Writing into a String cannot fail, so the `let _ =` at each call site is
111 111 // discarding an error that does not exist rather than ignoring a real one. Same
112 112 // use as `pkg.rs`'s.
113 - use std::fmt::Write as _;
114 - use std::path::PathBuf;
115 -
116 - use alloy_tui::keys::Action;
117 - use alloy_tui::{
118 - AlloyBlock, AlloyList, Cursor, Hint, KeyGroup, Severity, Theme, binding, hint, text,
119 - unavailable,
120 - };
121 - use anyhow::Result;
122 - use ratatui::Frame;
123 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
124 - use ratatui::layout::Rect;
125 - use ratatui::text::{Line, Span};
126 - use serde::Deserialize;
127 -
128 - use crate::cli::{CommandLog, Effect, Invocation};
129 - use crate::monitors;
130 - use crate::shell::{Flow, View, block_title};
131 -
132 113 /// The file this verb owns, relative to a home directory.
133 114 ///
134 115 /// Relative rather than absolute because the installer writes it too, into a
@@ -143,7 +124,7 @@
143 124 /// sway's `config/output.c` substitutes it for a missing make, model, or serial.
144 125 /// The FW12 panel's serial is exactly this, which is why the identifier rule
145 126 /// below does not trust a triple to be unique.
146 - const UNKNOWN: &str = "Unknown";
127 + pub(super) const UNKNOWN: &str = "Unknown";
147 128
148 129 /// The scales the `s` key walks.
149 130 ///
@@ -151,1241 +132,29 @@
151 132 /// quarter steps, and a field would need its own validation to keep somebody
152 133 /// from typing a scale that leaves the session unreadable. Anything off the
153 134 /// ladder still displays correctly, and the key moves to the next rung above it.
154 - const SCALES: [f64; 5] = [1.0, 1.25, 1.5, 1.75, 2.0];
155 -
156 - /// One mode an output advertises.
157 - ///
158 - /// `refresh` is millihertz. sway reports `60002` for a 60 Hz panel, so dividing
159 - /// by 1000 in the wrong place is how a mode line comes out as `@60Hz` for a
160 - /// panel that does not have one.
161 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
162 - pub(crate) struct Mode {
163 - pub width: u32,
164 - pub height: u32,
165 - /// Millihertz, as sway reports it.
166 - #[serde(default)]
167 - pub refresh: u32,
168 - }
169 -
170 - impl Mode {
171 - /// The mode as sway's `mode` directive spells it.
172 - ///
173 - /// Three decimals because that is what millihertz carries and because
174 - /// `60.002` is a real refresh rate: rounding it to `60` names a mode the
175 - /// panel does not advertise, and sway matches modes by value.
176 - fn spelled(self) -> String {
177 - format!(
178 - "{}x{}@{:.3}Hz",
179 - self.width,
180 - self.height,
181 - f64::from(self.refresh) / 1000.0
182 - )
183 - }
184 - }
185 -
186 - /// A rectangle in sway's layout, in logical pixels.
187 - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
188 - pub(crate) struct Rectangle {
189 - #[serde(default)]
190 - pub x: i32,
191 - #[serde(default)]
192 - pub y: i32,
193 - #[serde(default)]
194 - pub width: u32,
195 - #[serde(default)]
196 - pub height: u32,
197 - }
198 -
199 - /// One output, as `swaymsg -t get_outputs` describes it.
200 - ///
201 - /// Every field here is one the view or a directive reads. sway sends a good deal
202 - /// more (`percent`, `deco_rect`, `nodes`, the null `floating` and
203 - /// `scratchpad_state`), and serde ignores what is not named — which is the
204 - /// reason this is a derive rather than a hand-rolled reader. sway escapes
205 - /// forward slashes in its JSON strings, and a string reader written for this
206 - /// payload is a string reader that has to know that.
207 - #[derive(Debug, Clone, Deserialize)]
208 - pub(crate) struct Output {
209 - /// The connector name: `eDP-1`, `DP-3`, `HDMI-A-1`.
210 - pub name: String,
211 - #[serde(default)]
212 - pub make: String,
213 - #[serde(default)]
214 - pub model: String,
215 - #[serde(default)]
216 - pub serial: String,
217 - /// Whether sway is driving it. An output that is present but disabled is
218 - /// listed with `active: false`, and it is still ours to re-enable.
219 - #[serde(default)]
220 - pub active: bool,
221 - /// Whether the panel is awake. Distinct from `active`: a `dpms off` output
222 - /// is still in the layout, just dark.
223 - #[serde(default = "yes")]
224 - pub dpms: bool,
225 - #[serde(default)]
226 - pub focused: bool,
227 - /// Logical size and position, which at any scale other than 1 disagrees with
228 - /// `current_mode` by design.
229 - #[serde(default)]
230 - pub rect: Rectangle,
231 - #[serde(default = "one")]
232 - pub scale: f64,
233 - #[serde(default)]
234 - pub transform: String,
235 - #[serde(default)]
236 - pub current_mode: Option<Mode>,
237 - #[serde(default)]
238 - pub modes: Vec<Mode>,
239 - }
240 -
241 - /// `dpms` defaults to true when absent: an output sway lists and does not
242 - /// describe as asleep is awake. Guessing the other way would draw every row as
243 - /// blanked on a sway version that stopped sending the field.
244 - fn yes() -> bool {
245 - true
246 - }
247 -
248 - fn one() -> f64 {
249 - 1.0
250 - }
251 -
252 - impl Output {
253 - /// Whether this is the machine's built-in panel.
254 - ///
255 - /// By connector prefix, which is the only signal in the payload: eDP is the
256 - /// embedded DisplayPort every laptop panel arrives on, LVDS is what older
257 - /// ones used, and DSI is the small-board and tablet case. sway derives these
258 - /// names from DRM, so they are the kernel's vocabulary rather than sway's.
259 - pub(crate) fn built_in(&self) -> bool {
260 - let name = self.name.to_ascii_uppercase();
261 - ["EDP", "LVDS", "DSI"]
262 - .iter()
263 - .any(|prefix| name.starts_with(prefix))
264 - }
265 -
266 - /// The name a config stanza matches this output by: the connector, always.
267 - ///
268 - /// Never the `make model serial` triple, even though a triple survives being
269 - /// replugged into a different port and a connector name does not:
270 - ///
271 - /// - **The triple is vendor text in a file sway parses.** A quote or a
272 - /// newline in an EDID model string is a malformed line in the config
273 - /// loaded at login. Emitting the connector always removes the hazard
274 - /// instead of guarding it.
275 - /// - **The triple is not unique.** Two identical monitors with no serial
276 - /// produce the same one, sway merges the stanzas, and the last wins for
277 - /// both. Two connectors are always two stanzas.
278 - ///
279 - /// What carries the portability instead is [`crate::monitors`]: a table
280 - /// keyed by a hash of the same three fields, and a reconcile that moves the
281 - /// settings to the port the monitor is on now. The cost is that the config
282 - /// is only correct if something runs after a replug, which that module
283 - /// records.
284 - pub(crate) fn identifier(&self) -> String {
285 - self.name.clone()
286 - }
287 -
288 - /// This output's content-addressed identity, or `None` when there is no
289 - /// identity to address.
290 - ///
291 - /// Make or model has to say something. Serial alone is not enough: it is the
292 - /// field most often `Unknown`, and a hash of nothing but a serial is an
293 - /// identity built from the two fields that were missing. A panel with
294 - /// neither is remembered by nothing, which is correct — there is no fact
295 - /// about it that would survive a replug.
296 - ///
297 - /// The built-in panel is excluded for a different reason: it cannot move, so
298 - /// an entry for it would be a row that can never be wrong and never be
299 - /// useful.
300 - pub(crate) fn fingerprint(&self) -> Option<String> {
301 - if self.built_in() || !self.identifiable() {
302 - return None;
303 - }
304 - Some(monitors::fingerprint(&self.make, &self.model, &self.serial))
305 - }
306 -
307 - /// Whether the triple says anything specific about this output.
308 - ///
309 - /// Make or model is enough. Serial alone is not: it is the field most often
310 - /// `Unknown`, and a triple carrying only a serial is a triple that has
311 - /// already lost the two fields that identify the panel.
312 - fn identifiable(&self) -> bool {
313 - [&self.make, &self.model]
314 - .into_iter()
315 - .any(|field| !field.is_empty() && field != UNKNOWN)
316 - }
317 -
318 - /// What the row shows for a make and model, when it has one.
319 - fn description(&self) -> String {
320 - let named: Vec<&str> = [self.make.as_str(), self.model.as_str()]
321 - .into_iter()
322 - .filter(|field| !field.is_empty() && *field != UNKNOWN)
323 - .collect();
324 - named.join(" ")
325 - }
326 -
327 - /// Whether this output is contributing a usable screen right now.
328 - ///
329 - /// Both halves, because either one alone leaves the user looking at nothing:
330 - /// an inactive output is out of the layout, and a `dpms off` output is in it
331 - /// and dark. [`refuse`] counts these.
332 - fn usable(&self) -> bool {
333 - self.active && self.dpms
334 - }
335 -
336 - /// The next rung of the scale ladder above the current scale.
337 - ///
338 - /// Wraps, and a scale that is off the ladder lands on the first rung above
339 - /// it rather than snapping to the nearest, so pressing the key always
340 - /// changes something. `1.0` is the wrap target rather than a hard floor,
341 - /// which is what keeps the key from being a dead end at the top.
342 - fn next_scale(&self) -> f64 {
343 - SCALES
344 - .iter()
345 - .copied()
346 - // Not `>` on floats parsed from JSON: sway sends 1.25 and the ladder
347 - // holds 1.25, and an exact-equality assumption between the two is a
348 - // key that stops working on a value that reads identical.
349 - .find(|rung| *rung > self.scale + f64::EPSILON)
350 - .unwrap_or(SCALES[0])
351 - }
352 -
353 - /// The properties the console persists for this output, in the order the
354 - /// stanza spells them.
355 - ///
356 - /// Deliberately short. Everything here is something the console can set and
357 - /// the user can see, and everything omitted is either sway's default or a
358 - /// decision this verb does not make yet: no `position` (that wants a layout
359 - /// UI), no `mode` (the only testable panel advertises one), no `bg` (swww
360 - /// owns the wallpaper).
361 - fn persisted(&self) -> Vec<Directive> {
362 - let mut directives = vec![Directive::new(self, "scale", spell_scale(self.scale))];
363 - // `normal` is sway's default, so writing it says nothing and reads as
364 - // though the console had an opinion about rotation.
365 - if !self.transform.is_empty() && self.transform != "normal" {
366 - directives.push(Directive::new(self, "transform", &self.transform));
367 - }
368 - // An output the user turned off stays off across a reboot, which is the
369 - // whole point of persisting it. `refuse` is what keeps this from being
370 - // the last output standing.
371 - if !self.active {
372 - directives.push(Directive::new(self, "enable", "false"));
373 - }
374 - directives
375 - }
376 - }
377 -
378 - /// Spell a scale the way sway's parser reads it back.
379 - ///
380 - /// Trailing zeros trimmed, because `scale 1.250000` is the same instruction
381 - /// spelled to look machine-generated, and the whole point of the shared string
382 - /// is that a person can read the line and paste it.
383 - fn spell_scale(scale: f64) -> String {
384 - let spelled = format!("{scale:.3}");
385 - let trimmed = spelled.trim_end_matches('0').trim_end_matches('.');
386 - if trimmed.is_empty() {
387 - "1".to_string()
388 - } else {
389 - trimmed.to_string()
390 - }
391 - }
392 -
393 - /// One `output <identifier> <property> <value>` instruction.
394 - ///
395 - /// The words after the command name, which is the whole of what sway needs and
396 - /// exactly what a config file holds. Held as its three parts rather than a
397 - /// finished string so the identifier can be quoted once, in [`words`], for both
398 - /// consumers.
399 - ///
400 - /// [`words`]: Directive::words
401 - #[derive(Debug, Clone, PartialEq, Eq)]
402 - pub(crate) struct Directive {
403 - identifier: String,
404 - property: &'static str,
405 - value: String,
406 - }
407 -
408 - impl Directive {
409 - fn new(output: &Output, property: &'static str, value: impl Into<String>) -> Self {
410 - Self::at(output.identifier(), property, value)
411 - }
412 -
413 - /// A directive aimed at a connector by name, for the reconcile: it moves a
414 - /// remembered setting onto whatever port the monitor turned up on, and that
415 - /// port is a string rather than an `Output` field it can borrow.
416 - fn at(identifier: String, property: &'static str, value: impl Into<String>) -> Self {
417 - debug_assert!(
418 - is_one_word(&identifier),
419 - "a stanza identifier must be a connector name and nothing else: {identifier}"
420 - );
421 - Self {
422 - identifier,
423 - property,
424 - value: value.into(),
425 - }
426 - }
427 -
428 - /// The instruction, as sway's parser reads it.
429 - ///
430 - /// No quoting. The identifier is a connector name, which is one word by
431 - /// construction; see [`is_one_word`].
432 - pub(crate) fn words(&self) -> String {
433 - format!(
434 - "output {} {} {}",
435 - self.identifier, self.property, self.value
436 - )
437 - }
438 -
439 - /// Apply it now.
440 - ///
441 - /// One argv element per word, which is what makes the displayed line the one
442 - /// a person would have typed: `swaymsg output eDP-1 scale 1.25`. Passing the
443 - /// whole instruction as a single argument reaches sway identically, since
444 - /// swaymsg joins its arguments with spaces anyway, but the log pane shell-
445 - /// quotes an argument containing spaces and the line comes out as
446 - /// `swaymsg 'output eDP-1 scale 1.25'`.
447 - ///
448 - pub(crate) fn invocation(&self) -> Invocation {
449 - Invocation::new("swaymsg").args(["output", &self.identifier, self.property, &self.value])
450 - }
451 -
452 - /// The line that puts it back at the next login. The same words.
453 - pub(crate) fn line(&self) -> String {
454 - self.words()
455 - }
456 - }
457 -
458 - /// Whether an identifier is one sway's parser reads as a single word.
459 - ///
460 - /// A connector name always is: DRM's vocabulary is `eDP-1`, `DP-3`,
461 - /// `HDMI-A-1`, and nothing in it needs quoting. This is an assertion that it
462 - /// stayed that way rather than a quoting function.
463 - ///
464 - /// Quoting a `make model serial` triple is the thing not to reintroduce: a `"`
465 - /// inside a vendor's model string closes the quote early and the rest of the
466 - /// line becomes stray tokens in the config sway loads at login. Writing the
467 - /// connector name always is what removes the question. This assertion is the
468 - /// tripwire at the one place that would notice a future change quietly putting
469 - /// vendor text back into a stanza.
470 - fn is_one_word(identifier: &str) -> bool {
471 - !identifier.is_empty()
472 - && !identifier
473 - .chars()
474 - .any(|c| c.is_whitespace() || c == '"' || c == '\\' || c.is_control())
475 - }
476 -
477 - /// Why a change must not be made, or `None` when it may be.
478 - ///
479 - /// One rule, stated once, checked before the runtime apply and before the write:
480 - /// nothing may take away the last usable screen. `enable false` and `dpms off`
481 - /// are the two direct ways, and a scale that leaves no readable geometry is the
482 - /// indirect one — a session at scale 8 on a 1920x1200 panel is 240x150 logical
483 - /// pixels, which is a dark screen with extra steps.
484 - ///
485 - /// Returns the reason rather than a bool so the view can say which rule it hit.
486 - /// "Nothing happened" is the one outcome a console must never produce.
487 - fn refuse(outputs: &[Output], target: &Output, change: &Change) -> Option<String> {
488 - let others_usable = outputs
489 - .iter()
490 - .filter(|other| other.name != target.name)
491 - .any(Output::usable);
492 -
493 - match change {
494 - Change::Enabled(false) => {
495 - (!others_usable).then(|| format!("{} is the only screen left", target.name))
496 - }
497 - Change::Scale(scale) => {
498 - let mode = target
499 - .current_mode
500 - .or_else(|| target.modes.first().copied());
501 - // No mode at all means no geometry to check. Refusing on that would
502 - // block the key on every output sway has not finished describing,
503 - // which is a worse failure than a scale that has to be pressed twice.
504 - let mode = mode?;
505 - let logical = f64::from(mode.width) / scale;
506 - (logical < 640.0).then(|| {
507 - format!(
508 - "scale {} leaves {} at {} logical pixels wide",
509 - spell_scale(*scale),
510 - target.name,
511 - logical.round(),
512 - )
513 - })
514 - }
515 - // Switching an output back on takes nothing away, and it is the key that
516 - // recovers from the state the rules above are guarding against.
517 - Change::Enabled(true) => None,
518 - }
519 - }
520 -
521 - /// A change to one output.
522 - ///
523 - /// Named for the property rather than the key, so the safety rule reads as a
524 - /// rule about outputs instead of a rule about keystrokes.
525 - #[derive(Debug, Clone, Copy, PartialEq)]
526 - pub(crate) enum Change {
527 - Scale(f64),
528 - Enabled(bool),
529 - }
530 -
531 - impl Change {
532 - fn directive(self, output: &Output) -> Directive {
533 - match self {
534 - Change::Scale(scale) => Directive::new(output, "scale", spell_scale(scale)),
535 - Change::Enabled(on) => {
536 - Directive::new(output, "enable", if on { "true" } else { "false" })
537 - }
538 - }
539 - }
540 - }
541 -
542 - /// Regenerate the whole file from the outputs sway is reporting.
543 - ///
544 - /// Whole rather than patched, because sway merges every stanza that matches an
545 - /// output: a leftover `scale 1.25` from a previous write keeps applying even
546 - /// after a newer stanza sets something else. Regenerating is also what makes the
547 - /// file honest — it describes the outputs as they are now, which is what the
548 - /// user just looked at.
549 - ///
550 - /// A hand edit is lost, and the header says so rather than leaving somebody to
551 - /// find out. This is the one file in the skeleton the console claims outright;
552 - /// `theme_apply.rs` keeps user edits because those files are seeds, and a seed
553 - /// is not the same thing as a generated artifact.
554 - pub(crate) fn config_file(outputs: &[Output]) -> String {
555 - let mut out = String::from(
556 - "# Generated by Alloy: seeded at install from the screens this machine\n\
557 - # reports, and rewritten whole by `alloy display`, so\n\
558 - # hand edits here are lost. Put your own output config in another file\n\
559 - # in this directory: sway merges every stanza that matches an output,\n\
560 - # and the shipped config includes all of ~/.config/sway/config.d/*.\n",
561 - );
562 - for output in outputs {
563 - out.push('\n');
564 - // The comment is where the monitor is named, now that the directive
565 - // below it is a connector and says nothing about which screen that is.
566 - // The fingerprint rides along so the file and the monitor table can be
567 - // read against each other without running anything.
568 - let described = output.description();
569 - match (described.is_empty(), output.fingerprint()) {
570 - (true, None) => {
571 - let _ = writeln!(out, "# {}", output.name);
572 - }
573 - (true, Some(id)) => {
574 - let _ = writeln!(out, "# {} [{id}]", output.name);
575 - }
576 - (false, None) => {
577 - let _ = writeln!(out, "# {} ({described})", output.name);
578 - }
579 - (false, Some(id)) => {
580 - let _ = writeln!(out, "# {} ({described}) [{id}]", output.name);
581 - }
582 - }
583 - for directive in output.persisted() {
584 - out.push_str(&directive.line());
585 - out.push('\n');
586 - }
587 - }
588 - out
589 - }
590 -
591 - // ---- reconcile: settings follow the monitor, not the port ----
592 -
593 - /// What one monitor's settings should be after a reconcile, and why.
594 - ///
595 - /// Returned rather than applied so the decision is testable without a sway, a
596 - /// home directory or a clock. Everything below this line that touches the world
597 - /// is in [`reconcile`].
598 - #[derive(Debug, Clone, PartialEq)]
599 - pub(crate) struct Move {
600 - pub fingerprint: String,
601 - /// Where the monitor was last seen.
602 - pub from: String,
603 - /// Where it is now.
604 - pub to: String,
605 - pub scale: f64,
606 - pub transform: String,
607 - pub enabled: bool,
608 - }
609 -
610 - impl Move {
611 - /// The instructions that put the remembered settings on the new connector.
612 - pub(crate) fn directives(&self) -> Vec<Directive> {
613 - let mut directives = vec![Directive::at(
614 - self.to.clone(),
615 - "scale",
616 - spell_scale(self.scale),
617 - )];
Lines truncated
@@ -1,0 +1,159 @@
1 + //! The backend seam: a trait, the sway front, and the mock behind it.
2 +
3 + use alloy_tui::Severity;
4 + use anyhow::Result;
5 +
6 + use super::UNKNOWN;
7 + use super::model::{Change, Mode, Output, Rectangle, parse};
8 + use crate::cli::{CommandLog, Invocation};
9 +
10 + /// A source of output state, and the actions on it.
11 + ///
12 + /// Same shape as `net.rs`'s: the backend builds argv and runs nothing, the view
13 + /// executes through the command log. It is what keeps "every action shows its
14 + /// invocation" structural, and what makes the parser testable on a machine with
15 + /// no sway, which is every development machine this has been written on.
16 + pub(crate) trait Backend {
17 + fn name(&self) -> &'static str;
18 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Output>>;
19 +
20 + /// Apply a change now, or `None` for a backend that only reads.
21 + fn apply(&self, _output: &Output, _change: Change) -> Option<Invocation> {
22 + None
23 + }
24 + }
25 +
26 + /// Pick a backend: sway when it answers, the mock otherwise.
27 + ///
28 + /// A real invocation rather than a `which` check, for the reason `net.rs` gives:
29 + /// a `swaymsg` binary with no compositor to talk to is worse than no `swaymsg`,
30 + /// and only running it reveals that. Here it is also the common case —
31 + /// `swaymsg` is installed on every Alloy machine, and this console gets run over
32 + /// ssh and inside containers where `SWAYSOCK` names nothing.
33 + pub(crate) fn detect() -> Box<dyn Backend> {
34 + if Invocation::new("swaymsg")
35 + .args(["-t", "get_version"])
36 + .probe()
37 + {
38 + Box::new(Sway)
39 + } else {
40 + Box::new(Mock)
41 + }
42 + }
43 +
44 + pub(crate) struct Sway;
45 +
46 + impl Sway {
47 + /// The one read this verb makes.
48 + ///
49 + /// No `-r`. On sway 1.11 the raw form is byte-identical to the plain one —
50 + /// both pretty-printed, both 1499 bytes on the capture this parser was
51 + /// written against — so the flag buys nothing and asking for it implies a
52 + /// difference that is not there.
53 + fn outputs() -> Invocation {
54 + Invocation::new("swaymsg").args(["-t", "get_outputs"])
55 + }
56 + }
57 +
58 + impl Backend for Sway {
59 + fn name(&self) -> &'static str {
60 + "sway"
61 + }
62 +
63 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Output>> {
64 + parse(&Self::outputs().run(log)?)
65 + }
66 +
67 + fn apply(&self, output: &Output, change: Change) -> Option<Invocation> {
68 + Some(change.directive(output).invocation())
69 + }
70 + }
71 +
72 + /// Fixed sample state, for machines with no sway.
73 + ///
74 + /// Two outputs, one built-in and one external with a full triple, because the
75 + /// single-panel case is the only one with a real capture behind it and a mock
76 + /// that also had one output would let the multi-output rendering path go
77 + /// unexercised in development. It is a development convenience and not evidence:
78 + /// the parser's multi-output behavior is still unverified against real hardware.
79 + pub(crate) struct Mock;
80 +
81 + impl Backend for Mock {
82 + fn name(&self) -> &'static str {
83 + "mock"
84 + }
85 +
86 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Output>> {
87 + log.record("# no sway; showing mock outputs", Severity::Warn);
88 + Ok(vec![
89 + Output {
90 + name: "eDP-1".into(),
91 + make: "BOE".into(),
92 + model: "NV122WUM-N42".into(),
93 + serial: UNKNOWN.into(),
94 + active: true,
95 + dpms: true,
96 + focused: true,
97 + rect: Rectangle {
98 + x: 0,
99 + y: 0,
100 + width: 1536,
101 + height: 960,
102 + },
103 + scale: 1.25,
104 + transform: "normal".into(),
105 + current_mode: Some(Mode {
106 + width: 1920,
107 + height: 1200,
108 + refresh: 60002,
109 + }),
110 + modes: vec![Mode {
111 + width: 1920,
112 + height: 1200,
113 + refresh: 60002,
114 + }],
115 + },
116 + Output {
117 + name: "DP-3".into(),
118 + make: "Example Co".into(),
119 + model: "PA279CV".into(),
120 + serial: "K8LMQS032990".into(),
121 + active: true,
122 + dpms: true,
123 + focused: false,
124 + rect: Rectangle {
125 + x: 1536,
126 + y: 0,
127 + width: 2560,
128 + height: 1440,
129 + },
130 + scale: 1.0,
131 + transform: "normal".into(),
132 + current_mode: Some(Mode {
133 + width: 2560,
134 + height: 1440,
135 + refresh: 59951,
136 + }),
137 + modes: vec![
138 + Mode {
139 + width: 2560,
140 + height: 1440,
141 + refresh: 59951,
142 + },
143 + Mode {
144 + width: 1920,
145 + height: 1080,
146 + refresh: 60000,
147 + },
148 + ],
149 + },
150 + ])
151 + }
152 +
153 + // No `apply`. The mock exists so the console can be developed without sway,
154 + // and inventing a command for it would put a line in the log pane that is
155 + // not a thing anyone could run.
156 + }
157 +
158 + #[cfg(test)]
159 + mod tests;
@@ -1,0 +1,41 @@
1 + //! Tests for [`super`].
2 +
3 + use super::super::config::config_file;
4 + use super::super::model::spell_scale;
5 + use super::*;
6 +
7 + /// Read this machine's real outputs.
8 + ///
9 + /// Ignored by default: needs a running sway, which no development box here
10 + /// has. Run it on an Alloy install when touching the parser — the `FW12`
11 + /// fixture was captured from exactly this command, and this is the check
12 + /// that a later sway has not changed the shape underneath it.
13 + ///
14 + /// Prints the identifier and the stanza it would write, which is also the
15 + /// cheapest way to see the multi-output case the fixture cannot cover: run
16 + /// it with a second display attached.
17 + #[test]
18 + #[ignore = "requires a running sway (swaymsg)"]
19 + fn reads_this_machines_real_outputs() {
20 + let mut log = CommandLog::new();
21 + let outputs = Sway
22 + .list(&mut log)
23 + .expect("swaymsg should answer inside a sway session");
24 + assert!(
25 + !outputs.is_empty(),
26 + "a running sway has at least one output"
27 + );
28 + for output in &outputs {
29 + assert!(!output.name.is_empty(), "every output is identifiable");
30 + println!(
31 + "{:<10} {:<40} {}",
32 + output.name,
33 + output.identifier(),
34 + output.current_mode.map_or_else(
35 + || "no mode".to_string(),
36 + |mode| format!("{} scale {}", mode.spelled(), spell_scale(output.scale)),
37 + ),
38 + );
39 + }
40 + print!("{}", config_file(&outputs));
41 + }
@@ -1,0 +1,77 @@
1 + //! The generated sway snippet, and where it is written.
2 +
3 + use std::fmt::Write as _;
4 + use std::path::PathBuf;
5 +
6 + use super::FILE;
7 + use super::model::Output;
8 + use crate::cli::Effect;
9 +
10 + /// Regenerate the whole file from the outputs sway is reporting.
11 + ///
12 + /// Whole rather than patched, because sway merges every stanza that matches an
13 + /// output: a leftover `scale 1.25` from a previous write keeps applying even
14 + /// after a newer stanza sets something else. Regenerating is also what makes the
15 + /// file honest — it describes the outputs as they are now, which is what the
16 + /// user just looked at.
17 + ///
18 + /// A hand edit is lost, and the header says so rather than leaving somebody to
19 + /// find out. This is the one file in the skeleton the console claims outright;
20 + /// `theme_apply.rs` keeps user edits because those files are seeds, and a seed
21 + /// is not the same thing as a generated artifact.
22 + pub(crate) fn config_file(outputs: &[Output]) -> String {
23 + let mut out = String::from(
24 + "# Generated by Alloy: seeded at install from the screens this machine\n\
25 + # reports, and rewritten whole by `alloy display`, so\n\
26 + # hand edits here are lost. Put your own output config in another file\n\
27 + # in this directory: sway merges every stanza that matches an output,\n\
28 + # and the shipped config includes all of ~/.config/sway/config.d/*.\n",
29 + );
30 + for output in outputs {
31 + out.push('\n');
32 + // The comment is where the monitor is named, now that the directive
33 + // below it is a connector and says nothing about which screen that is.
34 + // The fingerprint rides along so the file and the monitor table can be
35 + // read against each other without running anything.
36 + let described = output.description();
37 + match (described.is_empty(), output.fingerprint()) {
38 + (true, None) => {
39 + let _ = writeln!(out, "# {}", output.name);
40 + }
41 + (true, Some(id)) => {
42 + let _ = writeln!(out, "# {} [{id}]", output.name);
43 + }
44 + (false, None) => {
45 + let _ = writeln!(out, "# {} ({described})", output.name);
46 + }
47 + (false, Some(id)) => {
48 + let _ = writeln!(out, "# {} ({described}) [{id}]", output.name);
49 + }
50 + }
51 + for directive in output.persisted() {
52 + out.push_str(&directive.line());
53 + out.push('\n');
54 + }
55 + }
56 + out
57 + }
58 +
59 + /// Where the file lives, or `None` on a machine with no `$HOME`.
60 + pub(super) fn config_path() -> Option<PathBuf> {
61 + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(FILE))
62 + }
63 +
64 + /// The write that makes the current outputs survive a reboot.
65 + pub(super) fn write_effect(outputs: &[Output]) -> Option<Effect> {
66 + Some(Effect::Write {
67 + path: config_path()?,
68 + contents: config_file(outputs),
69 + // Not 0o600: sway reads it as the same user, and a config file that is
70 + // stricter than the rest of ~/.config invites somebody to wonder what is
71 + // secret about it.
72 + mode: 0o644,
73 + })
74 + }
75 +
76 + #[cfg(test)]
77 + mod tests;
@@ -1,0 +1,32 @@
1 + //! Tests for [`super`].
2 +
3 + use super::super::fixtures::{external, fw12};
4 + use super::*;
5 +
6 + // The file is regenerated whole because sway *merges* every stanza that
7 + // matches an output: a leftover scale from a previous write would keep
8 + // applying underneath a newer one.
9 + #[test]
10 + fn the_file_holds_one_stanza_per_output() {
11 + let outputs = vec![
12 + fw12(),
13 + external("DP-3", "Example Co", "PA279CV", "K8LMQS032990"),
14 + ];
15 + let file = config_file(&outputs);
16 + // Directives only. The header talks about outputs too, and counting the
17 + // word rather than the instruction is how a test like this passes on a
18 + // file that has lost a stanza and gained a sentence.
19 + let directives = file.lines().filter(|line| line.starts_with("output "));
20 + assert_eq!(directives.count(), 2, "{file}");
21 + assert!(file.contains("output eDP-1 scale 1.25"), "{file}");
22 + assert!(file.contains("output DP-3 scale 1"), "{file}");
23 + assert!(
24 + !file.contains("Example Co PA279CV K8LMQS032990 scale"),
25 + "no EDID text belongs in a directive; the comment above it is where \
26 + a person reads which monitor this is: {file}",
27 + );
28 + assert!(
29 + file.contains("hand edits here are lost"),
30 + "the header says who owns the file: {file}",
31 + );
32 + }
@@ -1,0 +1,155 @@
1 + //! Test fixtures shared by more than one child of [`super`].
2 + //!
3 + //! The verbatim `swaymsg -t get_outputs` capture, an external-monitor builder,
4 + //! and two stand-in backends. They live here rather than in one sibling's test
5 + //! module because a `const` or a helper in one sibling cannot be named from
6 + //! another.
7 +
8 + use anyhow::Result;
9 +
10 + use super::backend::Backend;
11 + use super::model::{Mode, Output, Rectangle, parse};
12 + use crate::cli::CommandLog;
13 +
14 + // Captured verbatim from `swaymsg -t get_outputs` on the FW12 Alloy install,
15 + // 2026-07-29, sway 1.11, with no external display attached. Complete and
16 + // untrimmed: this is the whole 1499-byte payload, including the fields the
17 + // parser ignores, because the fields it ignores are where the next surprise
18 + // lives. `serial` really is the string "Unknown", `refresh` really is
19 + // millihertz, and `rect` really disagrees with `current_mode`.
20 + //
21 + // Still missing, and the reason the multi-output path has tests but no
22 + // evidence: nobody has attached a second display to an Alloy machine.
23 + pub(super) const FW12: &str = r#"
24 + [
25 + {
26 + "id": 3,
27 + "type": "output",
28 + "orientation": "none",
29 + "percent": 1.0,
30 + "urgent": false,
31 + "marks": [],
32 + "layout": "output",
33 + "border": "none",
34 + "current_border_width": 0,
35 + "rect": {
36 + "x": 0,
37 + "y": 0,
38 + "width": 1536,
39 + "height": 960
40 + },
41 + "deco_rect": {
42 + "x": 0,
43 + "y": 0,
44 + "width": 0,
45 + "height": 0
46 + },
47 + "window_rect": {
48 + "x": 0,
49 + "y": 0,
50 + "width": 0,
51 + "height": 0
52 + },
53 + "geometry": {
54 + "x": 0,
55 + "y": 0,
56 + "width": 0,
57 + "height": 0
58 + },
59 + "name": "eDP-1",
60 + "window": null,
61 + "nodes": [],
62 + "floating_nodes": [],
63 + "focus": [
64 + 4
65 + ],
66 + "fullscreen_mode": 0,
67 + "sticky": false,
68 + "floating": null,
69 + "scratchpad_state": null,
70 + "primary": false,
71 + "make": "BOE",
72 + "model": "NV122WUM-N42",
73 + "serial": "Unknown",
74 + "modes": [
75 + {
76 + "width": 1920,
77 + "height": 1200,
78 + "refresh": 60002,
79 + "picture_aspect_ratio": "none"
80 + }
81 + ],
82 + "non_desktop": false,
83 + "active": true,
84 + "dpms": true,
85 + "power": true,
86 + "scale": 1.25,
87 + "scale_filter": "linear",
88 + "transform": "normal",
89 + "adaptive_sync_status": "disabled",
90 + "current_workspace": "1",
91 + "current_mode": {
92 + "width": 1920,
93 + "height": 1200,
94 + "refresh": 60002,
95 + "picture_aspect_ratio": "none"
96 + },
97 + "max_render_time": 0,
98 + "allow_tearing": false,
99 + "focused": true,
100 + "subpixel_hinting": "unknown"
101 + }
102 + ]
103 + "#;
104 +
105 + pub(super) fn fw12() -> Output {
106 + parse(FW12).expect("the real capture parses").remove(0)
107 + }
108 +
109 + pub(super) fn external(name: &str, make: &str, model: &str, serial: &str) -> Output {
110 + Output {
111 + name: name.into(),
112 + make: make.into(),
113 + model: model.into(),
114 + serial: serial.into(),
115 + active: true,
116 + dpms: true,
117 + focused: false,
118 + rect: Rectangle {
119 + x: 0,
120 + y: 0,
121 + width: 2560,
122 + height: 1440,
123 + },
124 + scale: 1.0,
125 + transform: "normal".into(),
126 + current_mode: Some(Mode {
127 + width: 2560,
128 + height: 1440,
129 + refresh: 59951,
130 + }),
131 + modes: Vec::new(),
132 + }
133 + }
134 +
135 + pub(super) struct EmptyBackend;
136 +
137 + pub(super) struct FailingBackend;
138 +
139 + impl Backend for FailingBackend {
140 + fn name(&self) -> &'static str {
141 + "failing"
142 + }
143 + fn list(&self, _log: &mut CommandLog) -> Result<Vec<Output>> {
144 + anyhow::bail!("swaymsg went away")
145 + }
146 + }
147 +
148 + impl Backend for EmptyBackend {
149 + fn name(&self) -> &'static str {
150 + "empty"
151 + }
152 + fn list(&self, _log: &mut CommandLog) -> Result<Vec<Output>> {
153 + Ok(Vec::new())
154 + }
155 + }
@@ -1,0 +1,413 @@
1 + //! The output vocabulary: what an output is, what changing one means, and the
2 + //! directives that spell a change out for sway.
3 + //!
4 + //! One file rather than four because the layering only stays one-way this way:
5 + //! `Output::persisted` returns directives and `config_file` reads outputs back,
6 + //! so a boundary between them would be a two-way edge. Everything else in this
7 + //! verb depends on this module, and this module depends on nothing but std,
8 + //! serde, alloy_tui and `Invocation`.
9 +
10 + use anyhow::Result;
11 + use serde::Deserialize;
12 +
13 + use super::{SCALES, UNKNOWN};
14 + use crate::cli::Invocation;
15 + use crate::monitors;
16 +
17 + /// One mode an output advertises.
18 + ///
19 + /// `refresh` is millihertz. sway reports `60002` for a 60 Hz panel, so dividing
20 + /// by 1000 in the wrong place is how a mode line comes out as `@60Hz` for a
21 + /// panel that does not have one.
22 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
23 + pub(crate) struct Mode {
24 + pub width: u32,
25 + pub height: u32,
26 + /// Millihertz, as sway reports it.
27 + #[serde(default)]
28 + pub refresh: u32,
29 + }
30 +
31 + impl Mode {
32 + /// The mode as sway's `mode` directive spells it.
33 + ///
34 + /// Three decimals because that is what millihertz carries and because
35 + /// `60.002` is a real refresh rate: rounding it to `60` names a mode the
36 + /// panel does not advertise, and sway matches modes by value.
37 + pub(super) fn spelled(self) -> String {
38 + format!(
39 + "{}x{}@{:.3}Hz",
40 + self.width,
41 + self.height,
42 + f64::from(self.refresh) / 1000.0
43 + )
44 + }
45 + }
46 +
47 + /// A rectangle in sway's layout, in logical pixels.
48 + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
49 + pub(crate) struct Rectangle {
50 + #[serde(default)]
51 + pub x: i32,
52 + #[serde(default)]
53 + pub y: i32,
54 + #[serde(default)]
55 + pub width: u32,
56 + #[serde(default)]
57 + pub height: u32,
58 + }
59 +
60 + /// One output, as `swaymsg -t get_outputs` describes it.
61 + ///
62 + /// Every field here is one the view or a directive reads. sway sends a good deal
63 + /// more (`percent`, `deco_rect`, `nodes`, the null `floating` and
64 + /// `scratchpad_state`), and serde ignores what is not named — which is the
65 + /// reason this is a derive rather than a hand-rolled reader. sway escapes
66 + /// forward slashes in its JSON strings, and a string reader written for this
67 + /// payload is a string reader that has to know that.
68 + #[derive(Debug, Clone, Deserialize)]
69 + pub(crate) struct Output {
70 + /// The connector name: `eDP-1`, `DP-3`, `HDMI-A-1`.
71 + pub name: String,
72 + #[serde(default)]
73 + pub make: String,
74 + #[serde(default)]
75 + pub model: String,
76 + #[serde(default)]
77 + pub serial: String,
78 + /// Whether sway is driving it. An output that is present but disabled is
79 + /// listed with `active: false`, and it is still ours to re-enable.
80 + #[serde(default)]
81 + pub active: bool,
82 + /// Whether the panel is awake. Distinct from `active`: a `dpms off` output
83 + /// is still in the layout, just dark.
84 + #[serde(default = "yes")]
85 + pub dpms: bool,
86 + #[serde(default)]
87 + pub focused: bool,
88 + /// Logical size and position, which at any scale other than 1 disagrees with
89 + /// `current_mode` by design.
90 + #[serde(default)]
91 + pub rect: Rectangle,
92 + #[serde(default = "one")]
93 + pub scale: f64,
94 + #[serde(default)]
95 + pub transform: String,
96 + #[serde(default)]
97 + pub current_mode: Option<Mode>,
98 + #[serde(default)]
99 + pub modes: Vec<Mode>,
100 + }
101 +
102 + /// `dpms` defaults to true when absent: an output sway lists and does not
103 + /// describe as asleep is awake. Guessing the other way would draw every row as
104 + /// blanked on a sway version that stopped sending the field.
105 + fn yes() -> bool {
106 + true
107 + }
108 +
109 + fn one() -> f64 {
110 + 1.0
111 + }
112 +
113 + impl Output {
114 + /// Whether this is the machine's built-in panel.
115 + ///
116 + /// By connector prefix, which is the only signal in the payload: eDP is the
117 + /// embedded DisplayPort every laptop panel arrives on, LVDS is what older
118 + /// ones used, and DSI is the small-board and tablet case. sway derives these
119 + /// names from DRM, so they are the kernel's vocabulary rather than sway's.
120 + pub(crate) fn built_in(&self) -> bool {
121 + let name = self.name.to_ascii_uppercase();
122 + ["EDP", "LVDS", "DSI"]
123 + .iter()
124 + .any(|prefix| name.starts_with(prefix))
125 + }
126 +
127 + /// The name a config stanza matches this output by: the connector, always.
128 + ///
129 + /// Never the `make model serial` triple, even though a triple survives being
130 + /// replugged into a different port and a connector name does not:
131 + ///
132 + /// - **The triple is vendor text in a file sway parses.** A quote or a
133 + /// newline in an EDID model string is a malformed line in the config
134 + /// loaded at login. Emitting the connector always removes the hazard
135 + /// instead of guarding it.
136 + /// - **The triple is not unique.** Two identical monitors with no serial
137 + /// produce the same one, sway merges the stanzas, and the last wins for
138 + /// both. Two connectors are always two stanzas.
139 + ///
140 + /// What carries the portability instead is [`crate::monitors`]: a table
141 + /// keyed by a hash of the same three fields, and a reconcile that moves the
142 + /// settings to the port the monitor is on now. The cost is that the config
143 + /// is only correct if something runs after a replug, which that module
144 + /// records.
145 + pub(crate) fn identifier(&self) -> String {
146 + self.name.clone()
147 + }
148 +
149 + /// This output's content-addressed identity, or `None` when there is no
150 + /// identity to address.
151 + ///
152 + /// Make or model has to say something. Serial alone is not enough: it is the
153 + /// field most often `Unknown`, and a hash of nothing but a serial is an
154 + /// identity built from the two fields that were missing. A panel with
155 + /// neither is remembered by nothing, which is correct — there is no fact
156 + /// about it that would survive a replug.
157 + ///
158 + /// The built-in panel is excluded for a different reason: it cannot move, so
159 + /// an entry for it would be a row that can never be wrong and never be
160 + /// useful.
161 + pub(crate) fn fingerprint(&self) -> Option<String> {
162 + if self.built_in() || !self.identifiable() {
163 + return None;
164 + }
165 + Some(monitors::fingerprint(&self.make, &self.model, &self.serial))
166 + }
167 +
168 + /// Whether the triple says anything specific about this output.
169 + ///
170 + /// Make or model is enough. Serial alone is not: it is the field most often
171 + /// `Unknown`, and a triple carrying only a serial is a triple that has
172 + /// already lost the two fields that identify the panel.
173 + pub(super) fn identifiable(&self) -> bool {
174 + [&self.make, &self.model]
175 + .into_iter()
176 + .any(|field| !field.is_empty() && field != UNKNOWN)
177 + }
178 +
179 + /// What the row shows for a make and model, when it has one.
180 + pub(super) fn description(&self) -> String {
181 + let named: Vec<&str> = [self.make.as_str(), self.model.as_str()]
182 + .into_iter()
183 + .filter(|field| !field.is_empty() && *field != UNKNOWN)
184 + .collect();
185 + named.join(" ")
186 + }
187 +
188 + /// Whether this output is contributing a usable screen right now.
189 + ///
190 + /// Both halves, because either one alone leaves the user looking at nothing:
191 + /// an inactive output is out of the layout, and a `dpms off` output is in it
192 + /// and dark. [`refuse`] counts these.
193 + pub(super) fn usable(&self) -> bool {
194 + self.active && self.dpms
195 + }
196 +
197 + /// The next rung of the scale ladder above the current scale.
198 + ///
199 + /// Wraps, and a scale that is off the ladder lands on the first rung above
200 + /// it rather than snapping to the nearest, so pressing the key always
201 + /// changes something. `1.0` is the wrap target rather than a hard floor,
202 + /// which is what keeps the key from being a dead end at the top.
203 + pub(super) fn next_scale(&self) -> f64 {
204 + SCALES
205 + .iter()
206 + .copied()
207 + // Not `>` on floats parsed from JSON: sway sends 1.25 and the ladder
208 + // holds 1.25, and an exact-equality assumption between the two is a
209 + // key that stops working on a value that reads identical.
210 + .find(|rung| *rung > self.scale + f64::EPSILON)
211 + .unwrap_or(SCALES[0])
212 + }
213 +
214 + /// The properties the console persists for this output, in the order the
215 + /// stanza spells them.
216 + ///
217 + /// Deliberately short. Everything here is something the console can set and
218 + /// the user can see, and everything omitted is either sway's default or a
219 + /// decision this verb does not make yet: no `position` (that wants a layout
220 + /// UI), no `mode` (the only testable panel advertises one), no `bg` (swww
221 + /// owns the wallpaper).
222 + pub(super) fn persisted(&self) -> Vec<Directive> {
223 + let mut directives = vec![Directive::new(self, "scale", spell_scale(self.scale))];
224 + // `normal` is sway's default, so writing it says nothing and reads as
225 + // though the console had an opinion about rotation.
226 + if !self.transform.is_empty() && self.transform != "normal" {
227 + directives.push(Directive::new(self, "transform", &self.transform));
228 + }
229 + // An output the user turned off stays off across a reboot, which is the
230 + // whole point of persisting it. `refuse` is what keeps this from being
231 + // the last output standing.
232 + if !self.active {
233 + directives.push(Directive::new(self, "enable", "false"));
234 + }
235 + directives
236 + }
237 + }
238 +
239 + /// Spell a scale the way sway's parser reads it back.
240 + ///
241 + /// Trailing zeros trimmed, because `scale 1.250000` is the same instruction
242 + /// spelled to look machine-generated, and the whole point of the shared string
243 + /// is that a person can read the line and paste it.
244 + pub(super) fn spell_scale(scale: f64) -> String {
245 + let spelled = format!("{scale:.3}");
246 + let trimmed = spelled.trim_end_matches('0').trim_end_matches('.');
247 + if trimmed.is_empty() {
248 + "1".to_string()
249 + } else {
250 + trimmed.to_string()
251 + }
252 + }
253 +
254 + /// One `output <identifier> <property> <value>` instruction.
255 + ///
256 + /// The words after the command name, which is the whole of what sway needs and
257 + /// exactly what a config file holds. Held as its three parts rather than a
258 + /// finished string so the identifier can be quoted once, in [`words`], for both
259 + /// consumers.
260 + ///
261 + /// [`words`]: Directive::words
262 + #[derive(Debug, Clone, PartialEq, Eq)]
263 + pub(crate) struct Directive {
264 + identifier: String,
265 + property: &'static str,
266 + value: String,
267 + }
268 +
269 + impl Directive {
270 + fn new(output: &Output, property: &'static str, value: impl Into<String>) -> Self {
271 + Self::at(output.identifier(), property, value)
272 + }
273 +
274 + /// A directive aimed at a connector by name, for the reconcile: it moves a
275 + /// remembered setting onto whatever port the monitor turned up on, and that
276 + /// port is a string rather than an `Output` field it can borrow.
277 + pub(super) fn at(identifier: String, property: &'static str, value: impl Into<String>) -> Self {
278 + debug_assert!(
279 + is_one_word(&identifier),
280 + "a stanza identifier must be a connector name and nothing else: {identifier}"
281 + );
282 + Self {
283 + identifier,
284 + property,
285 + value: value.into(),
286 + }
287 + }
288 +
289 + /// The instruction, as sway's parser reads it.
290 + ///
291 + /// No quoting. The identifier is a connector name, which is one word by
292 + /// construction; see [`is_one_word`].
293 + pub(crate) fn words(&self) -> String {
294 + format!(
295 + "output {} {} {}",
296 + self.identifier, self.property, self.value
297 + )
298 + }
299 +
300 + /// Apply it now.
301 + ///
302 + /// One argv element per word, which is what makes the displayed line the one
303 + /// a person would have typed: `swaymsg output eDP-1 scale 1.25`. Passing the
304 + /// whole instruction as a single argument reaches sway identically, since
305 + /// swaymsg joins its arguments with spaces anyway, but the log pane shell-
306 + /// quotes an argument containing spaces and the line comes out as
307 + /// `swaymsg 'output eDP-1 scale 1.25'`.
308 + ///
309 + pub(crate) fn invocation(&self) -> Invocation {
310 + Invocation::new("swaymsg").args(["output", &self.identifier, self.property, &self.value])
311 + }
312 +
313 + /// The line that puts it back at the next login. The same words.
314 + pub(crate) fn line(&self) -> String {
315 + self.words()
316 + }
317 + }
318 +
319 + /// Whether an identifier is one sway's parser reads as a single word.
320 + ///
321 + /// A connector name always is: DRM's vocabulary is `eDP-1`, `DP-3`,
322 + /// `HDMI-A-1`, and nothing in it needs quoting. This is an assertion that it
323 + /// stayed that way rather than a quoting function.
324 + ///
325 + /// Quoting a `make model serial` triple is the thing not to reintroduce: a `"`
326 + /// inside a vendor's model string closes the quote early and the rest of the
327 + /// line becomes stray tokens in the config sway loads at login. Writing the
328 + /// connector name always is what removes the question. This assertion is the
329 + /// tripwire at the one place that would notice a future change quietly putting
330 + /// vendor text back into a stanza.
331 + pub(super) fn is_one_word(identifier: &str) -> bool {
332 + !identifier.is_empty()
333 + && !identifier
334 + .chars()
335 + .any(|c| c.is_whitespace() || c == '"' || c == '\\' || c.is_control())
336 + }
337 +
338 + /// Why a change must not be made, or `None` when it may be.
339 + ///
340 + /// One rule, stated once, checked before the runtime apply and before the write:
341 + /// nothing may take away the last usable screen. `enable false` and `dpms off`
342 + /// are the two direct ways, and a scale that leaves no readable geometry is the
343 + /// indirect one — a session at scale 8 on a 1920x1200 panel is 240x150 logical
344 + /// pixels, which is a dark screen with extra steps.
345 + ///
346 + /// Returns the reason rather than a bool so the view can say which rule it hit.
347 + /// "Nothing happened" is the one outcome a console must never produce.
348 + pub(super) fn refuse(outputs: &[Output], target: &Output, change: &Change) -> Option<String> {
349 + let others_usable = outputs
350 + .iter()
351 + .filter(|other| other.name != target.name)
352 + .any(Output::usable);
353 +
354 + match change {
355 + Change::Enabled(false) => {
356 + (!others_usable).then(|| format!("{} is the only screen left", target.name))
357 + }
358 + Change::Scale(scale) => {
359 + let mode = target
360 + .current_mode
361 + .or_else(|| target.modes.first().copied());
362 + // No mode at all means no geometry to check. Refusing on that would
363 + // block the key on every output sway has not finished describing,
364 + // which is a worse failure than a scale that has to be pressed twice.
365 + let mode = mode?;
366 + let logical = f64::from(mode.width) / scale;
367 + (logical < 640.0).then(|| {
368 + format!(
369 + "scale {} leaves {} at {} logical pixels wide",
370 + spell_scale(*scale),
371 + target.name,
372 + logical.round(),
373 + )
374 + })
375 + }
376 + // Switching an output back on takes nothing away, and it is the key that
377 + // recovers from the state the rules above are guarding against.
378 + Change::Enabled(true) => None,
379 + }
380 + }
381 +
382 + /// A change to one output.
383 + ///
384 + /// Named for the property rather than the key, so the safety rule reads as a
385 + /// rule about outputs instead of a rule about keystrokes.
386 + #[derive(Debug, Clone, Copy, PartialEq)]
387 + pub(crate) enum Change {
388 + Scale(f64),
389 + Enabled(bool),
390 + }
391 +
392 + impl Change {
393 + pub(super) fn directive(self, output: &Output) -> Directive {
394 + match self {
395 + Change::Scale(scale) => Directive::new(output, "scale", spell_scale(scale)),
396 + Change::Enabled(on) => {
397 + Directive::new(output, "enable", if on { "true" } else { "false" })
398 + }
399 + }
400 + }
401 + }
402 +
403 + /// Parse `swaymsg -t get_outputs`.
404 + ///
405 + /// An error rather than an empty list when the JSON does not fit, because a
406 + /// running sway always has at least one output and "no outputs" is a sentence
407 + /// this verb should never be able to say by accident.
408 + pub(crate) fn parse(raw: &str) -> Result<Vec<Output>> {
409 + Ok(serde_json::from_str(raw)?)
410 + }
411 +
412 + #[cfg(test)]
413 + mod tests;
@@ -1,0 +1,300 @@
1 + //! Tests for [`super`].
2 +
3 + use super::super::config::config_file;
4 + use super::super::fixtures::{FW12, external, fw12};
5 + use super::*;
6 +
7 + #[test]
8 + fn parses_the_real_capture() {
9 + let outputs = parse(FW12).unwrap();
10 + assert_eq!(outputs.len(), 1);
11 + let panel = &outputs[0];
12 + assert_eq!(panel.name, "eDP-1");
13 + assert_eq!(panel.make, "BOE");
14 + assert_eq!(panel.model, "NV122WUM-N42");
15 + assert!(panel.active && panel.dpms && panel.focused);
16 + assert_eq!(panel.transform, "normal");
17 + }
18 +
19 + // sway substitutes the literal string "Unknown" for a field the panel does
20 + // not report. A parser expecting null or an absent key mis-handles this
21 + // panel, and the identifier rule below depends on noticing it.
22 + #[test]
23 + fn an_unknown_serial_is_a_string_not_a_null() {
24 + assert_eq!(fw12().serial, "Unknown");
25 + }
26 +
27 + // Millihertz. 60002, not 60 and not 60.0.
28 + #[test]
29 + fn refresh_is_millihertz() {
30 + let mode = fw12().current_mode.expect("the panel has a current mode");
31 + assert_eq!(mode.refresh, 60002);
32 + assert_eq!(mode.spelled(), "1920x1200@60.002Hz");
33 + }
34 +
35 + // `rect` is the logical size and `current_mode` the physical one, and at
36 + // scale 1.25 they disagree by design. A view that showed one of them would
37 + // make the scale look inert.
38 + #[test]
39 + fn the_logical_and_physical_sizes_both_survive() {
40 + let panel = fw12();
41 + assert_eq!((panel.rect.width, panel.rect.height), (1536, 960));
42 + let mode = panel.current_mode.unwrap();
43 + assert_eq!((mode.width, mode.height), (1920, 1200));
44 + assert!((panel.scale - 1.25).abs() < f64::EPSILON);
45 + }
46 +
47 + // The panel advertises exactly one mode, which is why there is no mode
48 + // picker. Pinned so that the day a capture with more than one arrives, the
49 + // reason for the omission is visible in a diff.
50 + #[test]
51 + fn the_panel_advertises_exactly_one_mode() {
52 + assert_eq!(fw12().modes.len(), 1);
53 + }
54 +
55 + #[test]
56 + fn malformed_json_is_an_error() {
57 + assert!(parse("not json").is_err());
58 + assert!(parse("{}").is_err(), "an object is not a list of outputs");
59 + }
60 +
61 + // sway sends the fields this parser does not read, and it will send more
62 + // next release. Ignoring them is the point of the derive.
63 + #[test]
64 + fn unknown_fields_are_ignored() {
65 + let raw = r#"[{"name":"HDMI-A-1","something_new":{"nested":true}}]"#;
66 + assert_eq!(parse(raw).unwrap()[0].name, "HDMI-A-1");
67 + }
68 +
69 + // A field sway stops sending must not read as "asleep" or "scale 0".
70 + #[test]
71 + fn absent_fields_take_the_safe_default() {
72 + let output = parse(r#"[{"name":"DP-1"}]"#).unwrap().remove(0);
73 + assert!(output.dpms, "an output sway does not describe as asleep");
74 + assert!((output.scale - 1.0).abs() < f64::EPSILON);
75 + assert!(output.modes.is_empty());
76 + assert_eq!(output.current_mode, None);
77 + }
78 +
79 + // The identifier rule since 2026-08-06: the connector, always, for every
80 + // output. The triple it replaced was vendor text in a file sway parses and
81 + // was not unique across identical serial-less monitors; `monitors.rs` holds
82 + // the reasoning and the replacement.
83 + #[test]
84 + fn every_output_is_matched_by_its_connector() {
85 + let monitor = external("DP-3", "Example Co", "PA279CV", "K8LMQS032990");
86 + assert_eq!(monitor.identifier(), "DP-3");
87 + assert_eq!(fw12().identifier(), "eDP-1");
88 + }
89 +
90 + #[test]
91 + fn every_laptop_panel_connector_reads_as_built_in() {
92 + for name in ["eDP-1", "eDP-2", "LVDS-1", "DSI-1"] {
93 + let mut panel = external(name, "BOE", "NV122WUM-N42", UNKNOWN);
94 + panel.name = name.into();
95 + assert!(panel.built_in(), "{name}");
96 + assert_eq!(panel.identifier(), name);
97 + }
98 + assert!(!external("DP-3", "Example Co", "PA279CV", "S1").built_in());
99 + }
100 +
101 + /// The injection hazard, closed structurally rather than escaped: none of
102 + /// these can reach a stanza by any route, because no EDID text is written
103 + /// at all.
104 + #[test]
105 + fn no_edid_text_reaches_a_stanza_however_hostile() {
106 + for make in [
107 + "Ex\"Co",
108 + "Ex\\Co",
109 + "Ex\noutput * scale 3",
110 + "Ex\rCo",
111 + "Ex\tCo",
112 + ] {
113 + let output = external("DP-1", make, "PA279CV", "S1");
114 + assert_eq!(output.identifier(), "DP-1", "{make:?}");
115 + let line = Directive::new(&output, "scale", "2").line();
116 + assert_eq!(line, "output DP-1 scale 2", "{make:?}");
117 + }
118 + }
119 +
120 + /// The portability a triple would buy is not lost, it moved: two monitors
121 + /// that differ only in punctuation are still two identities, and the
122 + /// identity is a hash rather than something a config file has to hold.
123 + #[test]
124 + fn punctuation_still_distinguishes_two_monitors() {
125 + let one = external("DP-1", "Example Co.", "PA279CV", "S1");
126 + let two = external("DP-1", "Example Co", "PA279CV", "S1");
127 + assert_ne!(one.fingerprint(), two.fingerprint());
128 + assert!(one.fingerprint().is_some());
129 + }
130 +
131 + /// An output with nothing to identify it is remembered by nothing. There is
132 + /// no fact about it that would survive a replug, so a table row would be a
133 + /// row that cannot be right.
134 + #[test]
135 + fn an_anonymous_output_has_no_fingerprint() {
136 + assert_eq!(
137 + external("HDMI-A-1", UNKNOWN, UNKNOWN, UNKNOWN).fingerprint(),
138 + None
139 + );
140 + assert_eq!(external("HDMI-A-1", "", "", "").fingerprint(), None);
141 + // Serial alone is not identity: it is the field most often Unknown, and
142 + // what is left is the two fields that were missing.
143 + assert_eq!(
144 + external("HDMI-A-1", UNKNOWN, UNKNOWN, "S1").fingerprint(),
145 + None
146 + );
147 + }
148 +
149 + /// The built-in panel cannot move, so it is not in the table.
150 + #[test]
151 + fn the_built_in_panel_has_no_fingerprint() {
152 + assert_eq!(fw12().fingerprint(), None);
153 + }
154 +
155 + /// The tripwire that replaced the quoting function. Connector names pass;
156 + /// anything carrying the old hazards does not, which is what would fire if
157 + /// vendor text ever found its way back into a stanza.
158 + #[test]
159 + fn a_stanza_identifier_is_one_word() {
160 + assert!(is_one_word("eDP-1"));
161 + assert!(is_one_word("HDMI-A-1"));
162 + assert!(!is_one_word("BOE NV122WUM-N42 Unknown"));
163 + assert!(!is_one_word("Ex\"Co"));
164 + assert!(!is_one_word("Ex\\Co"));
165 + assert!(!is_one_word("Ex\nCo"));
166 + assert!(!is_one_word(""));
167 + }
168 +
169 + // The load-bearing property of the whole design: the words that apply the
170 + // change and the words that persist it are the same words.
171 + #[test]
172 + fn the_runtime_command_and_the_config_line_are_the_same_words() {
173 + let directive = Directive::new(&fw12(), "scale", "1.25");
174 + assert_eq!(directive.line(), "output eDP-1 scale 1.25");
175 + assert_eq!(
176 + directive.invocation().display(),
177 + "swaymsg output eDP-1 scale 1.25",
178 + );
179 + assert_eq!(
180 + directive.invocation().display(),
181 + format!("swaymsg {}", directive.line()),
182 + );
183 + }
184 +
185 + // swaymsg joins its argv with spaces and hands the result to the same
186 + // parser that reads a config file, and its `join_args` adds no quoting of
187 + // its own. That used to mean a three-word triple had to carry quotes in the
188 + // string, in both consumers; with connector names there is nothing to
189 + // quote, and the property to hold is that neither consumer adds any.
190 + #[test]
191 + fn neither_consumer_quotes_a_connector_name() {
192 + let monitor = external("DP-3", "Example Co", "PA279CV", "K8LMQS032990");
193 + let directive = Directive::new(&monitor, "scale", "2");
194 + assert_eq!(directive.line(), "output DP-3 scale 2");
195 + assert_eq!(
196 + directive.invocation().display(),
197 + "swaymsg output DP-3 scale 2",
198 + "the log pane's single quotes appear only around an argument with \
199 + whitespace in it, and there is none left",
200 + );
201 + }
202 +
203 + // `scale 1.250000` is the same instruction spelled to look machine-written,
204 + // and the shared string is only worth having if a person can paste it.
205 + #[test]
206 + fn scales_are_spelled_the_way_a_person_would_type_them() {
207 + assert_eq!(spell_scale(1.0), "1");
208 + assert_eq!(spell_scale(1.25), "1.25");
209 + assert_eq!(spell_scale(1.5), "1.5");
210 + assert_eq!(spell_scale(2.0), "2");
211 + }
212 +
213 + #[test]
214 + fn the_scale_key_always_moves() {
215 + let mut panel = fw12();
216 + assert!((panel.next_scale() - 1.5).abs() < f64::EPSILON);
217 + panel.scale = 2.0;
218 + assert!(
219 + (panel.next_scale() - 1.0).abs() < f64::EPSILON,
220 + "the top rung wraps rather than dead-ending",
221 + );
222 + // A scale set outside the console lands on the next rung above it.
223 + panel.scale = 1.1;
224 + assert!((panel.next_scale() - 1.25).abs() < f64::EPSILON);
225 + }
226 +
227 + // `transform normal` is sway's default, so writing it says nothing and reads
228 + // as though the console had an opinion about rotation.
229 + #[test]
230 + fn only_a_rotation_that_is_not_the_default_is_written() {
231 + let mut panel = fw12();
232 + assert!(!config_file(&[panel.clone()]).contains("transform"));
233 + panel.transform = "90".into();
234 + assert!(config_file(&[panel]).contains("output eDP-1 transform 90"));
235 + }
236 +
237 + // An output the user turned off has to stay off across a reboot, or the key
238 + // did not do what it said.
239 + #[test]
240 + fn a_disabled_output_is_persisted_as_disabled() {
241 + let mut monitor = external("DP-3", "Example Co", "PA279CV", "S1");
242 + monitor.active = false;
243 + let file = config_file(&[fw12(), monitor]);
244 + assert!(file.contains("scale 1"), "{file}");
245 + assert!(file.contains("output DP-3 enable false"), "{file}");
246 + }
247 +
248 + // The one edit that costs the session with no way back, and in a per-user
249 + // config file it survives the reboot that would otherwise recover it.
250 + #[test]
251 + fn the_last_usable_output_cannot_be_switched_off() {
252 + let panel = fw12();
253 + let reason = refuse(
254 + std::slice::from_ref(&panel),
255 + &panel,
256 + &Change::Enabled(false),
257 + )
258 + .expect("refused with a reason");
259 + assert!(reason.contains("eDP-1"), "{reason}");
260 + }
261 +
262 + #[test]
263 + fn switching_one_off_is_allowed_while_another_is_usable() {
264 + let panel = fw12();
265 + let monitor = external("DP-3", "Example Co", "PA279CV", "S1");
266 + let outputs = vec![panel.clone(), monitor];
267 + assert!(refuse(&outputs, &panel, &Change::Enabled(false)).is_none());
268 + }
269 +
270 + // A second output that is present but asleep or disabled is not a screen the
271 + // user can read, so it does not license switching off the one that is.
272 + #[test]
273 + fn an_asleep_second_output_does_not_count_as_a_screen() {
274 + let panel = fw12();
275 + let mut dark = external("DP-3", "Example Co", "PA279CV", "S1");
276 + dark.dpms = false;
277 + let outputs = vec![panel.clone(), dark];
278 + assert!(refuse(&outputs, &panel, &Change::Enabled(false)).is_some());
279 + }
280 +
281 + // The indirect way to lose the session: a scale that leaves no readable
282 + // geometry. 1920 physical pixels at scale 4 is 480 logical, which is a dark
283 + // screen with extra steps.
284 + #[test]
285 + fn a_scale_that_leaves_no_usable_geometry_is_refused() {
286 + let panel = fw12();
287 + assert!(refuse(std::slice::from_ref(&panel), &panel, &Change::Scale(4.0)).is_some());
288 + assert!(refuse(std::slice::from_ref(&panel), &panel, &Change::Scale(2.0)).is_none());
289 + }
290 +
291 + // An output sway has not described has no geometry to check. Refusing on
292 + // that would block the key on every such output, which is worse than a
293 + // scale that has to be pressed twice.
294 + #[test]
295 + fn a_scale_on_an_output_with_no_mode_is_allowed() {
296 + let mut unknown = external("DP-9", "", "", "");
297 + unknown.current_mode = None;
298 + unknown.modes = Vec::new();
299 + assert!(refuse(&[unknown.clone()], &unknown, &Change::Scale(3.0)).is_none());
300 + }
@@ -1,0 +1,219 @@
1 + //! The panel, before there is a sway to ask.
2 + //!
3 + //! Read straight from DRM so the installer can seed a display stanza on a
4 + //! machine with no compositor running.
5 +
6 + use std::path::PathBuf;
7 +
8 + use super::SCALES;
9 + use super::model::{Output, Rectangle};
10 +
11 + /// Where the kernel describes the connectors it found.
12 + ///
13 + /// One directory per connector, named `card<N>-<CONNECTOR>`, and the connector
14 + /// half is the same string sway reports as an output name. That correspondence
15 + /// is what lets the installer write a stanza the compositor will match later:
16 + /// both are reading DRM's vocabulary rather than inventing one.
17 + const DRM: &str = "/sys/class/drm";
18 +
19 + /// The PPI one step of scale is worth.
20 + ///
21 + /// 185 / 1.25, from the one panel anyone has looked at: the FW12's 12.2"
22 + /// 1920x1200 sits at ~185 PPI and takes 1.25, with 1.0 too small at arm's
23 + /// length and 1.5 wasting columns. docs/HARDWARE-FW12.md#display argues that
24 + /// choice; this constant is only that judgment restated as a ratio so a
25 + /// different panel can be answered without a second judgment.
26 + ///
27 + /// **One data point, so this is a rule and not a measurement.** It generalizes
28 + /// in the right direction — a denser panel gets more scale — and every value it
29 + /// produces is one keypress from being overridden, since `alloy display` writes
30 + /// the same file this seeds.
31 + const PPI_PER_SCALE: f64 = 148.0;
32 +
33 + /// The scale a panel of this geometry should come up at.
34 + ///
35 + /// Snapped to [`SCALES`] rather than used raw: the rungs are the values the
36 + /// console can walk, and seeding a scale the `s` key cannot return to would
37 + /// make the first press jump somewhere the user did not ask for. Off the ends
38 + /// of the ladder it clamps, which is what keeps a 1366x768 panel at 1.0 instead
39 + /// of below it.
40 + fn scale_for(pixels_wide: u32, millimetres_wide: u32) -> Option<f64> {
41 + if pixels_wide == 0 || millimetres_wide == 0 {
42 + return None;
43 + }
44 + let ppi = f64::from(pixels_wide) / (f64::from(millimetres_wide) / 25.4);
45 + let want = ppi / PPI_PER_SCALE;
46 + SCALES
47 + .iter()
48 + .copied()
49 + .min_by(|a, b| (a - want).abs().total_cmp(&(b - want).abs()))
50 + }
51 +
52 + /// The first mode a connector advertises, from its sysfs `modes` file.
53 + ///
54 + /// The first line is the preferred mode, which on a laptop panel is its native
55 + /// resolution and the only one it has. No refresh rate here: `modes` carries
56 + /// `1920x1200` and nothing else, which is the whole of what the scale needs.
57 + fn first_mode(modes: &str) -> Option<(u32, u32)> {
58 + let line = modes.lines().map(str::trim).find(|line| !line.is_empty())?;
59 + let (width, height) = line.split_once('x')?;
60 + Some((width.parse().ok()?, height.parse().ok()?))
61 + }
62 +
63 + /// The panel's physical size in millimetres, from its EDID.
64 + ///
65 + /// Two places carry it and they disagree in precision. The basic display
66 + /// parameters at 0x15 and 0x16 are whole centimetres, so a 263mm panel reports
67 + /// 26 and the PPI comes out 1.5% wrong; the first detailed timing descriptor
68 + /// carries millimetres outright, split across a shared byte of high nibbles.
69 + /// The descriptor is preferred and the centimetres are the fallback, which is
70 + /// the order every EDID reader uses.
71 + ///
72 + /// A descriptor whose pixel clock is zero is not a timing at all — that is how
73 + /// EDID marks the monitor-name and range-limit blocks — so its bytes 12 to 14
74 + /// mean something else entirely and reading them as a size gives a panel the
75 + /// dimensions of whatever text is stored there.
76 + fn panel_millimetres(edid: &[u8]) -> Option<(u32, u32)> {
77 + /// Start of the first detailed timing descriptor in the base block.
78 + const DTD: usize = 0x36;
79 +
80 + if edid.len() >= DTD + 15 && edid[DTD] | edid[DTD + 1] != 0 {
81 + let high = edid[DTD + 14];
82 + let width = u32::from(edid[DTD + 12]) | (u32::from(high >> 4) << 8);
83 + let height = u32::from(edid[DTD + 13]) | (u32::from(high & 0x0f) << 8);
84 + if width != 0 && height != 0 {
85 + return Some((width, height));
86 + }
87 + }
88 +
89 + let (width, height) = (
90 + u32::from(*edid.get(0x15)?) * 10,
91 + u32::from(*edid.get(0x16)?) * 10,
92 + );
93 + (width != 0 && height != 0).then_some((width, height))
94 + }
95 +
96 + /// One connector as sysfs describes it: what it is, and whether it is lit.
97 + ///
98 + /// The two halves come from different files and only the first is an `Output`.
99 + /// `enabled` is not a property of the output the console persists — it is how
100 + /// [`detect_outputs`] tells the screen being used from the one that merely has
101 + /// a cable in it.
102 + struct Detected {
103 + output: Output,
104 + enabled: bool,
105 + }
106 +
107 + /// Read one connector directory as an output, if it is worth seeding.
108 + ///
109 + /// `None` for a disconnected connector and for one whose EDID does not say how
110 + /// big it is. Each of those is a machine this cannot answer for, and a guessed
111 + /// scale is worse than none — an install that seeds nothing comes up at 1.0,
112 + /// which is legible everywhere and one keypress from correct.
113 + ///
114 + /// Not filtered to the built-in panel here; see [`detect_outputs`] for what
115 + /// decides which connectors are seeded.
116 + fn output_at(dir: &std::path::Path) -> Option<Detected> {
117 + // `card1-eDP-1` is one card and one connector; sway names the second half.
118 + let name = dir.file_name()?.to_str()?.split_once('-')?.1.to_string();
119 + let output = Output {
120 + name,
121 + make: String::new(),
122 + model: String::new(),
123 + serial: String::new(),
124 + active: true,
125 + dpms: true,
126 + focused: false,
127 + rect: Rectangle::default(),
128 + scale: 1.0,
129 + transform: "normal".into(),
130 + current_mode: None,
131 + modes: Vec::new(),
132 + };
133 + if std::fs::read_to_string(dir.join("status")).ok()?.trim() != "connected" {
134 + return None;
135 + }
136 +
137 + let (pixels_wide, _) = first_mode(&std::fs::read_to_string(dir.join("modes")).ok()?)?;
138 + let (millimetres_wide, _) = panel_millimetres(&std::fs::read(dir.join("edid")).ok()?)?;
139 + Some(Detected {
140 + output: Output {
141 + scale: scale_for(pixels_wide, millimetres_wide)?,
142 + ..output
143 + },
144 + // A connector with no `enabled` file reads as not lit rather than as an
145 + // error: the fallback below is what covers that, and it is the same
146 + // answer this gave before the file was ever read.
147 + enabled: std::fs::read_to_string(dir.join("enabled"))
148 + .is_ok_and(|state| state.trim() == "enabled"),
149 + })
150 + }
151 +
152 + /// The screens of the machine this is running on, in the order they are spelled.
153 + ///
154 + /// For the installer, which runs on the target hardware and before any
155 + /// compositor: `swaymsg` has nobody to ask there, and the answer is in sysfs
156 + /// either way.
157 + ///
158 + /// **The rule is what is lit, with the panel as the fallback.** This used to
159 + /// return the built-in panel and nothing else, and the reason recorded for that
160 + /// was sound: an external monitor plugged in during an install is not the
161 + /// machine's screen, and seeding the scale of hardware about to be unplugged
162 + /// configures a machine that will not exist. What the rule missed is the
163 + /// opposite arrangement, measured on fw13 (`docs/HARDWARE-FW13.md`): lid closed
164 + /// on a desk, `eDP-1` connected but `enabled=disabled`, everything being looked
165 + /// at coming off `DP-3`. The old rule seeded the panel that is off and said
166 + /// nothing about the screen in use, so the first boot came up at 1.0 on the only
167 + /// display anyone could see.
168 + ///
169 + /// So: seed the connectors the kernel reports as lit, which is the transient
170 + /// monitor's answer as much as it is the closed lid's — a monitor nobody is
171 + /// running the install on is connected, not enabled. When nothing reports lit,
172 + /// fall back to the connected built-in panel, which is exactly what this
173 + /// returned before and what a text-console install with no CRTC bound produces.
174 + ///
175 + /// Connectors are read in name order, and the built-in panel is spelled first
176 + /// when it is among them: the file reads as the machine does, and a machine with
177 + /// two panels (none has been seen) is deterministic rather than at the mercy of
178 + /// the directory listing.
179 + pub(crate) fn detect_outputs() -> Vec<Output> {
180 + detect_outputs_in(std::path::Path::new(DRM))
181 + }
182 +
183 + /// [`detect_outputs`] against a given sysfs root, which is the whole of it.
184 + ///
185 + /// Split out for the tests: the rule this implements is about a machine with
186 + /// several connectors in particular states, and the one thing no test can do is
187 + /// arrange that under the real `/sys`.
188 + fn detect_outputs_in(drm: &std::path::Path) -> Vec<Output> {
189 + let Ok(entries) = std::fs::read_dir(drm) else {
190 + return Vec::new();
191 + };
192 + let mut connectors: Vec<PathBuf> = entries
193 + .filter_map(|entry| Some(entry.ok()?.path()))
194 + .collect();
195 + connectors.sort();
196 +
197 + let detected: Vec<Detected> = connectors.iter().filter_map(|dir| output_at(dir)).collect();
198 + let mut outputs: Vec<Output> = if detected.iter().any(|screen| screen.enabled) {
199 + detected
200 + .into_iter()
201 + .filter(|screen| screen.enabled)
202 + .map(|screen| screen.output)
203 + .collect()
204 + } else {
205 + detected
206 + .into_iter()
207 + .map(|screen| screen.output)
208 + .filter(Output::built_in)
209 + .take(1)
210 + .collect()
211 + };
212 + // Stable sort, so the name order the connectors were read in survives among
213 + // the externals.
214 + outputs.sort_by_key(|output| !output.built_in());
215 + outputs
216 + }
217 +
218 + #[cfg(test)]
219 + mod tests;
@@ -1,0 +1,328 @@
1 + //! Tests for [`super`].
2 +
3 + use super::super::config::config_file;
4 + use super::*;
5 +
6 + /// A 128-byte EDID base block carrying a size and nothing else that matters.
7 + ///
8 + /// Hand-built rather than captured: no EDID has been read off an Alloy
9 + /// machine, and the two fields this parser reads are at fixed offsets that a
10 + /// real dump would not make any more true. What a capture would add is
11 + /// evidence that panels fill them the way the specification says, which is
12 + /// the part still unverified.
13 + ///
14 + /// `descriptor` is the first detailed timing block's millimetres, or `None`
15 + /// for a base block whose first descriptor is not a timing at all — the case
16 + /// where the centimetres at 0x15 are all there is.
17 + fn edid(centimetres: (u8, u8), descriptor: Option<(u32, u32)>) -> Vec<u8> {
18 + const DTD: usize = 0x36;
19 + let mut block = vec![0u8; 128];
20 + block[..8].copy_from_slice(&[0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]);
21 + block[0x15] = centimetres.0;
22 + block[0x16] = centimetres.1;
23 + match descriptor {
24 + Some((width, height)) => {
25 + // A nonzero pixel clock is what marks the block as a timing.
26 + block[DTD] = 0x01;
27 + block[DTD + 12] = u8::try_from(width & 0xff).unwrap();
28 + block[DTD + 13] = u8::try_from(height & 0xff).unwrap();
29 + block[DTD + 14] =
30 + u8::try_from(((width >> 8) << 4) | (height >> 8)).expect("a nibble each");
31 + }
32 + // Pixel clock zero: EDID's marker for a monitor-name or range-limit
33 + // descriptor, whose bytes 12-14 are text rather than a size.
34 + None => block[DTD + 12..DTD + 15].copy_from_slice(b"abc"),
35 + }
36 + block
37 + }
38 +
39 + /// The FW12 panel: 12.2" 1920x1200, 263x164mm, ~185 PPI.
40 + #[test]
41 + fn the_one_validated_panel_seeds_the_scale_it_ships_with() {
42 + assert_eq!(scale_for(1920, 263), Some(1.25));
43 + }
44 +
45 + // The rule generalizes in the right direction, and clamps rather than
46 + // running off either end of the ladder the `s` key walks.
47 + #[test]
48 + fn density_decides_the_rung() {
49 + // 13.3" 2560x1600, ~227 PPI.
50 + assert_eq!(scale_for(2560, 286), Some(1.5));
51 + // 11.6" 1366x768, ~135 PPI: below the first rung, and stays on it.
52 + assert_eq!(scale_for(1366, 256), Some(1.0));
53 + // Denser than the ladder goes.
54 + assert_eq!(scale_for(3840, 250), Some(2.0));
55 + }
56 +
57 + // A missing dimension is a panel this cannot answer for, not a panel at 1.0.
58 + #[test]
59 + fn a_panel_with_no_geometry_gets_no_scale() {
60 + assert_eq!(scale_for(0, 263), None);
61 + assert_eq!(scale_for(1920, 0), None);
62 + }
63 +
64 + // sysfs `modes` lists the preferred mode first and carries no refresh rate.
65 + #[test]
66 + fn the_preferred_mode_is_the_first_line() {
67 + assert_eq!(first_mode("1920x1200\n1280x800\n"), Some((1920, 1200)));
68 + assert_eq!(first_mode("\n\n1920x1200\n"), Some((1920, 1200)));
69 + assert_eq!(first_mode(""), None);
70 + assert_eq!(first_mode("nonsense\n"), None);
71 + }
72 +
73 + // Millimetres beat centimetres: 26cm and 263mm are the same panel described
74 + // to 1.5% different accuracy, and the PPI carries that error into the scale.
75 + #[test]
76 + fn the_timing_descriptor_wins_over_the_rounded_centimetres() {
77 + let block = edid((26, 16), Some((263, 164)));
78 + assert_eq!(panel_millimetres(&block), Some((263, 164)));
79 + }
80 +
81 + // A descriptor with no pixel clock is a monitor name, and its bytes 12-14
82 + // are letters. Reading those as a size gives a panel 25mm wide.
83 + #[test]
84 + fn a_descriptor_that_is_not_a_timing_falls_back_to_centimetres() {
85 + let block = edid((26, 16), None);
86 + assert_eq!(panel_millimetres(&block), Some((260, 160)));
87 + }
88 +
89 + #[test]
90 + fn an_edid_that_states_no_size_is_refused() {
91 + assert_eq!(panel_millimetres(&edid((0, 0), None)), None);
92 + assert_eq!(panel_millimetres(&[]), None);
93 + // Truncated before the descriptor, and before 0x16.
94 + assert_eq!(panel_millimetres(&[0u8; 0x16]), None);
95 + }
96 +
97 + /// A connector directory as the kernel lays one out.
98 + ///
99 + /// No `enabled` file: the connectors that need one are built by
100 + /// [`lit_connector`], and its absence is itself the case the fallback in
101 + /// [`detect_outputs`] covers.
102 + fn connector(name: &str, status: &str, modes: &str, edid: Option<Vec<u8>>) -> PathBuf {
103 + connector_in(
104 + &std::env::temp_dir().join("alloy-display-connectors"),
105 + name,
106 + status,
107 + modes,
108 + edid,
109 + )
110 + }
111 +
112 + /// The same, under a caller-chosen root, so one test can lay out a machine
113 + /// with several connectors instead of one connector at a time.
114 + fn connector_in(
115 + root: &std::path::Path,
116 + name: &str,
117 + status: &str,
118 + modes: &str,
119 + edid: Option<Vec<u8>>,
120 + ) -> PathBuf {
121 + let dir = root.join(name);
122 + let _ = std::fs::remove_dir_all(&dir);
123 + std::fs::create_dir_all(&dir).unwrap();
124 + std::fs::write(dir.join("status"), format!("{status}\n")).unwrap();
125 + std::fs::write(dir.join("modes"), modes).unwrap();
126 + if let Some(edid) = edid {
127 + std::fs::write(dir.join("edid"), edid).unwrap();
128 + }
129 + dir
130 + }
131 +
132 + /// A connector with the kernel's `enabled` file written too.
133 + fn lit_connector(
134 + root: &std::path::Path,
135 + name: &str,
136 + modes: &str,
137 + edid: &[u8],
138 + enabled: bool,
139 + ) -> PathBuf {
140 + let dir = connector_in(root, name, "connected", modes, Some(edid.to_vec()));
141 + let state = if enabled { "enabled" } else { "disabled" };
142 + std::fs::write(dir.join("enabled"), format!("{state}\n")).unwrap();
143 + dir
144 + }
145 +
146 + /// This machine's own EDIDs, captured from `/sys/class/drm`.
147 + ///
148 + /// The BOE NE135A1M-NY1 panel and the BenQ RD280U on `DP-3`, byte for byte.
149 + /// They are here because the multi-output path had nothing behind it but
150 + /// `Mock`, and a hand-built EDID cannot show that two real ones disagree
151 + /// about anything.
152 + const FW13_PANEL_EDID: &[u8] = include_bytes!("../../../testdata/fw13-edp-1.edid");
153 + const FW13_MONITOR_EDID: &[u8] = include_bytes!("../../../testdata/fw13-dp-3.edid");
154 +
155 + #[test]
156 + fn a_connected_panel_reads_as_an_output_the_generator_accepts() {
157 + let dir = connector(
158 + "card1-eDP-1",
159 + "connected",
160 + "1920x1200\n",
161 + Some(edid((26, 16), Some((263, 164)))),
162 + );
163 + let panel = output_at(&dir)
164 + .expect("a connected panel with a size")
165 + .output;
166 + assert_eq!(panel.name, "eDP-1");
167 + assert_eq!(panel.identifier(), "eDP-1");
168 + assert!((panel.scale - 1.25).abs() < f64::EPSILON);
169 +
170 + // The seed and a console write are the same file, from the same
171 + // generator: this is the whole reason detection returns an `Output`.
172 + let file = config_file(&[panel]);
173 + assert!(file.contains("output eDP-1 scale 1.25"), "{file}");
174 + assert!(!file.contains("transform"), "{file}");
175 + assert!(!file.contains("enable"), "{file}");
176 + }
177 +
178 + // An external connector reads as an output like any other. Which of them
179 + // gets seeded is [`detect_outputs`]'s question and not this function's; that
180 + // filter used to live here, and putting it here is what made a lid-closed
181 + // install unable to describe the screen it was being run on.
182 + #[test]
183 + fn an_external_connector_reads_as_an_output_too() {
184 + let dir = connector(
185 + "card1-DP-1",
186 + "connected",
187 + "2560x1440\n",
188 + Some(edid((60, 34), None)),
189 + );
190 + let monitor = output_at(&dir)
191 + .expect("a connected monitor with a size")
192 + .output;
193 + assert_eq!(monitor.identifier(), "DP-1");
194 + assert!(!monitor.built_in());
195 + }
196 +
197 + #[test]
198 + fn a_connector_with_nothing_on_it_is_skipped() {
199 + let dir = connector("card1-eDP-2", "disconnected", "", None);
200 + assert!(output_at(&dir).is_none());
201 + }
202 +
203 + // No EDID is the ordinary case on a connector the kernel has not read one
204 + // from, and it means there is no size to compute a scale from.
205 + #[test]
206 + fn a_panel_with_no_edid_seeds_nothing() {
207 + let dir = connector("card1-eDP-3", "connected", "1920x1200\n", None);
208 + assert!(output_at(&dir).is_none());
209 + }
210 +
211 + /// A sysfs root of this machine's own connectors, in a chosen lid state.
212 + ///
213 + /// `eDP-1` and `DP-3` carry the real EDIDs; the six dark DisplayPort
214 + /// connectors this machine also has are left out, since a disconnected
215 + /// connector is already covered above and eight of them would say the same
216 + /// thing seven more times.
217 + fn fw13_sysfs(case: &str, lid_open: bool) -> PathBuf {
218 + let root = std::env::temp_dir()
219 + .join("alloy-display-machines")
220 + .join(case);
221 + let _ = std::fs::remove_dir_all(&root);
222 + std::fs::create_dir_all(&root).unwrap();
223 + lit_connector(
224 + &root,
225 + "card1-eDP-1",
226 + "2880x1920\n",
227 + FW13_PANEL_EDID,
228 + lid_open,
229 + );
230 + lit_connector(&root, "card1-DP-3", "3840x2560\n", FW13_MONITOR_EDID, true);
231 + root
232 + }
233 +
234 + // THE LID-CLOSED DESK INSTALL, which is the case this rule exists for. Both
235 + // connectors say `connected`; only the monitor is lit. Seeding the panel
236 + // alone left the screen in use at 1.0 with no console session to fix it.
237 + #[test]
238 + fn a_dark_panel_does_not_displace_the_monitor_being_used() {
239 + let outputs = detect_outputs_in(&fw13_sysfs("lid-closed", false));
240 + let names: Vec<&str> = outputs.iter().map(|output| output.name.as_str()).collect();
241 + assert_eq!(names, ["DP-3"]);
242 + assert!(
243 + (outputs[0].scale - 1.0).abs() < f64::EPSILON,
244 + "{outputs:#?}"
245 + );
246 + }
247 +
248 + // Lid open on the same desk: both are lit, both are seeded, and the panel
249 + // is spelled first. The two real EDIDs disagree by three quarters of a rung,
250 + // which is the disagreement no hand-built fixture was showing.
251 + #[test]
252 + fn both_lit_screens_are_seeded_and_the_panel_leads() {
253 + let outputs = detect_outputs_in(&fw13_sysfs("lid-open", true));
254 + let names: Vec<&str> = outputs.iter().map(|output| output.name.as_str()).collect();
255 + assert_eq!(names, ["eDP-1", "DP-3"]);
256 +
257 + let file = config_file(&outputs);
258 + assert!(file.contains("output eDP-1 scale 1.75"), "{file}");
259 + assert!(file.contains("output DP-3 scale 1"), "{file}");
260 + }
261 +
262 + // A text-console install with no CRTC bound reports nothing lit. That is the
263 + // pre-2026-08-11 world, and the answer there is the one it always gave: the
264 + // built-in panel, and no stanza for a monitor nobody is looking at.
265 + #[test]
266 + fn with_nothing_lit_the_built_in_panel_is_still_the_answer() {
267 + let root = std::env::temp_dir().join("alloy-display-machines/console");
268 + let _ = std::fs::remove_dir_all(&root);
269 + std::fs::create_dir_all(&root).unwrap();
270 + connector_in(
271 + &root,
272 + "card1-eDP-1",
273 + "connected",
274 + "2880x1920\n",
275 + Some(FW13_PANEL_EDID.to_vec()),
276 + );
277 + connector_in(
278 + &root,
279 + "card1-DP-3",
280 + "connected",
281 + "3840x2560\n",
282 + Some(FW13_MONITOR_EDID.to_vec()),
283 + );
284 +
285 + let outputs = detect_outputs_in(&root);
286 + let names: Vec<&str> = outputs.iter().map(|output| output.name.as_str()).collect();
287 + assert_eq!(names, ["eDP-1"]);
288 + }
289 +
290 + // A desktop: no panel, nothing lit in the console. Seeding nothing is the
291 + // right answer, and it is the one an empty `/sys` gives too.
292 + #[test]
293 + fn a_machine_with_no_lit_screen_and_no_panel_seeds_nothing() {
294 + let root = std::env::temp_dir().join("alloy-display-machines/desktop");
295 + let _ = std::fs::remove_dir_all(&root);
296 + std::fs::create_dir_all(&root).unwrap();
297 + connector_in(
298 + &root,
299 + "card1-DP-3",
300 + "connected",
301 + "3840x2560\n",
302 + Some(FW13_MONITOR_EDID.to_vec()),
303 + );
304 +
305 + assert!(detect_outputs_in(&root).is_empty());
306 + assert!(detect_outputs_in(std::path::Path::new("/nonexistent-drm")).is_empty());
307 + }
308 +
309 + /// Read this machine's real screens, the way an install would.
310 + ///
311 + /// Ignored by default because it asserts about hardware: it passes on a
312 + /// machine with a screen this can describe and says nothing in a container.
313 + /// Unlike the sway test below it needs no session.
314 + ///
315 + /// On fw13 (Framework 13, 2880x1920, EDID 285x190mm) it reads 257 PPI and
316 + /// seeds 1.75, which is the second panel the rule has been put to. Neither
317 + /// is an Alloy install, and the seed has never been written by a real
318 + /// installer run.
319 + #[test]
320 + #[ignore = "requires a machine with a screen it can describe"]
321 + fn reads_this_machines_real_screens() {
322 + let outputs = detect_outputs();
323 + assert!(!outputs.is_empty(), "this machine has a screen");
324 + println!("{}", config_file(&outputs));
325 + for output in &outputs {
326 + assert!(SCALES.contains(&output.scale), "{}", output.scale);
327 + }
328 + }
@@ -1,0 +1,168 @@
1 + //! Settings follow the monitor, not the port.
2 + //!
3 + //! Runs headless from `main.rs` before the theme loads, so nothing here draws.
4 +
5 + use super::backend::detect;
6 + use super::config::write_effect;
7 + use super::model::{Directive, Output, spell_scale};
8 + use crate::cli::CommandLog;
9 + use crate::monitors;
10 +
11 + /// What one monitor's settings should be after a reconcile, and why.
12 + ///
13 + /// Returned rather than applied so the decision is testable without a sway, a
14 + /// home directory or a clock. Everything below this line that touches the world
15 + /// is in [`reconcile`].
16 + #[derive(Debug, Clone, PartialEq)]
17 + pub(crate) struct Move {
18 + pub fingerprint: String,
19 + /// Where the monitor was last seen.
20 + pub from: String,
21 + /// Where it is now.
22 + pub to: String,
23 + pub scale: f64,
24 + pub transform: String,
25 + pub enabled: bool,
26 + }
27 +
28 + impl Move {
29 + /// The instructions that put the remembered settings on the new connector.
30 + pub(crate) fn directives(&self) -> Vec<Directive> {
31 + let mut directives = vec![Directive::at(
32 + self.to.clone(),
33 + "scale",
34 + spell_scale(self.scale),
35 + )];
36 + if !self.transform.is_empty() && self.transform != "normal" {
37 + directives.push(Directive::at(
38 + self.to.clone(),
39 + "transform",
40 + self.transform.clone(),
41 + ));
42 + }
43 + if !self.enabled {
44 + directives.push(Directive::at(self.to.clone(), "enable", "false"));
45 + }
46 + directives
47 + }
48 + }
49 +
50 + /// Work out which monitors moved, given what is attached and what is remembered.
51 + ///
52 + /// Pure, and the whole of the design's logic. A monitor is "moved" when its
53 + /// fingerprint is in the table under a different connector than the one it is on
54 + /// now; anything unknown, unchanged, or unidentifiable is left alone.
55 + ///
56 + /// A monitor that moved and whose settings already match what is remembered
57 + /// still counts as a move, because the *table* is wrong either way and the
58 + /// caller is what fixes it. Emitting the directives again costs one swaymsg per
59 + /// property and is how the surface stays idempotent.
60 + pub(crate) fn moves(outputs: &[Output], registry: &monitors::Registry) -> Vec<Move> {
61 + outputs
62 + .iter()
63 + .filter_map(|output| {
64 + let fingerprint = output.fingerprint()?;
65 + let from = registry.moved_from(&fingerprint, &output.name)?;
66 + let remembered = registry.get(&fingerprint)?;
67 + Some(Move {
68 + fingerprint: fingerprint.clone(),
69 + from: from.to_string(),
70 + to: output.name.clone(),
71 + scale: remembered.scale,
72 + transform: remembered.transform.clone(),
73 + enabled: remembered.enabled,
74 + })
75 + })
76 + .collect()
77 + }
78 +
79 + /// Record what is attached now, so a later run can notice it moved.
80 + ///
81 + /// Called after a write and after a reconcile, which are the two moments the
82 + /// console knows both what is attached and what its settings are. An output
83 + /// with no fingerprint is skipped rather than stored under a placeholder: see
84 + /// [`Output::fingerprint`].
85 + pub(crate) fn remember(outputs: &[Output], registry: &mut monitors::Registry, now: u64) {
86 + for output in outputs {
87 + let Some(fingerprint) = output.fingerprint() else {
88 + continue;
89 + };
90 + registry.remember(
91 + fingerprint,
92 + monitors::Remembered {
93 + connector: output.name.clone(),
94 + scale: output.scale,
95 + transform: output.transform.clone(),
96 + enabled: output.active,
97 + last_seen: now,
98 + description: output.description(),
99 + },
100 + );
101 + }
102 + registry.expire(now);
103 + }
104 +
105 + /// The whole reconcile, for the session wrapper and for `alloy display
106 + /// --reconcile`.
107 + ///
108 + /// Prints rather than draws: it runs from `usr/bin/alloy-session` before there
109 + /// is a session to draw into, beside `alloy theme apply` and for the same
110 + /// reason. Failure is reported and not fatal, on the rule that file already
111 + /// follows: a login must not be lost to bookkeeping.
112 + pub(crate) fn reconcile(verbose: bool) {
113 + let mut log = CommandLog::new();
114 + let backend = detect();
115 + let outputs = match backend.list(&mut log) {
116 + Ok(outputs) => outputs,
117 + Err(err) => {
118 + eprintln!("alloy display: cannot read outputs: {err}");
119 + return;
120 + }
121 + };
122 +
123 + let mut registry = monitors::load();
124 + let moved = moves(&outputs, &registry);
125 +
126 + for one in &moved {
127 + println!(
128 + "alloy display: {} moved from {} to {}",
129 + one.fingerprint, one.from, one.to
130 + );
131 + for directive in one.directives() {
132 + if let Err(err) = directive.invocation().run(&mut log) {
133 + eprintln!("alloy display: {}: {err}", directive.words());
134 + }
135 + }
136 + }
137 +
138 + // Re-read after applying, so what is recorded is what sway ended up with
139 + // rather than what it was asked for. A refused scale would otherwise be
140 + // remembered as though it had taken.
141 + let settled = if moved.is_empty() {
142 + outputs
143 + } else {
144 + backend.list(&mut log).unwrap_or_default()
145 + };
146 +
147 + // Through `Effect::apply` rather than `fs::write`, so the reconcile writes
148 + // the file the same way the `w` key does, mode included.
149 + if let Some(effect) = (!settled.is_empty())
150 + .then(|| write_effect(&settled))
151 + .flatten()
152 + && let Err(err) = effect.apply(&mut log)
153 + {
154 + eprintln!("alloy display: {err}");
155 + }
156 +
157 + remember(&settled, &mut registry, monitors::now());
158 + if let Err(err) = monitors::save(&registry) {
159 + eprintln!("alloy display: cannot write the monitor table: {err}");
160 + }
161 +
162 + if verbose && moved.is_empty() {
163 + println!("alloy display: nothing moved");
164 + }
165 + }
166 +
167 + #[cfg(test)]
168 + mod tests;