git.lucas.co / cce-compositor
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 }