Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
src/server/backdrop.rs (18.6K)
1 //! What is behind a status segment — computed, not sampled.
2 //!
3 //! The bar is a Wayland client: it draws into its own buffer and can never
4 //! see what it is composited over, so a translucent module box leaves its
5 //! text at the mercy of whatever the desktop happens to be showing there.
6 //! This module measures that backdrop compositor-side and the status socket
7 //! pushes the answer to each segment (`backdrop` topic), which is the only
8 //! way the bar can adapt its own contrast.
9 //!
10 //! The measurement is **geometry, not pixels**. The desktop background is
11 //! drawn by the compositor itself from a declarative spec
12 //! ([`crate::policy::background::grid_frame`]), so what sits under a segment
13 //! is known exactly: the fraction of its rect falling on a grid cell versus
14 //! on the gap between cells. That makes this a few rect intersections on the
15 //! CPU rather than a GPU readback — no pipeline stall, no frame-latency
16 //! feedback loop from sampling a frame the bar is already part of, and an
17 //! exact answer instead of a sampled one.
18 //!
19 //! Windows are the exception. The reserved strip keeps *tiled* windows out
20 //! from under the bar (`arrange.rs` shrinks the usable box by `bar_height`),
21 //! but a floating or fullscreen window — or a panned camera — can still slide
22 //! one beneath a segment, and a client's pixels are not knowable here. Any
23 //! such overlap reports maximum spread: "unknown, assume the worst", which
24 //! the bar answers with its outline treatment rather than a guess.
25
26 use crate::policy::api::Rgba;
27 use crate::policy::background::GridFrame;
28
29 /// One segment's backdrop, quantized to 0–100.
30 ///
31 /// Quantized for two reasons: [`crate::status_server::StatusUpdate`] derives
32 /// `Eq` and its equality IS the resend gate, so a float would both break the
33 /// derive and defeat the gate — sub-percent wobble during a camera pan would
34 /// push a line every frame to every segment.
35 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 pub struct BackdropSample {
37 /// WCAG relative luminance of the backdrop, 0 (black) – 100 (white).
38 pub luma: u8,
39 /// How much the backdrop VARIES across the segment, 0 (uniform) – 100.
40 /// High spread means no single text color works over the whole run and
41 /// an outline is the only honest answer; it is also what an unknown
42 /// backdrop (a window in the way) reports.
43 pub spread: u8,
44 }
45
46 /// An axis-aligned rect in layout px — the same space `GridFrame::tree_pos`
47 /// and a window's `box_geom` are expressed in.
48 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
49 pub struct Rect {
50 pub x: i32,
51 pub y: i32,
52 pub w: i32,
53 pub h: i32,
54 }
55
56 impl Rect {
57 fn right(&self) -> i32 {
58 self.x + self.w
59 }
60
61 fn bottom(&self) -> i32 {
62 self.y + self.h
63 }
64
65 /// Overlap area with `other`, in px².
66 pub fn intersect_area(&self, other: &Rect) -> i64 {
67 let w = (self.right().min(other.right()) - self.x.max(other.x)).max(0) as i64;
68 let h = (self.bottom().min(other.bottom()) - self.y.max(other.y)).max(0) as i64;
69 w * h
70 }
71
72 pub fn intersects(&self, other: &Rect) -> bool {
73 self.intersect_area(other) > 0
74 }
75
76 /// The overlapping rect, or None when they do not meet.
77 pub fn intersection(&self, other: &Rect) -> Option<Rect> {
78 let x = self.x.max(other.x);
79 let y = self.y.max(other.y);
80 let w = self.right().min(other.right()) - x;
81 let h = self.bottom().min(other.bottom()) - y;
82 (w > 0 && h > 0).then_some(Rect { x, y, w, h })
83 }
84 }
85
86 /// One sRGB channel to linear light (the WCAG transfer function).
87 fn to_linear(c: f32) -> f32 {
88 let c = c.clamp(0.0, 1.0);
89 if c <= 0.04045 {
90 c / 12.92
91 } else {
92 ((c + 0.055) / 1.055).powf(2.4)
93 }
94 }
95
96 /// WCAG relative luminance, 0–1. Perceptual weighting, not a channel mean:
97 /// the eye reads green as most of the brightness, and a naive average would
98 /// call the blue-gray gap color and a mid-gray equally bright.
99 fn relative_luminance(rgb: [f32; 3]) -> f32 {
100 0.2126 * to_linear(rgb[0]) + 0.7152 * to_linear(rgb[1]) + 0.0722 * to_linear(rgb[2])
101 }
102
103 /// `over` composited onto `under`, both PREMULTIPLIED (which is how
104 /// [`Rgba`] carries grid colors — see `GridSpec::gap_color`). Returns
105 /// straight rgb, since that is all luminance needs.
106 fn over(over_c: Rgba, under: [f32; 3]) -> [f32; 3] {
107 let a = over_c.0[3].clamp(0.0, 1.0);
108 [
109 over_c.0[0] + under[0] * (1.0 - a),
110 over_c.0[1] + under[1] * (1.0 - a),
111 over_c.0[2] + under[2] * (1.0 - a),
112 ]
113 }
114
115 /// The fraction of `rect` covered by grid cells, 0–1.
116 ///
117 /// Only the cell columns/rows that can reach `rect` are visited — derived
118 /// from the period rather than by scanning the whole lattice, which at a far
119 /// zoom-out is thousands of cells that a 27px-tall segment cannot touch.
120 ///
121 /// Cell corner radius and the fade inset are deliberately ignored: both
122 /// soften a cell's edge by a few px, which moves the coverage fraction far
123 /// less than the quantization to whole percent does.
124 fn cell_coverage(frame: &GridFrame, rect: &Rect) -> f32 {
125 let Some(cells) = &frame.cells else {
126 return 0.0;
127 };
128 let Some((tx, ty)) = frame.tree_pos else {
129 return 0.0;
130 };
131 let area = (rect.w as i64) * (rect.h as i64);
132 if area <= 0 {
133 return 0.0;
134 }
135 let px = frame.period_px_exact_x;
136 let py = frame.period_px_exact_y;
137 if !(px > 0.5) || !(py > 0.5) {
138 return 0.0;
139 }
140
141 // Tree-local span the rect can touch, widened by one cell so a cell
142 // whose origin sits before the rect but whose body reaches into it is
143 // still visited.
144 let lx0 = (rect.x - tx) as f64;
145 let lx1 = (rect.right() - tx) as f64;
146 let ly0 = (rect.y - ty) as f64;
147 let ly1 = (rect.bottom() - ty) as f64;
148 let col0 = (((lx0 - cells.cell_w_px as f64) / px).floor() as i64).clamp(0, cells.cols as i64);
149 let col1 = ((lx1 / px).ceil() as i64).clamp(0, cells.cols as i64);
150 let row0 = (((ly0 - cells.cell_h_px as f64) / py).floor() as i64).clamp(0, cells.rows as i64);
151 let row1 = ((ly1 / py).ceil() as i64).clamp(0, cells.rows as i64);
152
153 let mut covered: i64 = 0;
154 for row in row0..=row1 {
155 let cy = ty + (row as f64 * py).round() as i32;
156 for col in col0..=col1 {
157 let cx = tx + (col as f64 * px).round() as i32;
158 let cell = Rect { x: cx, y: cy, w: cells.cell_w_px, h: cells.cell_h_px };
159 covered += rect.intersect_area(&cell);
160 }
161 }
162 (covered as f32 / area as f32).clamp(0.0, 1.0)
163 }
164
165 /// Measure a block of RGBA pixels — the window-content path, where the
166 /// backdrop is not derivable geometry and has to be looked at.
167 ///
168 /// Spread comes from the 10th and 90th luminance percentiles rather than the
169 /// full range, so one stray highlight (a cursor, an icon, an anti-aliased
170 /// edge) does not report a whole terminal as high-variance. It is the same
171 /// quantity the grid path computes analytically: how far apart the light and
172 /// dark parts of this patch are.
173 pub fn measure_pixels(rgba: &[u8]) -> Option<BackdropSample> {
174 let n = rgba.len() / 4;
175 if n == 0 {
176 return None;
177 }
178 let mut lumas: Vec<f32> = Vec::with_capacity(n);
179 let mut sum = 0.0f32;
180 for px in rgba.chunks_exact(4) {
181 let l = relative_luminance([px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0]);
182 sum += l;
183 lumas.push(l);
184 }
185 lumas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
186 let p10 = lumas[n / 10];
187 let p90 = lumas[n - 1 - n / 10];
188 Some(BackdropSample {
189 luma: ((sum / n as f32).clamp(0.0, 1.0) * 100.0).round() as u8,
190 spread: ((p90 - p10).clamp(0.0, 1.0) * 100.0).round() as u8,
191 })
192 }
193
194 /// Fold a window-content sample covering `coverage` (0-1) of a segment into
195 /// the desktop sample for the rest of it.
196 ///
197 /// The third spread term is the one that is easy to miss: two patches can each
198 /// be perfectly uniform and still leave the text straddling a hard edge
199 /// between them — a black terminal ending halfway across a segment that sits
200 /// on a light gap. That boundary is exactly as unreadable as a busy texture,
201 /// and only the difference between the two means shows it.
202 pub fn blend(desktop: BackdropSample, window: BackdropSample, coverage: f32) -> BackdropSample {
203 let c = coverage.clamp(0.0, 1.0);
204 let dl = desktop.luma as f32 / 100.0;
205 let wl = window.luma as f32 / 100.0;
206 let luma = c * wl + (1.0 - c) * dl;
207 let edge = 2.0 * c.min(1.0 - c) * (wl - dl).abs();
208 let spread = (desktop.spread as f32 / 100.0)
209 .max(window.spread as f32 / 100.0)
210 .max(edge);
211 BackdropSample {
212 luma: (luma.clamp(0.0, 1.0) * 100.0).round() as u8,
213 spread: (spread.clamp(0.0, 1.0) * 100.0).round() as u8,
214 }
215 }
216
217 /// What a segment reports when its backdrop cannot be determined at all —
218 /// mid luminance, full spread, which drives the outline.
219 pub const UNKNOWN: BackdropSample = BackdropSample { luma: 50, spread: 100 };
220
221 /// Measure the backdrop under `rect`.
222 ///
223 /// `base` is the opaque desktop background color the grid is drawn onto (the
224 /// output's background rect), so a gap or cell color carrying alpha resolves
225 /// against the same thing the screen shows.
226 ///
227 /// `occluded` says a window overlaps the rect; its content is not knowable
228 /// here, so the sample degrades to "unknown" — mid luminance and full
229 /// spread — rather than confidently reporting the desktop that is no longer
230 /// what the text sits on.
231 pub fn measure(frame: &GridFrame, spec_gap: Rgba, base: [f32; 3], rect: Rect, occluded: bool) -> BackdropSample {
232 if occluded {
233 return UNKNOWN;
234 }
235
236 let gap_rgb = over(spec_gap, base);
237 let gap_luma = relative_luminance(gap_rgb);
238
239 let (cell_luma, f) = match &frame.cells {
240 // The cell color carries the density fade in its alpha, so a
241 // faded-out lattice correctly resolves toward the gap color.
242 Some(cells) => (relative_luminance(over(cells.color, gap_rgb)), cell_coverage(frame, &rect)),
243 None => (gap_luma, 0.0),
244 };
245
246 let luma = f * cell_luma + (1.0 - f) * gap_luma;
247
248 // Spread is the area split WEIGHTED by how different the two colors
249 // actually are: a rect straddling cell and gap is only a problem for the
250 // text when the two read as different brightnesses. A lattice drawn in
251 // two similar tones is uniform as far as legibility is concerned, however
252 // the area happens to divide.
253 let split = 2.0 * f.min(1.0 - f);
254 let spread = split * (cell_luma - gap_luma).abs();
255
256 BackdropSample {
257 luma: (luma.clamp(0.0, 1.0) * 100.0).round() as u8,
258 spread: (spread.clamp(0.0, 1.0) * 100.0).round() as u8,
259 }
260 }
261
262 #[cfg(test)]
263 mod tests {
264 use super::*;
265 use crate::policy::api::{GridFadeMode, GridSpec};
266 use crate::policy::background::{grid_frame, GridCells};
267 use crate::policy::camera::Camera;
268
269 const BLACK: Rgba = Rgba([0.0, 0.0, 0.0, 1.0]);
270 const WHITE: Rgba = Rgba([1.0, 1.0, 1.0, 1.0]);
271
272 fn frame_with(cells: Option<GridCells>, period: f64) -> GridFrame {
273 GridFrame {
274 tree_pos: Some((0, 0)),
275 period_px_x: period as i32,
276 period_px_y: period as i32,
277 period_px_exact_x: period,
278 period_px_exact_y: period,
279 backdrop_w: 1000,
280 backdrop_h: 1000,
281 cells,
282 first_col: 0,
283 first_row: 0,
284 }
285 }
286
287 fn cells(w: i32, h: i32, color: Rgba) -> GridCells {
288 GridCells {
289 cell_w_px: w,
290 cell_h_px: h,
291 cols: 8,
292 rows: 8,
293 color,
294 corner_radius_px: 0,
295 fade_inset_px: 0,
296 }
297 }
298
299 #[test]
300 fn a_rect_wholly_on_a_cell_is_uniform_at_the_cell_color() {
301 // The legibility case that motivated all this: a segment sitting
302 // entirely on a black cell must report black AND report it
303 // confidently, or the bar has no reason to change anything.
304 let frame = frame_with(Some(cells(500, 500, BLACK)), 516.0);
305 let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], Rect { x: 100, y: 100, w: 200, h: 27 }, false);
306 assert_eq!(s.luma, 0);
307 assert_eq!(s.spread, 0);
308 }
309
310 #[test]
311 fn a_rect_wholly_in_the_gap_is_uniform_at_the_gap_color() {
312 let frame = frame_with(Some(cells(100, 100, BLACK)), 200.0);
313 // x 120..180 falls between the cell at 0..100 and the one at 200.
314 let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], Rect { x: 120, y: 120, w: 60, h: 27 }, false);
315 assert_eq!(s.luma, 100);
316 assert_eq!(s.spread, 0);
317 }
318
319 #[test]
320 fn straddling_a_cell_edge_reports_spread() {
321 // Half on a black cell, half on a white gap: no single text color
322 // works, which is exactly what a high spread tells the bar.
323 let frame = frame_with(Some(cells(100, 100, BLACK)), 200.0);
324 let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], Rect { x: 50, y: 20, w: 100, h: 27 }, false);
325 assert!(s.spread > 90, "spread was {}", s.spread);
326 assert!((40..=60).contains(&s.luma), "luma was {}", s.luma);
327 }
328
329 #[test]
330 fn a_two_tone_lattice_of_similar_colors_is_not_spread() {
331 // Same 50/50 area split as above, but the two tones are close, so
332 // there is no legibility problem to report.
333 let near_white = Rgba([0.97, 0.97, 0.97, 1.0]);
334 let frame = frame_with(Some(cells(100, 100, near_white)), 200.0);
335 let rect = Rect { x: 50, y: 20, w: 100, h: 27 };
336 let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], rect, false);
337 // The same area split black-on-white reports ~100 (above), so the
338 // weighting — not the geometry — is what separates these two.
339 assert!(s.spread < 10, "spread was {}", s.spread);
340 }
341
342 #[test]
343 fn an_occluding_window_reports_unknown_rather_than_the_desktop() {
344 let frame = frame_with(Some(cells(500, 500, BLACK)), 516.0);
345 let rect = Rect { x: 100, y: 100, w: 200, h: 27 };
346 let clear = measure(&frame, WHITE, [1.0, 1.0, 1.0], rect, false);
347 let hidden = measure(&frame, WHITE, [1.0, 1.0, 1.0], rect, true);
348 assert_eq!(clear.spread, 0);
349 assert_eq!(hidden.spread, 100);
350 assert_ne!(clear.luma, hidden.luma);
351 }
352
353 #[test]
354 fn no_cells_is_the_flat_gap_color() {
355 let frame = frame_with(None, 200.0);
356 let s = measure(&frame, BLACK, [0.0, 0.0, 0.0], Rect { x: 0, y: 0, w: 100, h: 27 }, false);
357 assert_eq!(s.luma, 0);
358 assert_eq!(s.spread, 0);
359 }
360
361 #[test]
362 fn luminance_is_perceptual_not_a_channel_mean() {
363 // Pure green and pure blue have the same channel mean; the eye does
364 // not see them as remotely the same brightness.
365 let green = relative_luminance([0.0, 1.0, 0.0]);
366 let blue = relative_luminance([0.0, 0.0, 1.0]);
367 assert!(green > blue * 5.0, "green {} blue {}", green, blue);
368 }
369
370 #[test]
371 fn coverage_visits_only_the_cells_that_can_reach_the_rect() {
372 // A far zoom-out puts thousands of cells on screen; a bar segment
373 // touches a handful. The result must still be right when the rect
374 // sits deep inside the lattice rather than at its origin.
375 let frame = frame_with(Some(cells(10, 10, BLACK)), 20.0);
376 let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], Rect { x: 1000, y: 1000, w: 40, h: 27 }, false);
377 // Beyond cols/rows (8), so no cell reaches it — pure gap.
378 assert_eq!(s.luma, 100);
379 }
380
381 fn solid(luma_byte: u8, n: usize) -> Vec<u8> {
382 std::iter::repeat([luma_byte, luma_byte, luma_byte, 255]).take(n).flatten().collect()
383 }
384
385 #[test]
386 fn a_flat_patch_of_pixels_has_no_spread() {
387 let s = measure_pixels(&solid(0, 1000)).unwrap();
388 assert_eq!(s.luma, 0);
389 assert_eq!(s.spread, 0);
390 let s = measure_pixels(&solid(255, 1000)).unwrap();
391 assert_eq!(s.luma, 100);
392 assert_eq!(s.spread, 0);
393 }
394
395 #[test]
396 fn half_black_half_white_pixels_report_full_spread() {
397 let mut px = solid(0, 500);
398 px.extend(solid(255, 500));
399 let s = measure_pixels(&px).unwrap();
400 assert!(s.spread > 95, "spread was {}", s.spread);
401 assert!((45..=55).contains(&s.luma), "luma was {}", s.luma);
402 }
403
404 #[test]
405 fn a_lone_highlight_does_not_read_as_a_busy_backdrop() {
406 // A cursor or an icon on an otherwise flat terminal. The percentile
407 // spread is what keeps a handful of bright pixels from pinning the
408 // outline on over content the text reads fine against.
409 let mut px = solid(0, 990);
410 px.extend(solid(255, 10));
411 let s = measure_pixels(&px).unwrap();
412 assert_eq!(s.spread, 0, "spread was {}", s.spread);
413 }
414
415 #[test]
416 fn measure_pixels_rejects_an_empty_read() {
417 assert!(measure_pixels(&[]).is_none());
418 }
419
420 #[test]
421 fn blending_a_window_over_part_of_a_segment_moves_the_luma() {
422 let desktop = BackdropSample { luma: 0, spread: 0 };
423 let window = BackdropSample { luma: 100, spread: 0 };
424 assert_eq!(blend(desktop, window, 0.0).luma, 0);
425 assert_eq!(blend(desktop, window, 1.0).luma, 100);
426 assert_eq!(blend(desktop, window, 0.5).luma, 50);
427 }
428
429 #[test]
430 fn a_hard_edge_between_two_flat_patches_is_itself_spread() {
431 // A black terminal ending halfway across a segment that sits on a
432 // light gap: both halves uniform, the text across the seam is not.
433 let desktop = BackdropSample { luma: 100, spread: 0 };
434 let window = BackdropSample { luma: 0, spread: 0 };
435 assert_eq!(blend(desktop, window, 0.5).spread, 100);
436 // ...and at the edges of coverage there is no seam to worry about.
437 assert_eq!(blend(desktop, window, 0.02).spread, 4);
438 }
439
440 #[test]
441 fn blending_keeps_the_worse_of_the_two_spreads() {
442 let desktop = BackdropSample { luma: 50, spread: 10 };
443 let window = BackdropSample { luma: 50, spread: 80 };
444 assert_eq!(blend(desktop, window, 0.5).spread, 80);
445 }
446
447 #[test]
448 fn a_real_grid_frame_measures_without_panicking() {
449 // Exercises the real grid_frame output rather than a hand-built one,
450 // so a field-meaning drift in the policy crate surfaces here.
451 let spec = GridSpec {
452 gap_color: Rgba([0.686, 0.796, 0.867, 1.0]),
453 cell_color: BLACK,
454 cell_w: 512.0,
455 cell_h: 512.0,
456 gap_width: 16.0,
457 cell_corner_radius: 0,
458 cell_fade_inset: 4,
459 fade_mode: GridFadeMode::Quadratic,
460 };
461 let cam = Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 };
462 let frame = grid_frame(&spec, cam, 1920, 1080, 0, 0);
463 let s = measure(&frame, spec.gap_color, [0.0, 0.0, 0.0], Rect { x: 40, y: 0, w: 200, h: 27 }, false);
464 assert!(s.luma <= 100);
465 assert!(s.spread <= 100);
466 }
467 }