max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
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, ®istry); | |
| 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(®istry) { | |
| 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; |