max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+544 insertions,
-54 deletions
| @@ -222,7 +222,7 @@ | |||
| 222 | 222 | ||
| 223 | 223 | - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, `alloy setup`, and all three `alloy pkg` tabs (`box`, plus `install` and `update` fronting `rpm-ostree status --json`). Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. `alloy pkg` forced three more pieces into it: `AlloyTabs`, a Cancel that views see before the shell claims it (a confirm needs a cancel that is not "exit the app"), and terminal suspend, so entering a box can hand the TTY to another interactive program. | |
| 224 | 224 | - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) presets and live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, save and dirty tracking having landed with the edit; (10) `AlloyTable` read-only. | |
| 225 | - | - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode). | |
| 225 | + | - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode). | |
| 226 | 226 | - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). The first-boot flow (see [CONTINUITY.md](CONTINUITY.md)) shipped as `alloy setup`: two rows over the same `mesh` and `sync` backend seams, so there is no third enrollment path to keep in agreement with them. It survives the teardown mesh enrollment costs by refreshing on the first tick after a suspend rather than on its poll counter. The shell's post-suspend refresh arrives as one `tick` call, and a counter would swallow four out of five of them, leaving the row the user just enrolled reading "not enrolled" for several seconds. The screen appears once: the session autostart runs `alloy setup --if-first-boot`, and the console records that it asked in the same config store the theme lives in. | |
| 227 | 227 | - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.) | |
| 228 | 228 | - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned. |
| @@ -25,7 +25,7 @@ | |||
| 25 | 25 | ## Display | |
| 26 | 26 | ||
| 27 | 27 | - **Native panel:** 12.2" 1920x1200 (16:10), matte, IPS-class, ~185 PPI. Sits in the awkward middle where 1.0x is too small and 2.0x is too big. | |
| 28 | - | - **Alloy default scale:** 1.25x fractional under sway (`output eDP-1 scale 1.25`, shipped in `etc/sway/config.d/10-fw12.conf`). `alloy display` overrides it per user without touching that file: it writes the same directive to `~/.config/sway/config.d/50-display.conf`, which the sway config includes afterwards. sway handles the compositor side; terminal apps scale with the compositor, so there is no per-app fractional-scale work as there would have been for egui. Verify glyph crispness at v0 sanity check. | |
| 28 | + | - **Alloy default scale:** 1.25x fractional under sway (`output eDP-1 scale 1.25`). No longer shipped as a system file naming this laptop: the installer reads the panel's size out of its EDID and writes the directive to `~/.config/sway/config.d/50-display.conf`, from the same generator `alloy display` uses, so the value is the machine's own and its owner can change it with one keypress. This panel is the measurement the rule is anchored on — ~185 PPI takes 1.25, which is `PPI_PER_SCALE` in `crates/alloy/src/display.rs`. sway handles the compositor side; terminal apps scale with the compositor, so there is no per-app fractional-scale work as there would have been for egui. Verify glyph crispness at v0 sanity check. | |
| 29 | 29 | - **Contrast/color:** validate the DESIGN-LANGUAGE.md dark-mode L stops on this panel specifically (currently deferred in `todo.md`). Matte 400-nit IPS is roughly the panel class most Alloy users are likely to have; if the tokens miss here, they miss for the base audience. | |
| 30 | 30 | ||
| 31 | 31 | Rejected: 1.0x default (glyphs too small at arm's length on 12.2"), 1.5x default (wastes usable columns in a scrollable-tiling model where horizontal density matters). |
| @@ -58,6 +58,14 @@ | |||
| 58 | 58 | //! sway config includes `~/.config/sway/config.d/*` after | |
| 59 | 59 | //! `/etc/sway/config.d/*`, so the user's file wins on a conflicting property. | |
| 60 | 60 | //! | |
| 61 | + | //! **The installer seeds that file, from the same generator.** A fresh machine | |
| 62 | + | //! has no console session behind it, so without a seed the first boot renders | |
| 63 | + | //! at 1.0 whatever the panel is. [`detect_panel`] reads the built-in panel out | |
| 64 | + | //! of sysfs — where the installer runs there is no compositor to ask — and | |
| 65 | + | //! `install.rs` writes [`config_file`] of it into the new home. One generator, | |
| 66 | + | //! so a seeded file and a console-written one are the same file, and the first | |
| 67 | + | //! press of `s` in `alloy display` overwrites it without a special case. | |
| 68 | + | //! | |
| 61 | 69 | //! The path is built from `$HOME` and not from `$XDG_CONFIG_HOME`, which is the | |
| 62 | 70 | //! one place this deliberately disagrees with the rest of the console. The | |
| 63 | 71 | //! include line in the shipped config names `~/.config/sway/config.d/*` | |
| @@ -102,8 +110,13 @@ | |||
| 102 | 110 | use crate::cli::{CommandLog, Effect, Invocation}; | |
| 103 | 111 | use crate::shell::{Flow, View, block_title}; | |
| 104 | 112 | ||
| 105 | - | /// The file this verb owns. | |
| 106 | - | const FILE: &str = ".config/sway/config.d/50-display.conf"; | |
| 113 | + | /// The file this verb owns, relative to a home directory. | |
| 114 | + | /// | |
| 115 | + | /// Relative rather than absolute because the installer writes it too, into a | |
| 116 | + | /// home that is not `$HOME` — the target's, reached through a mountpoint. Both | |
| 117 | + | /// callers join it onto the home they mean, so there is one spelling of the | |
| 118 | + | /// path. | |
| 119 | + | pub(crate) const FILE: &str = ".config/sway/config.d/50-display.conf"; | |
| 107 | 120 | ||
| 108 | 121 | /// What sway reports for a field the panel does not carry. | |
| 109 | 122 | /// | |
| @@ -493,7 +506,8 @@ | |||
| 493 | 506 | /// is not the same thing as a generated artifact. | |
| 494 | 507 | pub(crate) fn config_file(outputs: &[Output]) -> String { | |
| 495 | 508 | let mut out = String::from( | |
| 496 | - | "# Generated by `alloy display`. Regenerated whole on every change, so\n\ | |
| 509 | + | "# Generated by Alloy: seeded at install from the panel this machine\n\ | |
| 510 | + | # reports, and rewritten whole by `alloy display`, so\n\ | |
| 497 | 511 | # hand edits here are lost. Put your own output config in another file\n\ | |
| 498 | 512 | # in this directory: sway merges every stanza that matches an output,\n\ | |
| 499 | 513 | # and the shipped config includes all of ~/.config/sway/config.d/*.\n", | |
| @@ -514,6 +528,148 @@ | |||
| 514 | 528 | out | |
| 515 | 529 | } | |
| 516 | 530 | ||
| 531 | + | // ---- the panel, before there is a sway to ask ---- | |
| 532 | + | ||
| 533 | + | /// Where the kernel describes the connectors it found. | |
| 534 | + | /// | |
| 535 | + | /// One directory per connector, named `card<N>-<CONNECTOR>`, and the connector | |
| 536 | + | /// half is the same string sway reports as an output name. That correspondence | |
| 537 | + | /// is what lets the installer write a stanza the compositor will match later: | |
| 538 | + | /// both are reading DRM's vocabulary rather than inventing one. | |
| 539 | + | const DRM: &str = "/sys/class/drm"; | |
| 540 | + | ||
| 541 | + | /// The PPI one step of scale is worth. | |
| 542 | + | /// | |
| 543 | + | /// 185 / 1.25, from the one panel anyone has looked at: the FW12's 12.2" | |
| 544 | + | /// 1920x1200 sits at ~185 PPI and takes 1.25, with 1.0 too small at arm's | |
| 545 | + | /// length and 1.5 wasting columns. docs/HARDWARE-FW12.md#display argues that | |
| 546 | + | /// choice; this constant is only that judgment restated as a ratio so a | |
| 547 | + | /// different panel can be answered without a second judgment. | |
| 548 | + | /// | |
| 549 | + | /// **One data point, so this is a rule and not a measurement.** It generalizes | |
| 550 | + | /// in the right direction — a denser panel gets more scale — and every value it | |
| 551 | + | /// produces is one keypress from being overridden, since `alloy display` writes | |
| 552 | + | /// the same file this seeds. | |
| 553 | + | const PPI_PER_SCALE: f64 = 148.0; | |
| 554 | + | ||
| 555 | + | /// The scale a panel of this geometry should come up at. | |
| 556 | + | /// | |
| 557 | + | /// Snapped to [`SCALES`] rather than used raw: the rungs are the values the | |
| 558 | + | /// console can walk, and seeding a scale the `s` key cannot return to would | |
| 559 | + | /// make the first press jump somewhere the user did not ask for. Off the ends | |
| 560 | + | /// of the ladder it clamps, which is what keeps a 1366x768 panel at 1.0 instead | |
| 561 | + | /// of below it. | |
| 562 | + | fn scale_for(pixels_wide: u32, millimetres_wide: u32) -> Option<f64> { | |
| 563 | + | if pixels_wide == 0 || millimetres_wide == 0 { | |
| 564 | + | return None; | |
| 565 | + | } | |
| 566 | + | let ppi = f64::from(pixels_wide) / (f64::from(millimetres_wide) / 25.4); | |
| 567 | + | let want = ppi / PPI_PER_SCALE; | |
| 568 | + | SCALES | |
| 569 | + | .iter() | |
| 570 | + | .copied() | |
| 571 | + | .min_by(|a, b| (a - want).abs().total_cmp(&(b - want).abs())) | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | /// The first mode a connector advertises, from its sysfs `modes` file. | |
| 575 | + | /// | |
| 576 | + | /// The first line is the preferred mode, which on a laptop panel is its native | |
| 577 | + | /// resolution and the only one it has. No refresh rate here: `modes` carries | |
| 578 | + | /// `1920x1200` and nothing else, which is the whole of what the scale needs. | |
| 579 | + | fn first_mode(modes: &str) -> Option<(u32, u32)> { | |
| 580 | + | let line = modes.lines().map(str::trim).find(|line| !line.is_empty())?; | |
| 581 | + | let (width, height) = line.split_once('x')?; | |
| 582 | + | Some((width.parse().ok()?, height.parse().ok()?)) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | /// The panel's physical size in millimetres, from its EDID. | |
| 586 | + | /// | |
| 587 | + | /// Two places carry it and they disagree in precision. The basic display | |
| 588 | + | /// parameters at 0x15 and 0x16 are whole centimetres, so a 263mm panel reports | |
| 589 | + | /// 26 and the PPI comes out 1.5% wrong; the first detailed timing descriptor | |
| 590 | + | /// carries millimetres outright, split across a shared byte of high nibbles. | |
| 591 | + | /// The descriptor is preferred and the centimetres are the fallback, which is | |
| 592 | + | /// the order every EDID reader uses. | |
| 593 | + | /// | |
| 594 | + | /// A descriptor whose pixel clock is zero is not a timing at all — that is how | |
| 595 | + | /// EDID marks the monitor-name and range-limit blocks — so its bytes 12 to 14 | |
| 596 | + | /// mean something else entirely and reading them as a size gives a panel the | |
| 597 | + | /// dimensions of whatever text is stored there. | |
| 598 | + | fn panel_millimetres(edid: &[u8]) -> Option<(u32, u32)> { | |
| 599 | + | /// Start of the first detailed timing descriptor in the base block. | |
| 600 | + | const DTD: usize = 0x36; | |
| 601 | + | ||
| 602 | + | if edid.len() >= DTD + 15 && edid[DTD] | edid[DTD + 1] != 0 { | |
| 603 | + | let high = edid[DTD + 14]; | |
| 604 | + | let width = u32::from(edid[DTD + 12]) | (u32::from(high >> 4) << 8); | |
| 605 | + | let height = u32::from(edid[DTD + 13]) | (u32::from(high & 0x0f) << 8); | |
| 606 | + | if width != 0 && height != 0 { | |
| 607 | + | return Some((width, height)); | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | let (width, height) = ( | |
| 612 | + | u32::from(*edid.get(0x15)?) * 10, | |
| 613 | + | u32::from(*edid.get(0x16)?) * 10, | |
| 614 | + | ); | |
| 615 | + | (width != 0 && height != 0).then_some((width, height)) | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | /// Read one connector directory as an output, if it is a panel worth seeding. | |
| 619 | + | /// | |
| 620 | + | /// `None` for everything else: a disconnected connector, an external port, and | |
| 621 | + | /// a panel whose EDID does not say how big it is. Each of those is a machine | |
| 622 | + | /// this cannot answer for, and a guessed scale is worse than none — an install | |
| 623 | + | /// that seeds nothing comes up at 1.0, which is legible everywhere and one | |
| 624 | + | /// keypress from correct. | |
| 625 | + | fn panel_at(dir: &std::path::Path) -> Option<Output> { | |
| 626 | + | // `card1-eDP-1` is one card and one connector; sway names the second half. | |
| 627 | + | let name = dir.file_name()?.to_str()?.split_once('-')?.1.to_string(); | |
| 628 | + | let panel = Output { | |
| 629 | + | name, | |
| 630 | + | make: String::new(), | |
| 631 | + | model: String::new(), | |
| 632 | + | serial: String::new(), | |
| 633 | + | active: true, | |
| 634 | + | dpms: true, | |
| 635 | + | focused: false, | |
| 636 | + | rect: Rectangle::default(), | |
| 637 | + | scale: 1.0, | |
| 638 | + | transform: "normal".into(), | |
| 639 | + | current_mode: None, | |
| 640 | + | modes: Vec::new(), | |
| 641 | + | }; | |
| 642 | + | if !panel.built_in() { | |
| 643 | + | return None; | |
| 644 | + | } | |
| 645 | + | if std::fs::read_to_string(dir.join("status")).ok()?.trim() != "connected" { | |
| 646 | + | return None; | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | let (pixels_wide, _) = first_mode(&std::fs::read_to_string(dir.join("modes")).ok()?)?; | |
| 650 | + | let (millimetres_wide, _) = panel_millimetres(&std::fs::read(dir.join("edid")).ok()?)?; | |
| 651 | + | Some(Output { | |
| 652 | + | scale: scale_for(pixels_wide, millimetres_wide)?, | |
| 653 | + | ..panel | |
| 654 | + | }) | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | /// The built-in panel of the machine this is running on, if it has one. | |
| 658 | + | /// | |
| 659 | + | /// For the installer, which runs on the target hardware and before any | |
| 660 | + | /// compositor: `swaymsg` has nobody to ask there, and the answer is in sysfs | |
| 661 | + | /// either way. Connectors are read in name order so a machine with two panels | |
| 662 | + | /// (none has been seen) seeds the lower-numbered one rather than whichever the | |
| 663 | + | /// directory listing happened to yield first. | |
| 664 | + | pub(crate) fn detect_panel() -> Option<Output> { | |
| 665 | + | let mut connectors: Vec<PathBuf> = std::fs::read_dir(DRM) | |
| 666 | + | .ok()? | |
| 667 | + | .filter_map(|entry| Some(entry.ok()?.path())) | |
| 668 | + | .collect(); | |
| 669 | + | connectors.sort(); | |
| 670 | + | connectors.iter().find_map(|dir| panel_at(dir)) | |
| 671 | + | } | |
| 672 | + | ||
| 517 | 673 | /// Where the file lives, or `None` on a machine with no `$HOME`. | |
| 518 | 674 | fn config_path() -> Option<PathBuf> { | |
| 519 | 675 | std::env::var_os("HOME").map(|home| PathBuf::from(home).join(FILE)) | |
| @@ -1446,6 +1602,182 @@ | |||
| 1446 | 1602 | assert!(message.contains("eDP-1"), "{message}"); | |
| 1447 | 1603 | } | |
| 1448 | 1604 | ||
| 1605 | + | // ---- the installer's seed ---- | |
| 1606 | + | ||
| 1607 | + | /// A 128-byte EDID base block carrying a size and nothing else that matters. | |
| 1608 | + | /// | |
| 1609 | + | /// Hand-built rather than captured: no EDID has been read off an Alloy | |
| 1610 | + | /// machine, and the two fields this parser reads are at fixed offsets that a | |
| 1611 | + | /// real dump would not make any more true. What a capture would add is | |
| 1612 | + | /// evidence that panels fill them the way the specification says, which is | |
| 1613 | + | /// the part still unverified. | |
| 1614 | + | /// | |
| 1615 | + | /// `descriptor` is the first detailed timing block's millimetres, or `None` | |
| 1616 | + | /// for a base block whose first descriptor is not a timing at all — the case | |
| 1617 | + | /// where the centimetres at 0x15 are all there is. | |
| 1618 | + | fn edid(centimetres: (u8, u8), descriptor: Option<(u32, u32)>) -> Vec<u8> { | |
| 1619 | + | const DTD: usize = 0x36; | |
| 1620 | + | let mut block = vec![0u8; 128]; | |
| 1621 | + | block[..8].copy_from_slice(&[0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]); | |
| 1622 | + | block[0x15] = centimetres.0; | |
| 1623 | + | block[0x16] = centimetres.1; | |
| 1624 | + | match descriptor { | |
| 1625 | + | Some((width, height)) => { | |
| 1626 | + | // A nonzero pixel clock is what marks the block as a timing. | |
| 1627 | + | block[DTD] = 0x01; | |
| 1628 | + | block[DTD + 12] = u8::try_from(width & 0xff).unwrap(); | |
| 1629 | + | block[DTD + 13] = u8::try_from(height & 0xff).unwrap(); | |
| 1630 | + | block[DTD + 14] = | |
| 1631 | + | u8::try_from(((width >> 8) << 4) | (height >> 8)).expect("a nibble each"); | |
| 1632 | + | } | |
| 1633 | + | // Pixel clock zero: EDID's marker for a monitor-name or range-limit | |
| 1634 | + | // descriptor, whose bytes 12-14 are text rather than a size. | |
| 1635 | + | None => block[DTD + 12..DTD + 15].copy_from_slice(b"abc"), | |
| 1636 | + | } | |
| 1637 | + | block | |
| 1638 | + | } | |
| 1639 | + | ||
| 1640 | + | /// The FW12 panel: 12.2" 1920x1200, 263x164mm, ~185 PPI. | |
| 1641 | + | #[test] | |
| 1642 | + | fn the_one_validated_panel_seeds_the_scale_it_ships_with() { | |
| 1643 | + | assert_eq!(scale_for(1920, 263), Some(1.25)); | |
| 1644 | + | } | |
| 1645 | + | ||
| 1646 | + | // The rule generalizes in the right direction, and clamps rather than | |
| 1647 | + | // running off either end of the ladder the `s` key walks. | |
| 1648 | + | #[test] | |
| 1649 | + | fn density_decides_the_rung() { | |
| 1650 | + | // 13.3" 2560x1600, ~227 PPI. | |
| 1651 | + | assert_eq!(scale_for(2560, 286), Some(1.5)); | |
| 1652 | + | // 11.6" 1366x768, ~135 PPI: below the first rung, and stays on it. | |
| 1653 | + | assert_eq!(scale_for(1366, 256), Some(1.0)); | |
| 1654 | + | // Denser than the ladder goes. | |
| 1655 | + | assert_eq!(scale_for(3840, 250), Some(2.0)); | |
| 1656 | + | } | |
| 1657 | + | ||
| 1658 | + | // A missing dimension is a panel this cannot answer for, not a panel at 1.0. | |
| 1659 | + | #[test] | |
| 1660 | + | fn a_panel_with_no_geometry_gets_no_scale() { | |
| 1661 | + | assert_eq!(scale_for(0, 263), None); | |
| 1662 | + | assert_eq!(scale_for(1920, 0), None); | |
| 1663 | + | } | |
| 1664 | + | ||
| 1665 | + | // sysfs `modes` lists the preferred mode first and carries no refresh rate. | |
| 1666 | + | #[test] | |
| 1667 | + | fn the_preferred_mode_is_the_first_line() { | |
| 1668 | + | assert_eq!(first_mode("1920x1200\n1280x800\n"), Some((1920, 1200))); | |
| 1669 | + | assert_eq!(first_mode("\n\n1920x1200\n"), Some((1920, 1200))); | |
| 1670 | + | assert_eq!(first_mode(""), None); | |
| 1671 | + | assert_eq!(first_mode("nonsense\n"), None); | |
| 1672 | + | } | |
| 1673 | + | ||
| 1674 | + | // Millimetres beat centimetres: 26cm and 263mm are the same panel described | |
| 1675 | + | // to 1.5% different accuracy, and the PPI carries that error into the scale. | |
| 1676 | + | #[test] | |
| 1677 | + | fn the_timing_descriptor_wins_over_the_rounded_centimetres() { | |
| 1678 | + | let block = edid((26, 16), Some((263, 164))); | |
| 1679 | + | assert_eq!(panel_millimetres(&block), Some((263, 164))); | |
| 1680 | + | } | |
| 1681 | + | ||
| 1682 | + | // A descriptor with no pixel clock is a monitor name, and its bytes 12-14 | |
| 1683 | + | // are letters. Reading those as a size gives a panel 25mm wide. | |
| 1684 | + | #[test] | |
| 1685 | + | fn a_descriptor_that_is_not_a_timing_falls_back_to_centimetres() { | |
| 1686 | + | let block = edid((26, 16), None); | |
| 1687 | + | assert_eq!(panel_millimetres(&block), Some((260, 160))); | |
| 1688 | + | } | |
| 1689 | + | ||
| 1690 | + | #[test] | |
| 1691 | + | fn an_edid_that_states_no_size_is_refused() { | |
| 1692 | + | assert_eq!(panel_millimetres(&edid((0, 0), None)), None); | |
| 1693 | + | assert_eq!(panel_millimetres(&[]), None); | |
| 1694 | + | // Truncated before the descriptor, and before 0x16. | |
| 1695 | + | assert_eq!(panel_millimetres(&[0u8; 0x16]), None); | |
| 1696 | + | } | |
| 1697 | + | ||
| 1698 | + | /// A connector directory as the kernel lays one out. | |
| 1699 | + | fn connector(name: &str, status: &str, modes: &str, edid: Option<Vec<u8>>) -> PathBuf { | |
| 1700 | + | let dir = std::env::temp_dir() | |
| 1701 | + | .join("alloy-display-connectors") | |
| 1702 | + | .join(name); | |
| 1703 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 1704 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 1705 | + | std::fs::write(dir.join("status"), format!("{status}\n")).unwrap(); | |
| 1706 | + | std::fs::write(dir.join("modes"), modes).unwrap(); | |
| 1707 | + | if let Some(edid) = edid { | |
| 1708 | + | std::fs::write(dir.join("edid"), edid).unwrap(); | |
| 1709 | + | } | |
| 1710 | + | dir | |
| 1711 | + | } | |
| 1712 | + | ||
| 1713 | + | #[test] | |
| 1714 | + | fn a_connected_panel_reads_as_an_output_the_generator_accepts() { | |
| 1715 | + | let dir = connector( | |
| 1716 | + | "card1-eDP-1", | |
| 1717 | + | "connected", | |
| 1718 | + | "1920x1200\n", | |
| 1719 | + | Some(edid((26, 16), Some((263, 164)))), | |
| 1720 | + | ); | |
| 1721 | + | let panel = panel_at(&dir).expect("a connected panel with a size"); | |
| 1722 | + | assert_eq!(panel.name, "eDP-1"); | |
| 1723 | + | assert_eq!(panel.identifier(), "eDP-1"); | |
| 1724 | + | assert!((panel.scale - 1.25).abs() < f64::EPSILON); | |
| 1725 | + | ||
| 1726 | + | // The seed and a console write are the same file, from the same | |
| 1727 | + | // generator: this is the whole reason detection returns an `Output`. | |
| 1728 | + | let file = config_file(&[panel]); | |
| 1729 | + | assert!(file.contains("output eDP-1 scale 1.25"), "{file}"); | |
| 1730 | + | assert!(!file.contains("transform"), "{file}"); | |
| 1731 | + | assert!(!file.contains("enable"), "{file}"); | |
| 1732 | + | } | |
| 1733 | + | ||
| 1734 | + | // Only the built-in panel. An external monitor plugged in during an install | |
| 1735 | + | // is not the machine's screen, and seeding its scale into the new home would | |
| 1736 | + | // configure hardware that is about to be unplugged. | |
| 1737 | + | #[test] | |
| 1738 | + | fn an_external_connector_is_not_a_panel() { | |
| 1739 | + | let dir = connector( | |
| 1740 | + | "card1-DP-1", | |
| 1741 | + | "connected", | |
| 1742 | + | "2560x1440\n", | |
| 1743 | + | Some(edid((60, 34), None)), | |
| 1744 | + | ); | |
| 1745 | + | assert!(panel_at(&dir).is_none()); | |
| 1746 | + | } | |
| 1747 | + | ||
| 1748 | + | #[test] | |
| 1749 | + | fn a_connector_with_nothing_on_it_is_skipped() { | |
| 1750 | + | let dir = connector("card1-eDP-2", "disconnected", "", None); | |
| 1751 | + | assert!(panel_at(&dir).is_none()); | |
| 1752 | + | } | |
| 1753 | + | ||
| 1754 | + | // No EDID is the ordinary case on a connector the kernel has not read one | |
| 1755 | + | // from, and it means there is no size to compute a scale from. | |
| 1756 | + | #[test] | |
| 1757 | + | fn a_panel_with_no_edid_seeds_nothing() { | |
| 1758 | + | let dir = connector("card1-eDP-3", "connected", "1920x1200\n", None); | |
| 1759 | + | assert!(panel_at(&dir).is_none()); | |
| 1760 | + | } | |
| 1761 | + | ||
| 1762 | + | /// Read this machine's real panel, the way an install would. | |
| 1763 | + | /// | |
| 1764 | + | /// Ignored by default because it asserts about hardware: it passes on a | |
| 1765 | + | /// laptop and says nothing on a desktop or in a container. Unlike the sway | |
| 1766 | + | /// test below it needs no session, so it runs anywhere with a panel. | |
| 1767 | + | /// | |
| 1768 | + | /// Run on fw13 (Framework 13, 2880x1920, EDID 285x190mm) on 2026-07-30 it | |
| 1769 | + | /// reads 257 PPI and seeds 1.75, which is the second panel the rule has been | |
| 1770 | + | /// put to and the first that is not the one it was anchored on. Neither is | |
| 1771 | + | /// an Alloy install, and the seed has still never been written by a real | |
| 1772 | + | /// installer run. | |
| 1773 | + | #[test] | |
| 1774 | + | #[ignore = "requires a machine with a built-in panel"] | |
| 1775 | + | fn reads_this_machines_real_panel() { | |
| 1776 | + | let panel = detect_panel().expect("a laptop has a built-in panel"); | |
| 1777 | + | println!("{}", config_file(std::slice::from_ref(&panel))); | |
| 1778 | + | assert!(SCALES.contains(&panel.scale), "{}", panel.scale); | |
| 1779 | + | } | |
| 1780 | + | ||
| 1449 | 1781 | /// Read this machine's real outputs. | |
| 1450 | 1782 | /// | |
| 1451 | 1783 | /// Ignored by default: needs a running sway, which no development box here |
| @@ -1120,6 +1120,22 @@ | |||
| 1120 | 1120 | vec![enroll(passphrase), enroll(recovery)] | |
| 1121 | 1121 | } | |
| 1122 | 1122 | ||
| 1123 | + | /// What lands in the new home besides the skeleton. | |
| 1124 | + | /// | |
| 1125 | + | /// Grouped because they are the same kind of thing and go to the same place: | |
| 1126 | + | /// both are optional, both are written under the home, and both have to precede | |
| 1127 | + | /// the recursive chown that makes it the user's. Neither is an answer the wizard | |
| 1128 | + | /// insists on — a key is optional by the minting decision, and a panel is | |
| 1129 | + | /// something the machine either reports or does not. | |
| 1130 | + | #[derive(Debug, Default)] | |
| 1131 | + | struct HomeSeeds<'a> { | |
| 1132 | + | /// The SSH public key to authorize, if one was given. | |
| 1133 | + | pubkey: Option<&'a str>, | |
| 1134 | + | /// The built-in panel, if this machine has one it can describe. See | |
| 1135 | + | /// [`crate::display::detect_panel`]. | |
| 1136 | + | panel: Option<&'a crate::display::Output>, | |
| 1137 | + | } | |
| 1138 | + | ||
| 1123 | 1139 | /// The commands that configure an already-deployed system. | |
| 1124 | 1140 | /// | |
| 1125 | 1141 | /// `root` is the ostree deployment directory, not the mountpoint. See | |
| @@ -1133,7 +1149,7 @@ | |||
| 1133 | 1149 | hostname: &str, | |
| 1134 | 1150 | username: &str, | |
| 1135 | 1151 | password: &str, | |
| 1136 | - | pubkey: Option<&str>, | |
| 1152 | + | seeds: &HomeSeeds<'_>, | |
| 1137 | 1153 | locate_timezone: bool, | |
| 1138 | 1154 | encryption: Option<&Encryption<'_>>, | |
| 1139 | 1155 | root: &str, | |
| @@ -1262,7 +1278,7 @@ | |||
| 1262 | 1278 | // no second place that has to know the numeric ids. Writing the key after it | |
| 1263 | 1279 | // would leave the directory root-owned, and sshd refuses an authorized_keys | |
| 1264 | 1280 | // it does not trust the ownership of, silently. | |
| 1265 | - | if let Some(key) = pubkey { | |
| 1281 | + | if let Some(key) = seeds.pubkey { | |
| 1266 | 1282 | let ssh_dir = format!("{home}/.ssh"); | |
| 1267 | 1283 | let authorized = format!("{ssh_dir}/authorized_keys"); | |
| 1268 | 1284 | stages.extend([ | |
| @@ -1296,6 +1312,34 @@ | |||
| 1296 | 1312 | ]); | |
| 1297 | 1313 | } | |
| 1298 | 1314 | ||
| 1315 | + | // The scale the panel wants, before anyone has logged in to set it. | |
| 1316 | + | // | |
| 1317 | + | // Same placement argument as the key above: the recursive chown below | |
| 1318 | + | // covers whatever is under the home, so writing here costs no second place | |
| 1319 | + | // that has to know the numeric ids. | |
| 1320 | + | // | |
| 1321 | + | // Passed in rather than detected here, because detection reads this | |
| 1322 | + | // machine's sysfs and a plan that changed shape with the panel of whoever | |
| 1323 | + | // ran the tests would be untestable. [`crate::display::detect_panel`] is | |
| 1324 | + | // called once, where the plan is built. | |
| 1325 | + | if let Some(panel) = seeds.panel { | |
| 1326 | + | let file = format!("{home}/{}", crate::display::FILE); | |
| 1327 | + | let dir = file | |
| 1328 | + | .rsplit_once('/') | |
| 1329 | + | .map_or_else(|| home.clone(), |(parent, _)| parent.to_string()); | |
| 1330 | + | stages.extend([ | |
| 1331 | + | Stage::Run(Invocation::new("mkdir").args(["-p", &dir])), | |
| 1332 | + | // `tee` and [`Secret`] for the reason the key above gives: an | |
| 1333 | + | // Invocation is argv with no shell, so stdin is the only way to | |
| 1334 | + | // hand over a whole file, and Secret is the only stdin it takes. | |
| 1335 | + | // Nothing here is secret, and the run screen showing the config it | |
| 1336 | + | // wrote is wanted. | |
| 1337 | + | Stage::Run(Invocation::new("tee").arg(&file).stdin(Secret::new( | |
| 1338 | + | crate::display::config_file(std::slice::from_ref(panel)), | |
| 1339 | + | ))), | |
| 1340 | + | ]); | |
| 1341 | + | } | |
| 1342 | + | ||
| 1299 | 1343 | stages.extend([ | |
| 1300 | 1344 | // First discovery: which uid and gid useradd picked. Reading the file | |
| 1301 | 1345 | // rather than asking getent, because getent answers about this machine. | |
| @@ -1583,6 +1627,11 @@ | |||
| 1583 | 1627 | let password = password.to_string(); | |
| 1584 | 1628 | let pubkey = pubkey.map(str::to_string); | |
| 1585 | 1629 | let encrypt = encryption.is_some(); | |
| 1630 | + | // Read here, once, on the machine being installed. The panel does not | |
| 1631 | + | // change while the install runs, and reading it before the disk is touched | |
| 1632 | + | // means a machine whose EDID cannot be read is one where nothing was seeded | |
| 1633 | + | // rather than one where a stage failed after the deploy. | |
| 1634 | + | let panel = crate::display::detect_panel(); | |
| 1586 | 1635 | ||
| 1587 | 1636 | vec![ | |
| 1588 | 1637 | // --wipe is explicit rather than implied by the confirm the user just | |
| @@ -1712,7 +1761,10 @@ | |||
| 1712 | 1761 | &hostname, | |
| 1713 | 1762 | &username, | |
| 1714 | 1763 | &password, | |
| 1715 | - | pubkey.as_deref(), | |
| 1764 | + | &HomeSeeds { | |
| 1765 | + | pubkey: pubkey.as_deref(), | |
| 1766 | + | panel: panel.as_ref(), | |
| 1767 | + | }, | |
| 1716 | 1768 | locate_timezone, | |
| 1717 | 1769 | encryption.as_ref(), | |
| 1718 | 1770 | deployment, | |
| @@ -4384,7 +4436,7 @@ | |||
| 4384 | 4436 | "workshop", | |
| 4385 | 4437 | "max", | |
| 4386 | 4438 | "hunter2", | |
| 4387 | - | None, | |
| 4439 | + | &HomeSeeds::default(), | |
| 4388 | 4440 | false, | |
| 4389 | 4441 | Some(&encryption), | |
| 4390 | 4442 | DEPLOYMENT, | |
| @@ -4498,11 +4550,19 @@ | |||
| 4498 | 4550 | /// Without the location lookup, which is the default and what almost every | |
| 4499 | 4551 | /// assertion below is about. `configured_locating` is the other one. | |
| 4500 | 4552 | fn configured() -> Vec<String> { | |
| 4501 | - | configure_plan("workshop", "max", "hunter2", None, false, None, DEPLOYMENT) | |
| 4502 | - | .expect("a well-formed deployment path") | |
| 4503 | - | .iter() | |
| 4504 | - | .map(Stage::display) | |
| 4505 | - | .collect() | |
| 4553 | + | configure_plan( | |
| 4554 | + | "workshop", | |
| 4555 | + | "max", | |
| 4556 | + | "hunter2", | |
| 4557 | + | &HomeSeeds::default(), | |
| 4558 | + | false, | |
| 4559 | + | None, | |
| 4560 | + | DEPLOYMENT, | |
| 4561 | + | ) | |
| 4562 | + | .expect("a well-formed deployment path") | |
| 4563 | + | .iter() | |
| 4564 | + | .map(Stage::display) | |
| 4565 | + | .collect() | |
| 4506 | 4566 | } | |
| 4507 | 4567 | ||
| 4508 | 4568 | /// Whether `line` runs `program`, wrapped in a chroot or not. | |
| @@ -4954,12 +5014,19 @@ | |||
| 4954 | 5014 | ||
| 4955 | 5015 | #[test] | |
| 4956 | 5016 | fn a_located_install_asks_and_bounds_the_asking() { | |
| 4957 | - | let shown: Vec<String> = | |
| 4958 | - | configure_plan("workshop", "max", "hunter2", None, true, None, DEPLOYMENT) | |
| 4959 | - | .expect("a well-formed deployment path") | |
| 4960 | - | .iter() | |
| 4961 | - | .map(Stage::display) | |
| 4962 | - | .collect(); | |
| 5017 | + | let shown: Vec<String> = configure_plan( | |
| 5018 | + | "workshop", | |
| 5019 | + | "max", | |
| 5020 | + | "hunter2", | |
| 5021 | + | &HomeSeeds::default(), | |
| 5022 | + | true, | |
| 5023 | + | None, | |
| 5024 | + | DEPLOYMENT, | |
| 5025 | + | ) | |
| 5026 | + | .expect("a well-formed deployment path") | |
| 5027 | + | .iter() | |
| 5028 | + | .map(Stage::display) | |
| 5029 | + | .collect(); | |
| 4963 | 5030 | ||
| 4964 | 5031 | let lookup = shown | |
| 4965 | 5032 | .iter() | |
| @@ -5297,7 +5364,10 @@ | |||
| 5297 | 5364 | "workshop", | |
| 5298 | 5365 | "max", | |
| 5299 | 5366 | "hunter2", | |
| 5300 | - | Some(key), | |
| 5367 | + | &HomeSeeds { | |
| 5368 | + | pubkey: Some(key), | |
| 5369 | + | ..HomeSeeds::default() | |
| 5370 | + | }, | |
| 5301 | 5371 | false, | |
| 5302 | 5372 | None, | |
| 5303 | 5373 | DEPLOYMENT, | |
| @@ -5338,7 +5408,10 @@ | |||
| 5338 | 5408 | "workshop", | |
| 5339 | 5409 | "max", | |
| 5340 | 5410 | "hunter2", | |
| 5341 | - | Some(key), | |
| 5411 | + | &HomeSeeds { | |
| 5412 | + | pubkey: Some(key), | |
| 5413 | + | ..HomeSeeds::default() | |
| 5414 | + | }, | |
| 5342 | 5415 | false, | |
| 5343 | 5416 | None, | |
| 5344 | 5417 | DEPLOYMENT, | |
| @@ -5369,16 +5442,109 @@ | |||
| 5369 | 5442 | // diagnose than an absent one. | |
| 5370 | 5443 | #[test] | |
| 5371 | 5444 | fn no_key_writes_no_ssh_directory() { | |
| 5372 | - | let joined = configure_plan("workshop", "max", "hunter2", None, false, None, DEPLOYMENT) | |
| 5373 | - | .unwrap() | |
| 5374 | - | .iter() | |
| 5375 | - | .map(Stage::display) | |
| 5376 | - | .collect::<Vec<_>>() | |
| 5377 | - | .join("\n"); | |
| 5445 | + | let joined = configure_plan( | |
| 5446 | + | "workshop", | |
| 5447 | + | "max", | |
| 5448 | + | "hunter2", | |
| 5449 | + | &HomeSeeds::default(), | |
| 5450 | + | false, | |
| 5451 | + | None, | |
| 5452 | + | DEPLOYMENT, | |
| 5453 | + | ) | |
| 5454 | + | .unwrap() | |
| 5455 | + | .iter() | |
| 5456 | + | .map(Stage::display) | |
| 5457 | + | .collect::<Vec<_>>() | |
| 5458 | + | .join("\n"); | |
| 5378 | 5459 | assert!(!joined.contains("authorized_keys"), "{joined}"); | |
| 5379 | 5460 | assert!(!joined.contains(".ssh"), "{joined}"); | |
| 5380 | 5461 | } | |
| 5381 | 5462 | ||
| 5463 | + | /// A panel as [`crate::display::detect_panel`] would return one. | |
| 5464 | + | fn panel() -> crate::display::Output { | |
| 5465 | + | crate::display::Output { | |
| 5466 | + | name: "eDP-1".into(), | |
| 5467 | + | make: String::new(), | |
| 5468 | + | model: String::new(), | |
| 5469 | + | serial: String::new(), | |
| 5470 | + | active: true, | |
| 5471 | + | dpms: true, | |
| 5472 | + | focused: false, | |
| 5473 | + | rect: crate::display::Rectangle::default(), | |
| 5474 | + | scale: 1.25, | |
| 5475 | + | transform: "normal".into(), | |
| 5476 | + | current_mode: None, | |
| 5477 | + | modes: Vec::new(), | |
| 5478 | + | } | |
| 5479 | + | } | |
| 5480 | + | ||
| 5481 | + | fn plan_with_panel(panel: Option<&crate::display::Output>) -> Vec<String> { | |
| 5482 | + | configure_plan( | |
| 5483 | + | "workshop", | |
| 5484 | + | "max", | |
| 5485 | + | "hunter2", | |
| 5486 | + | &HomeSeeds { | |
| 5487 | + | panel, | |
| 5488 | + | ..HomeSeeds::default() | |
| 5489 | + | }, | |
| 5490 | + | false, | |
| 5491 | + | None, | |
| 5492 | + | DEPLOYMENT, | |
| 5493 | + | ) | |
| 5494 | + | .expect("a well-formed deployment path") | |
| 5495 | + | .iter() | |
| 5496 | + | .map(Stage::display) | |
| 5497 | + | .collect() | |
| 5498 | + | } | |
| 5499 | + | ||
| 5500 | + | // The seed exists so a first boot renders at the scale the panel wants | |
| 5501 | + | // rather than at 1.0. Its directory has to be made first: the skel copy does | |
| 5502 | + | // not create one, since `alloy display` is what owns this path. | |
| 5503 | + | #[test] | |
| 5504 | + | fn a_detected_panel_seeds_the_per_user_display_config() { | |
| 5505 | + | let shown = plan_with_panel(Some(&panel())); | |
| 5506 | + | let joined = shown.join("\n"); | |
| 5507 | + | assert!(joined.contains(crate::display::FILE), "{joined}"); | |
| 5508 | + | ||
| 5509 | + | let made = shown | |
| 5510 | + | .iter() | |
| 5511 | + | .position(|line| line.contains("mkdir") && line.contains("sway/config.d")) | |
| 5512 | + | .expect("the plan makes the directory"); | |
| 5513 | + | let wrote = shown | |
| 5514 | + | .iter() | |
| 5515 | + | .position(|line| line.contains(crate::display::FILE)) | |
| 5516 | + | .expect("the plan writes the file"); | |
| 5517 | + | assert!(made < wrote, "{shown:#?}"); | |
| 5518 | + | } | |
| 5519 | + | ||
| 5520 | + | // Same argument as the ssh key: the recursive chown is what makes the home | |
| 5521 | + | // the user's, and a file written after it stays root-owned. Here that would | |
| 5522 | + | // cost the user a scale they cannot change from the console, since | |
| 5523 | + | // `alloy display` rewrites this exact file. | |
| 5524 | + | #[test] | |
| 5525 | + | fn the_seed_is_written_before_the_recursive_chown_that_owns_it() { | |
| 5526 | + | let shown = plan_with_panel(Some(&panel())); | |
| 5527 | + | let wrote = shown | |
| 5528 | + | .iter() | |
| 5529 | + | .position(|line| line.contains(crate::display::FILE)) | |
| 5530 | + | .expect("the plan writes the file"); | |
| 5531 | + | let discovers = shown | |
| 5532 | + | .iter() | |
| 5533 | + | .position(|line| line.contains("etc/passwd")) | |
| 5534 | + | .expect("the plan discovers the numeric ids"); | |
| 5535 | + | assert!(wrote < discovers, "{shown:#?}"); | |
| 5536 | + | } | |
| 5537 | + | ||
| 5538 | + | // A machine whose panel cannot be read seeds nothing at all. An empty or | |
| 5539 | + | // guessed file would be a scale nobody chose, in the one file the console | |
| 5540 | + | // regenerates whole. | |
| 5541 | + | #[test] | |
| 5542 | + | fn no_detected_panel_seeds_nothing() { | |
| 5543 | + | let joined = plan_with_panel(None).join("\n"); | |
| 5544 | + | assert!(!joined.contains("50-display.conf"), "{joined}"); | |
| 5545 | + | assert!(!joined.contains("sway"), "{joined}"); | |
| 5546 | + | } | |
| 5547 | + | ||
| 5382 | 5548 | // A field left as whitespace is a field nobody filled in, and it must not | |
| 5383 | 5549 | // become a blank line in authorized_keys. | |
| 5384 | 5550 | #[test] |
| @@ -1,0 +1,18 @@ | |||
| 1 | + | # System-wide sway drop-ins. | |
| 2 | + | # | |
| 3 | + | # The shipped config includes this directory and then | |
| 4 | + | # ~/.config/sway/config.d/*, so anything set here is the machine's default and | |
| 5 | + | # anything a user sets wins over it: sway merges every stanza that matches an | |
| 6 | + | # output, the later one taking a property both name. | |
| 7 | + | # | |
| 8 | + | # This file carries no directives. It is here so the include has something to | |
| 9 | + | # match — sway expands the glob with wordexp, which passes an unmatched pattern | |
| 10 | + | # through unchanged, leaving sway to log a failed open of a file literally named | |
| 11 | + | # `*` — and so the directory survives in git, which does not track empty ones. | |
| 12 | + | # | |
| 13 | + | # It used to carry 10-fw12.conf, `output eDP-1 scale 1.25`: the FW12's scale, | |
| 14 | + | # matched by a connector name every laptop panel uses, shipped to every machine | |
| 15 | + | # because nothing seeded the per-user file on a fresh install. The installer now | |
| 16 | + | # does, from the panel's own EDID (crates/alloy/src/display.rs, `detect_panel`), | |
| 17 | + | # so the machine's scale is written where its owner can change it rather than | |
| 18 | + | # into a root-owned file naming one laptop. |
| @@ -1,26 +1,0 @@ | |||
| 1 | - | # Framework Laptop 12 display tuning. | |
| 2 | - | # | |
| 3 | - | # 12.2" 1920x1200 (16:10), ~185 PPI. 1.0x puts glyphs too small at | |
| 4 | - | # arm's length and 1.5x wastes usable columns, so the panel sits in the | |
| 5 | - | # awkward middle that fractional scaling exists for. See | |
| 6 | - | # docs/HARDWARE-FW12.md#display for the full reasoning. | |
| 7 | - | # | |
| 8 | - | # SCOPE: this matches eDP-1, which is the internal panel on any laptop, | |
| 9 | - | # not just the FW12. Alloy has one hardware target today so shipping it | |
| 10 | - | # active is the pragmatic call. | |
| 11 | - | # | |
| 12 | - | # It is no longer the last word, which is what the old version of this | |
| 13 | - | # comment asked for. `alloy display` writes the same directive to | |
| 14 | - | # ~/.config/sway/config.d/50-display.conf, and the shipped sway config | |
| 15 | - | # includes that directory AFTER this one. sway merges every stanza that | |
| 16 | - | # matches an output, the later one winning on a property both name, so a | |
| 17 | - | # scale set in the console overrides this file without touching it. That | |
| 18 | - | # is the recovery path on hardware where 1.25x is wrong: open the console | |
| 19 | - | # and press `s`, rather than editing a root-owned file. | |
| 20 | - | # | |
| 21 | - | # Still shipped because nothing seeds the per-user file on a fresh install. | |
| 22 | - | # Deleting this today would boot the one validated machine at 1.0x. Full | |
| 23 | - | # retirement needs the installer to write a 50-display.conf from the panel | |
| 24 | - | # it detected, which is not built. | |
| 25 | - | ||
| 26 | - | output eDP-1 scale 1.25 |