Skip to main content

max / alloy

9.2 KB · 220 lines History Blame Raw
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;
220