Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat: the backdrop topic — tell each status segment what it sits over
A Wayland client cannot see what it is composited over, so the bar's
translucent module boxes have always left their text at the mercy of
whatever the desktop shows through them; its two contrast treatments
(text_relief, text_halo) are unconditional because a blind client has no
other option.
This side can see. `backdrop <app_id>` is a new status-socket topic — the
first per-subscriber one, since the two ends of a bar sit over different
things — reporting `<luma> <spread>`, 0-100 each.
The measurement is geometry, not a readback. The desktop background is
drawn here from a declarative spec, so what sits under a segment is known
exactly: backdrop.rs intersects the segment rect with the grid lattice and
weights the cell/gap split by how far apart the two colors actually read.
That means no GPU stall, no feedback loop from sampling a frame the bar is
already part of, and an exact answer rather than a sampled one. Spread
carries the part a mean cannot: a segment half on a black cell and half on
a light gap averages to a comfortable mid-gray while its text is invisible
over one half.
Windows are the exception — the reserved strip keeps tiled windows off the
bar, but a fullscreen or floating one can still slide under, and its
pixels are not knowable from here. Any overlap reports maximum spread:
unknown, assume the worst.
Measured in the output's render pass, where the frame's grid geometry
already is, and gated by update_status's existing equality check — the
0-100 quantization is what makes that gate stick, so a still desktop
pushes nothing however many frames go by.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 17 ++-
WORKSPACE.md | 9 +-
src/lib.rs | 2 +
src/server/backdrop.rs | 336 +++++++++++++++++++++++++++++++++++++++++++
src/server/output.rs | 141 ++++++++++++++++++
src/server/server.rs | 4 +
src/server/status_server.rs | 43 +++++-
src/server/window_manager.rs | 6 +
8 files changed, 548 insertions(+), 10 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8ff76e3..9886850 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -292,9 +292,20 @@ Persistent window state is saved to **`~/.local/state/cce/state.json`**
request/reply over a Unix socket. `ccectl` / `cce_ctl.rs` is the client.
- **Status socket** `/tmp/cce-status-{WAYLAND_DISPLAY}.sock` (`status_server.rs`): runs
on its own thread; a client sends one subscription line (`layout`, `title`,
- `modifiers`, or `dismiss`) and receives text lines on every change. This feeds the
- status bar (`cce-status-interface`). The main loop pushes updates through a
- `StatusSender` mpsc handle.
+ `modifiers`, `dismiss`, or `backdrop <app_id>`) and receives text lines on every
+ change. This feeds the status bar (`cce-status-interface`). The main loop pushes
+ updates through a `StatusSender` mpsc handle.
+
+ **`backdrop` is the one per-subscriber topic** — it names the asking segment,
+ because the whole point is that the two ends of a bar sit over different things.
+ Lines are `<luma> <spread>` (0-100 each) or `unknown`. It answers a question a
+ Wayland client cannot: what its translucent module boxes are composited *over*,
+ so it can raise its text contrast to match. The measurement is geometry, not a
+ readback — the desktop background is drawn from a declarative spec, so
+ `backdrop.rs` computes cell-vs-gap coverage under each segment rect on the CPU
+ (`Output::measure_status_backdrops`, per frame, gated by `update_status`'s
+ equality check). A window overlapping a segment reports maximum spread, since
+ its pixels are not knowable from here.
## Conventions
diff --git a/WORKSPACE.md b/WORKSPACE.md
index 28294d5..1a55608 100644
--- a/WORKSPACE.md
+++ b/WORKSPACE.md
@@ -257,9 +257,12 @@ Clients and compositor communicate over Unix sockets keyed by `$WAYLAND_DISPLAY`
- **Control**: `/tmp/cce-{WAYLAND_DISPLAY}.sock` — line-oriented request/reply. The
`ccectl` binary (in `cce-compositor/`) is the CLI client; run `ccectl` with no args for the
command list.
-- **Status**: `/tmp/cce-status-{WAYLAND_DISPLAY}.sock` — subscribe to `viewport` /
- `layout` / `title` / `modifiers` and receive push updates. This feeds
- `cce-status-interface` (the status bar).
+- **Status**: `/tmp/cce-status-{WAYLAND_DISPLAY}.sock` — subscribe to `layout` /
+ `title` / `modifiers` / `dismiss` / `backdrop <app_id>` and receive push updates.
+ This feeds `cce-status-interface` (the status bar). `backdrop` is the odd one
+ out: it takes the asking segment's app_id and reports what that segment is
+ composited over, which is the one thing a Wayland client can never see for
+ itself. (The `viewport` topic went with the viewport-tag feature.)
## Repo hygiene
diff --git a/src/lib.rs b/src/lib.rs
index 057af15..21bbb61 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -27,6 +27,8 @@ pub mod text;
// `crate::policy::…` / `crate::tiling` / `crate::slotmap` paths working.
pub use cce_window_manager as policy;
pub use cce_window_manager::tiling;
+#[path = "server/backdrop.rs"]
+pub mod backdrop;
#[path = "server/config.rs"]
pub mod config;
#[path = "server/ipc_server.rs"]
diff --git a/src/server/backdrop.rs b/src/server/backdrop.rs
new file mode 100644
index 0000000..e14aec9
--- /dev/null
+++ b/src/server/backdrop.rs
@@ -0,0 +1,336 @@
+//! What is behind a status segment — computed, not sampled.
+//!
+//! The bar is a Wayland client: it draws into its own buffer and can never
+//! see what it is composited over, so a translucent module box leaves its
+//! text at the mercy of whatever the desktop happens to be showing there.
+//! This module measures that backdrop compositor-side and the status socket
+//! pushes the answer to each segment (`backdrop` topic), which is the only
+//! way the bar can adapt its own contrast.
+//!
+//! The measurement is **geometry, not pixels**. The desktop background is
+//! drawn by the compositor itself from a declarative spec
+//! ([`crate::policy::background::grid_frame`]), so what sits under a segment
+//! is known exactly: the fraction of its rect falling on a grid cell versus
+//! on the gap between cells. That makes this a few rect intersections on the
+//! CPU rather than a GPU readback — no pipeline stall, no frame-latency
+//! feedback loop from sampling a frame the bar is already part of, and an
+//! exact answer instead of a sampled one.
+//!
+//! Windows are the exception. The reserved strip keeps *tiled* windows out
+//! from under the bar (`arrange.rs` shrinks the usable box by `bar_height`),
+//! but a floating or fullscreen window — or a panned camera — can still slide
+//! one beneath a segment, and a client's pixels are not knowable here. Any
+//! such overlap reports maximum spread: "unknown, assume the worst", which
+//! the bar answers with its outline treatment rather than a guess.
+
+use crate::policy::api::Rgba;
+use crate::policy::background::GridFrame;
+
+/// One segment's backdrop, quantized to 0–100.
+///
+/// Quantized for two reasons: [`crate::status_server::StatusUpdate`] derives
+/// `Eq` and its equality IS the resend gate, so a float would both break the
+/// derive and defeat the gate — sub-percent wobble during a camera pan would
+/// push a line every frame to every segment.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct BackdropSample {
+ /// WCAG relative luminance of the backdrop, 0 (black) – 100 (white).
+ pub luma: u8,
+ /// How much the backdrop VARIES across the segment, 0 (uniform) – 100.
+ /// High spread means no single text color works over the whole run and
+ /// an outline is the only honest answer; it is also what an unknown
+ /// backdrop (a window in the way) reports.
+ pub spread: u8,
+}
+
+/// An axis-aligned rect in layout px — the same space `GridFrame::tree_pos`
+/// and a window's `box_geom` are expressed in.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Rect {
+ pub x: i32,
+ pub y: i32,
+ pub w: i32,
+ pub h: i32,
+}
+
+impl Rect {
+ fn right(&self) -> i32 {
+ self.x + self.w
+ }
+
+ fn bottom(&self) -> i32 {
+ self.y + self.h
+ }
+
+ /// Overlap area with `other`, in px².
+ fn intersect_area(&self, other: &Rect) -> i64 {
+ let w = (self.right().min(other.right()) - self.x.max(other.x)).max(0) as i64;
+ let h = (self.bottom().min(other.bottom()) - self.y.max(other.y)).max(0) as i64;
+ w * h
+ }
+
+ pub fn intersects(&self, other: &Rect) -> bool {
+ self.intersect_area(other) > 0
+ }
+}
+
+/// One sRGB channel to linear light (the WCAG transfer function).
+fn to_linear(c: f32) -> f32 {
+ let c = c.clamp(0.0, 1.0);
+ if c <= 0.04045 {
+ c / 12.92
+ } else {
+ ((c + 0.055) / 1.055).powf(2.4)
+ }
+}
+
+/// WCAG relative luminance, 0–1. Perceptual weighting, not a channel mean:
+/// the eye reads green as most of the brightness, and a naive average would
+/// call the blue-gray gap color and a mid-gray equally bright.
+fn relative_luminance(rgb: [f32; 3]) -> f32 {
+ 0.2126 * to_linear(rgb[0]) + 0.7152 * to_linear(rgb[1]) + 0.0722 * to_linear(rgb[2])
+}
+
+/// `over` composited onto `under`, both PREMULTIPLIED (which is how
+/// [`Rgba`] carries grid colors — see `GridSpec::gap_color`). Returns
+/// straight rgb, since that is all luminance needs.
+fn over(over_c: Rgba, under: [f32; 3]) -> [f32; 3] {
+ let a = over_c.0[3].clamp(0.0, 1.0);
+ [
+ over_c.0[0] + under[0] * (1.0 - a),
+ over_c.0[1] + under[1] * (1.0 - a),
+ over_c.0[2] + under[2] * (1.0 - a),
+ ]
+}
+
+/// The fraction of `rect` covered by grid cells, 0–1.
+///
+/// Only the cell columns/rows that can reach `rect` are visited — derived
+/// from the period rather than by scanning the whole lattice, which at a far
+/// zoom-out is thousands of cells that a 27px-tall segment cannot touch.
+///
+/// Cell corner radius and the fade inset are deliberately ignored: both
+/// soften a cell's edge by a few px, which moves the coverage fraction far
+/// less than the quantization to whole percent does.
+fn cell_coverage(frame: &GridFrame, rect: &Rect) -> f32 {
+ let Some(cells) = &frame.cells else {
+ return 0.0;
+ };
+ let Some((tx, ty)) = frame.tree_pos else {
+ return 0.0;
+ };
+ let area = (rect.w as i64) * (rect.h as i64);
+ if area <= 0 {
+ return 0.0;
+ }
+ let px = frame.period_px_exact_x;
+ let py = frame.period_px_exact_y;
+ if !(px > 0.5) || !(py > 0.5) {
+ return 0.0;
+ }
+
+ // Tree-local span the rect can touch, widened by one cell so a cell
+ // whose origin sits before the rect but whose body reaches into it is
+ // still visited.
+ let lx0 = (rect.x - tx) as f64;
+ let lx1 = (rect.right() - tx) as f64;
+ let ly0 = (rect.y - ty) as f64;
+ let ly1 = (rect.bottom() - ty) as f64;
+ let col0 = (((lx0 - cells.cell_w_px as f64) / px).floor() as i64).clamp(0, cells.cols as i64);
+ let col1 = ((lx1 / px).ceil() as i64).clamp(0, cells.cols as i64);
+ let row0 = (((ly0 - cells.cell_h_px as f64) / py).floor() as i64).clamp(0, cells.rows as i64);
+ let row1 = ((ly1 / py).ceil() as i64).clamp(0, cells.rows as i64);
+
+ let mut covered: i64 = 0;
+ for row in row0..=row1 {
+ let cy = ty + (row as f64 * py).round() as i32;
+ for col in col0..=col1 {
+ let cx = tx + (col as f64 * px).round() as i32;
+ let cell = Rect { x: cx, y: cy, w: cells.cell_w_px, h: cells.cell_h_px };
+ covered += rect.intersect_area(&cell);
+ }
+ }
+ (covered as f32 / area as f32).clamp(0.0, 1.0)
+}
+
+/// Measure the backdrop under `rect`.
+///
+/// `base` is the opaque desktop background color the grid is drawn onto (the
+/// output's background rect), so a gap or cell color carrying alpha resolves
+/// against the same thing the screen shows.
+///
+/// `occluded` says a window overlaps the rect; its content is not knowable
+/// here, so the sample degrades to "unknown" — mid luminance and full
+/// spread — rather than confidently reporting the desktop that is no longer
+/// what the text sits on.
+pub fn measure(frame: &GridFrame, spec_gap: Rgba, base: [f32; 3], rect: Rect, occluded: bool) -> BackdropSample {
+ if occluded {
+ return BackdropSample { luma: 50, spread: 100 };
+ }
+
+ let gap_rgb = over(spec_gap, base);
+ let gap_luma = relative_luminance(gap_rgb);
+
+ let (cell_luma, f) = match &frame.cells {
+ // The cell color carries the density fade in its alpha, so a
+ // faded-out lattice correctly resolves toward the gap color.
+ Some(cells) => (relative_luminance(over(cells.color, gap_rgb)), cell_coverage(frame, &rect)),
+ None => (gap_luma, 0.0),
+ };
+
+ let luma = f * cell_luma + (1.0 - f) * gap_luma;
+
+ // Spread is the area split WEIGHTED by how different the two colors
+ // actually are: a rect straddling cell and gap is only a problem for the
+ // text when the two read as different brightnesses. A lattice drawn in
+ // two similar tones is uniform as far as legibility is concerned, however
+ // the area happens to divide.
+ let split = 2.0 * f.min(1.0 - f);
+ let spread = split * (cell_luma - gap_luma).abs();
+
+ BackdropSample {
+ luma: (luma.clamp(0.0, 1.0) * 100.0).round() as u8,
+ spread: (spread.clamp(0.0, 1.0) * 100.0).round() as u8,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::policy::api::{GridFadeMode, GridSpec};
+ use crate::policy::background::{grid_frame, GridCells};
+ use crate::policy::camera::Camera;
+
+ const BLACK: Rgba = Rgba([0.0, 0.0, 0.0, 1.0]);
+ const WHITE: Rgba = Rgba([1.0, 1.0, 1.0, 1.0]);
+
+ fn frame_with(cells: Option<GridCells>, period: f64) -> GridFrame {
+ GridFrame {
+ tree_pos: Some((0, 0)),
+ period_px_x: period as i32,
+ period_px_y: period as i32,
+ period_px_exact_x: period,
+ period_px_exact_y: period,
+ backdrop_w: 1000,
+ backdrop_h: 1000,
+ cells,
+ first_col: 0,
+ first_row: 0,
+ }
+ }
+
+ fn cells(w: i32, h: i32, color: Rgba) -> GridCells {
+ GridCells {
+ cell_w_px: w,
+ cell_h_px: h,
+ cols: 8,
+ rows: 8,
+ color,
+ corner_radius_px: 0,
+ fade_inset_px: 0,
+ }
+ }
+
+ #[test]
+ fn a_rect_wholly_on_a_cell_is_uniform_at_the_cell_color() {
+ // The legibility case that motivated all this: a segment sitting
+ // entirely on a black cell must report black AND report it
+ // confidently, or the bar has no reason to change anything.
+ let frame = frame_with(Some(cells(500, 500, BLACK)), 516.0);
+ let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], Rect { x: 100, y: 100, w: 200, h: 27 }, false);
+ assert_eq!(s.luma, 0);
+ assert_eq!(s.spread, 0);
+ }
+
+ #[test]
+ fn a_rect_wholly_in_the_gap_is_uniform_at_the_gap_color() {
+ let frame = frame_with(Some(cells(100, 100, BLACK)), 200.0);
+ // x 120..180 falls between the cell at 0..100 and the one at 200.
+ let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], Rect { x: 120, y: 120, w: 60, h: 27 }, false);
+ assert_eq!(s.luma, 100);
+ assert_eq!(s.spread, 0);
+ }
+
+ #[test]
+ fn straddling_a_cell_edge_reports_spread() {
+ // Half on a black cell, half on a white gap: no single text color
+ // works, which is exactly what a high spread tells the bar.
+ let frame = frame_with(Some(cells(100, 100, BLACK)), 200.0);
+ let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], Rect { x: 50, y: 20, w: 100, h: 27 }, false);
+ assert!(s.spread > 90, "spread was {}", s.spread);
+ assert!((40..=60).contains(&s.luma), "luma was {}", s.luma);
+ }
+
+ #[test]
+ fn a_two_tone_lattice_of_similar_colors_is_not_spread() {
+ // Same 50/50 area split as above, but the two tones are close, so
+ // there is no legibility problem to report.
+ let near_white = Rgba([0.97, 0.97, 0.97, 1.0]);
+ let frame = frame_with(Some(cells(100, 100, near_white)), 200.0);
+ let rect = Rect { x: 50, y: 20, w: 100, h: 27 };
+ let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], rect, false);
+ // The same area split black-on-white reports ~100 (above), so the
+ // weighting — not the geometry — is what separates these two.
+ assert!(s.spread < 10, "spread was {}", s.spread);
+ }
+
+ #[test]
+ fn an_occluding_window_reports_unknown_rather_than_the_desktop() {
+ let frame = frame_with(Some(cells(500, 500, BLACK)), 516.0);
+ let rect = Rect { x: 100, y: 100, w: 200, h: 27 };
+ let clear = measure(&frame, WHITE, [1.0, 1.0, 1.0], rect, false);
+ let hidden = measure(&frame, WHITE, [1.0, 1.0, 1.0], rect, true);
+ assert_eq!(clear.spread, 0);
+ assert_eq!(hidden.spread, 100);
+ assert_ne!(clear.luma, hidden.luma);
+ }
+
+ #[test]
+ fn no_cells_is_the_flat_gap_color() {
+ let frame = frame_with(None, 200.0);
+ let s = measure(&frame, BLACK, [0.0, 0.0, 0.0], Rect { x: 0, y: 0, w: 100, h: 27 }, false);
+ assert_eq!(s.luma, 0);
+ assert_eq!(s.spread, 0);
+ }
+
+ #[test]
+ fn luminance_is_perceptual_not_a_channel_mean() {
+ // Pure green and pure blue have the same channel mean; the eye does
+ // not see them as remotely the same brightness.
+ let green = relative_luminance([0.0, 1.0, 0.0]);
+ let blue = relative_luminance([0.0, 0.0, 1.0]);
+ assert!(green > blue * 5.0, "green {} blue {}", green, blue);
+ }
+
+ #[test]
+ fn coverage_visits_only_the_cells_that_can_reach_the_rect() {
+ // A far zoom-out puts thousands of cells on screen; a bar segment
+ // touches a handful. The result must still be right when the rect
+ // sits deep inside the lattice rather than at its origin.
+ let frame = frame_with(Some(cells(10, 10, BLACK)), 20.0);
+ let s = measure(&frame, WHITE, [1.0, 1.0, 1.0], Rect { x: 1000, y: 1000, w: 40, h: 27 }, false);
+ // Beyond cols/rows (8), so no cell reaches it — pure gap.
+ assert_eq!(s.luma, 100);
+ }
+
+ #[test]
+ fn a_real_grid_frame_measures_without_panicking() {
+ // Exercises the real grid_frame output rather than a hand-built one,
+ // so a field-meaning drift in the policy crate surfaces here.
+ let spec = GridSpec {
+ gap_color: Rgba([0.686, 0.796, 0.867, 1.0]),
+ cell_color: BLACK,
+ cell_w: 512.0,
+ cell_h: 512.0,
+ gap_width: 16.0,
+ cell_corner_radius: 0,
+ cell_fade_inset: 4,
+ fade_mode: GridFadeMode::Quadratic,
+ };
+ let cam = Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 };
+ let frame = grid_frame(&spec, cam, 1920, 1080, 0, 0);
+ let s = measure(&frame, spec.gap_color, [0.0, 0.0, 0.0], Rect { x: 40, y: 0, w: 200, h: 27 }, false);
+ assert!(s.luma <= 100);
+ assert!(s.spread <= 100);
+ }
+}
diff --git a/src/server/output.rs b/src/server/output.rs
index b30648e..e75cc6b 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -510,6 +510,11 @@ impl Output {
// Update grid node positions and parameters first, which marks the scene output as damaged if changed
self.draw_grid();
self.draw_adjust_overlay();
+ // Right after draw_grid, whose geometry this reuses, and BEFORE the
+ // needs-frame early-out: a camera pan slides the lattice under a
+ // segment that has no damage of its own, and the bar still has to
+ // hear about it.
+ self.measure_status_backdrops();
// A parked `ccectl screenshot` targeting this output forces a render
// even without damage so there is a fresh buffer to read back.
@@ -687,6 +692,142 @@ impl Output {
}
}
+ /// Measure what each status segment on this output is composited over
+ /// and push the result to the bar (see [`crate::backdrop`] for why this
+ /// is geometry rather than a readback).
+ ///
+ /// Runs per frame. The cost is a handful of rect intersections per
+ /// segment; the resend gate is `update_status`'s equality check against
+ /// the last update, which the whole-percent quantization makes stick —
+ /// so a still desktop pushes nothing however many frames go by.
+ pub unsafe fn measure_status_backdrops(&mut self) {
+ let wm = &(*self.server).wm;
+ if wm.status_sender.is_none() {
+ return;
+ }
+
+ let (viewport_w, viewport_h) = self.current.dimensions();
+ let out_rect = crate::backdrop::Rect {
+ x: self.sent.x,
+ y: self.sent.y,
+ w: viewport_w,
+ h: viewport_h,
+ };
+
+ // The opaque ground the grid is drawn onto, so a gap or cell color
+ // carrying alpha resolves against what the screen actually shows.
+ let base = [
+ (wm.layout.background_r as f64 / u32::MAX as f64) as f32,
+ (wm.layout.background_g as f64 / u32::MAX as f64) as f32,
+ (wm.layout.background_b as f64 / u32::MAX as f64) as f32,
+ ];
+
+ let spec = wm.layout.background_spec();
+ let grid = match &spec {
+ crate::policy::api::BackgroundSpec::Grid(g) => g,
+ // No lattice: every segment sits on the flat background color,
+ // which `measure` still reports correctly through an empty frame.
+ _ => {
+ let flat = crate::policy::background::GridFrame {
+ tree_pos: None,
+ period_px_x: 0,
+ period_px_y: 0,
+ period_px_exact_x: 0.0,
+ period_px_exact_y: 0.0,
+ backdrop_w: 0,
+ backdrop_h: 0,
+ cells: None,
+ first_col: 0,
+ first_row: 0,
+ };
+ self.store_backdrops(&flat, crate::policy::api::Rgba([base[0], base[1], base[2], 1.0]), base, out_rect);
+ return;
+ }
+ };
+
+ let frame = crate::policy::background::grid_frame(
+ grid,
+ wm.camera(),
+ viewport_w,
+ viewport_h,
+ self.sent.x,
+ self.sent.y,
+ );
+ let gap = grid.gap_color;
+ self.store_backdrops(&frame, gap, base, out_rect);
+ }
+
+ /// The half of [`Self::measure_status_backdrops`] that walks the windows:
+ /// each status segment on this output measured against `frame`, anything
+ /// else on screen treated as an occluder whose pixels are unknowable.
+ unsafe fn store_backdrops(
+ &mut self,
+ frame: &crate::policy::background::GridFrame,
+ gap: crate::policy::api::Rgba,
+ base: [f32; 3],
+ out_rect: crate::backdrop::Rect,
+ ) {
+ let wm = &(*self.server).wm;
+
+ let visible = |w: *mut crate::window::Window| -> bool {
+ !w.is_null()
+ && !(*w).closed
+ && !(*w).minimized
+ && !matches!((*w).state, crate::window::WindowState::Closing | crate::window::WindowState::Init)
+ };
+ let rect_of = |w: *mut crate::window::Window| crate::backdrop::Rect {
+ x: (*w).box_geom.x,
+ y: (*w).box_geom.y,
+ w: (*w).box_geom.width,
+ h: (*w).box_geom.height,
+ };
+
+ let mut mine: Vec<(String, u8, u8)> = Vec::new();
+ for &seg in wm.windows.iter() {
+ if !visible(seg) || !(*seg).is_status_bar() {
+ continue;
+ }
+ let seg_rect = rect_of(seg);
+ if !seg_rect.intersects(&out_rect) {
+ continue;
+ }
+ let Some(app_id) = (*seg).get_app_id_string() else {
+ continue;
+ };
+
+ // Anything that is not desktop furniture and overlaps the segment
+ // is content this side cannot read — see the module docs.
+ let mut occluded = false;
+ for &other in wm.windows.iter() {
+ if other == seg || !visible(other) {
+ continue;
+ }
+ if (*other).is_status_bar() || (*other).is_wallpaper() || (*other).is_grid() {
+ continue;
+ }
+ if rect_of(other).intersects(&seg_rect) {
+ occluded = true;
+ break;
+ }
+ }
+
+ let s = crate::backdrop::measure(frame, gap, base, seg_rect, occluded);
+ mine.push((app_id, s.luma, s.spread));
+ }
+
+ {
+ let mut store = wm.status_backdrops.borrow_mut();
+ // Replace only this output's segments; another output's entries
+ // are its own to maintain. Sorted so a reordering of the window
+ // list cannot, by itself, look like a change worth resending.
+ store.retain(|(id, _, _)| !mine.iter().any(|(m, _, _)| m == id));
+ store.extend(mine);
+ store.sort_by(|a, b| a.0.cmp(&b.0));
+ }
+
+ wm.update_status();
+ }
+
pub unsafe fn draw_adjust_overlay(&mut self) {
if self.adjust_tree.is_null() {
return;
diff --git a/src/server/server.rs b/src/server/server.rs
index c837be2..55149b0 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -905,6 +905,10 @@ impl Default for Server {
std::ptr::write(&mut (*server.as_mut_ptr()).wm.startup, Vec::new());
std::ptr::write(&mut (*server.as_mut_ptr()).wm.startup_pids, Vec::new());
std::ptr::write(&mut (*server.as_mut_ptr()).wm.status_sender, None);
+ // A zeroed Vec is a null data pointer, which Vec's NonNull
+ // invariant forbids — the same reason `startup` above is written
+ // explicitly rather than left to the zeroed MaybeUninit.
+ std::ptr::write(&mut (*server.as_mut_ptr()).wm.status_backdrops, std::cell::RefCell::new(Vec::new()));
std::ptr::write(&mut (*server.as_mut_ptr()).wm.last_saved_state_json, None);
std::ptr::write(&mut (*server.as_mut_ptr()).layer_shell.surfaces, crate::slotmap::SlotMap::new());
std::ptr::write(&mut (*server.as_mut_ptr()).inspector, crate::inspector::Inspector::new());
diff --git a/src/server/status_server.rs b/src/server/status_server.rs
index 704d4f6..f0fc413 100644
--- a/src/server/status_server.rs
+++ b/src/server/status_server.rs
@@ -20,6 +20,14 @@ pub struct StatusUpdate {
pub title_text: String,
/// Plain text for modifiers subscriber
pub modifiers_text: String,
+ /// What each status segment is composited OVER, by app_id — see
+ /// [`crate::backdrop`]. Unlike the other topics this one is
+ /// per-subscriber: a segment gets only its own entry, since the whole
+ /// point is that the far ends of a bar sit over different things.
+ /// Quantized to whole percent, which is what keeps this struct `Eq` and
+ /// therefore keeps `update_status`'s resend gate working while the
+ /// camera pans.
+ pub backdrops: Vec<(String, u8, u8)>,
}
/// A message from the main loop to the server thread: either a new state
@@ -34,19 +42,32 @@ pub enum StatusMsg {
}
/// Subscription types that the status bar script can request.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+///
+/// Not `Copy`: `Backdrop` names the segment doing the asking, because it is
+/// the one topic whose value differs per subscriber.
+#[derive(Debug, Clone, PartialEq, Eq)]
enum Subscription {
Layout,
Title,
Modifiers,
/// One-shot menu-dismiss events only — never receives state pushes.
Dismiss,
+ /// `backdrop <app_id>` — what THIS segment is composited over, so it can
+ /// adapt its own text contrast. Lines are `<luma> <spread>`, both 0-100.
+ Backdrop(String),
Unknown,
}
impl Subscription {
fn from_str(s: &str) -> Self {
- match s.trim() {
+ let s = s.trim();
+ // The one topic that takes an argument. A bare `backdrop` is
+ // accepted and simply never matches a segment, which reads as a
+ // permanently unknown backdrop rather than as an error.
+ if let Some(app_id) = s.strip_prefix("backdrop") {
+ return Subscription::Backdrop(app_id.trim().to_string());
+ }
+ match s {
"layout" => Subscription::Layout,
"title" => Subscription::Title,
"modifiers" => Subscription::Modifiers,
@@ -272,7 +293,7 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, display_socket: Option<Stri
if client.subscription == Subscription::Dismiss {
continue;
}
- let msg = format_for_subscription(client.subscription, update);
+ let msg = format_for_subscription(&client.subscription, update);
// Only lines that changed for THIS topic go out (see
// Client::last_line); a fresh client always gets one.
if client.last_line.as_deref() == Some(msg.as_str()) {
@@ -336,11 +357,21 @@ fn read_subscription(stream: &UnixStream) -> Subscription {
}
}
-fn format_for_subscription(sub: Subscription, update: &StatusUpdate) -> String {
+fn format_for_subscription(sub: &Subscription, update: &StatusUpdate) -> String {
match sub {
Subscription::Layout => update.layout_text.clone(),
Subscription::Title => update.title_text.clone(),
Subscription::Modifiers => update.modifiers_text.clone(),
+ Subscription::Backdrop(app_id) => {
+ // A segment the compositor has no sample for (not mapped yet, or
+ // its app_id does not match a window) is told so explicitly
+ // rather than left to time out: "unknown" is a state the bar
+ // renders for, not an absence.
+ match update.backdrops.iter().find(|(id, _, _)| id == app_id) {
+ Some((_, luma, spread)) => format!("{} {}", luma, spread),
+ None => "unknown".to_string(),
+ }
+ }
Subscription::Dismiss | Subscription::Unknown => String::new(),
}
}
@@ -419,5 +450,9 @@ pub unsafe fn build_status_update(wm: &crate::window_manager::WindowManager) ->
layout_text,
title_text,
modifiers_text,
+ // Measured in the render pass (see `Output::measure_status_backdrops`)
+ // because that is where the frame's grid geometry already lives;
+ // here it is only carried.
+ backdrops: wm.status_backdrops.borrow().clone(),
}
}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 113f5dd..2ee985b 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -118,6 +118,11 @@ pub struct WindowManager {
pub input_rules: Vec<crate::config::InputDeviceConfigRule>,
pub input_config: crate::config::InputConfig,
pub last_status_update: std::cell::RefCell<Option<crate::status_server::StatusUpdate>>,
+ /// What each status segment is composited over, by app_id — measured in
+ /// the output's render pass (`Output::measure_status_backdrops`, which is
+ /// where the frame's grid geometry already is) and read back out by
+ /// `build_status_update`. See [`crate::backdrop`].
+ pub status_backdrops: std::cell::RefCell<Vec<(String, u8, u8)>>,
pub status_hide_mode: bool,
pub adjust_position_mode: bool,
/// xkb modifier mask currently held via injected `key-down` (see the ipc handler):
@@ -379,6 +384,7 @@ impl WindowManager {
self.input_rules = Vec::new();
self.input_config = crate::config::InputConfig::default();
self.last_status_update = std::cell::RefCell::new(None);
+ self.status_backdrops = std::cell::RefCell::new(Vec::new());
self.status_hide_mode = false;
self.adjust_position_mode = false;
self.injected_key_mods = 0;