git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit00ff81cd041e7cdd83fe0e809b53ccc09ffa0b55
parent6c7692a22c
authorLucas Galante <[email protected]>
date2026-08-27 19:00
feat: read window content for the backdrop, instead of calling it unknown

The backdrop measurement could only reason about the desktop it draws
itself. A window over a segment was reported as maximum spread — honest,
but it meant the bar wore a full outline whenever anything sat under it,
which on a real desktop is most of the time.

That case is now read. read_window_region composites the occluding
window's surfaces over just the overlapping strip and measures the
pixels; blend folds that into the desktop measurement for whatever part
of the segment the window does not cover. Spread comes from the 10th and
90th luminance percentiles, so a cursor or an icon does not report a flat
terminal as busy, and blend carries a third term for the seam itself —
two uniform halves still leave text straddling a hard edge between them.

Subsurfaces are composited in: a toolkit that renders into one would
otherwise be measured as its blank root.

The readback is the one part of this that costs a GPU sync, and it runs
in the render path, so it is gated twice: a 250ms throttle, and a check
that the window has committed anything since the last read. The second
gate is the one that matters — a window nobody is typing in is read
exactly once, and a static fullscreen window now produces one status push
in six seconds rather than twenty-four. Only the strip is read, never the
whole window: a bar-height slice of a 4K client is 0.3% of it.

Verified in a shadow session against ground truth: with cce-files
fullscreen under the clock, the topic reports `11 14`, and computing mean
luminance and p10-p90 spread independently from a capture of that same
window region gives exactly 11 and 14. Panning a floating window across
the segment band shows the blend moving between the window's 12 and the
desktop's 4 as coverage changes.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md                        |  21 +++-
 src/server/backdrop.rs           | 135 ++++++++++++++++++++-
 src/server/output.rs             | 256 +++++++++++++++++++++++++++++++++++++--
 src/server/screenshot.rs         |  36 +++++-
 src/server/wlroots_log_wrapper.c |   7 ++
 wrapper.h                        |   1 +
 6 files changed, 437 insertions(+), 19 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9886850..3e0d3f9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -301,11 +301,22 @@ Persistent window state is saved to **`~/.local/state/cce/state.json`**
   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.
+  readback where it can be — 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 covering part of a segment is the case that has to be *read*:
+  `Output::read_window_region` composites that window's surfaces (subsurfaces
+  included) over just the overlapping strip via
+  `screenshot::read_texture_region`, and `backdrop::blend` folds the result
+  into the desktop measurement for the rest of the segment. Two gates keep that
+  readback off the render thread's back, and the second one matters more than
+  the first: a 250ms throttle, and a check that the window's summed surface
+  commit sequence changed at all (`river_wlr_surface_current_seq`). A window
+  nobody is typing in is read exactly once. Content that still cannot be read —
+  no committed buffer, an unsupported read format, an implausibly large strip —
+  falls back to `backdrop::UNKNOWN`.
 
 ## Conventions
 
diff --git a/src/server/backdrop.rs b/src/server/backdrop.rs
index e14aec9..30db0a8 100644
--- a/src/server/backdrop.rs
+++ b/src/server/backdrop.rs
@@ -63,7 +63,7 @@ impl Rect {
     }
 
     /// Overlap area with `other`, in px².
-    fn intersect_area(&self, other: &Rect) -> i64 {
+    pub 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
@@ -72,6 +72,15 @@ impl Rect {
     pub fn intersects(&self, other: &Rect) -> bool {
         self.intersect_area(other) > 0
     }
+
+    /// The overlapping rect, or None when they do not meet.
+    pub fn intersection(&self, other: &Rect) -> Option<Rect> {
+        let x = self.x.max(other.x);
+        let y = self.y.max(other.y);
+        let w = self.right().min(other.right()) - x;
+        let h = self.bottom().min(other.bottom()) - y;
+        (w > 0 && h > 0).then_some(Rect { x, y, w, h })
+    }
 }
 
 /// One sRGB channel to linear light (the WCAG transfer function).
@@ -153,6 +162,62 @@ fn cell_coverage(frame: &GridFrame, rect: &Rect) -> f32 {
     (covered as f32 / area as f32).clamp(0.0, 1.0)
 }
 
+/// Measure a block of RGBA pixels — the window-content path, where the
+/// backdrop is not derivable geometry and has to be looked at.
+///
+/// Spread comes from the 10th and 90th luminance percentiles rather than the
+/// full range, so one stray highlight (a cursor, an icon, an anti-aliased
+/// edge) does not report a whole terminal as high-variance. It is the same
+/// quantity the grid path computes analytically: how far apart the light and
+/// dark parts of this patch are.
+pub fn measure_pixels(rgba: &[u8]) -> Option<BackdropSample> {
+    let n = rgba.len() / 4;
+    if n == 0 {
+        return None;
+    }
+    let mut lumas: Vec<f32> = Vec::with_capacity(n);
+    let mut sum = 0.0f32;
+    for px in rgba.chunks_exact(4) {
+        let l = relative_luminance([px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0]);
+        sum += l;
+        lumas.push(l);
+    }
+    lumas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
+    let p10 = lumas[n / 10];
+    let p90 = lumas[n - 1 - n / 10];
+    Some(BackdropSample {
+        luma: ((sum / n as f32).clamp(0.0, 1.0) * 100.0).round() as u8,
+        spread: ((p90 - p10).clamp(0.0, 1.0) * 100.0).round() as u8,
+    })
+}
+
+/// Fold a window-content sample covering `coverage` (0-1) of a segment into
+/// the desktop sample for the rest of it.
+///
+/// The third spread term is the one that is easy to miss: two patches can each
+/// be perfectly uniform and still leave the text straddling a hard edge
+/// between them — a black terminal ending halfway across a segment that sits
+/// on a light gap. That boundary is exactly as unreadable as a busy texture,
+/// and only the difference between the two means shows it.
+pub fn blend(desktop: BackdropSample, window: BackdropSample, coverage: f32) -> BackdropSample {
+    let c = coverage.clamp(0.0, 1.0);
+    let dl = desktop.luma as f32 / 100.0;
+    let wl = window.luma as f32 / 100.0;
+    let luma = c * wl + (1.0 - c) * dl;
+    let edge = 2.0 * c.min(1.0 - c) * (wl - dl).abs();
+    let spread = (desktop.spread as f32 / 100.0)
+        .max(window.spread as f32 / 100.0)
+        .max(edge);
+    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,
+    }
+}
+
+/// What a segment reports when its backdrop cannot be determined at all —
+/// mid luminance, full spread, which drives the outline.
+pub const UNKNOWN: BackdropSample = BackdropSample { luma: 50, spread: 100 };
+
 /// Measure the backdrop under `rect`.
 ///
 /// `base` is the opaque desktop background color the grid is drawn onto (the
@@ -165,7 +230,7 @@ fn cell_coverage(frame: &GridFrame, rect: &Rect) -> f32 {
 /// 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 };
+        return UNKNOWN;
     }
 
     let gap_rgb = over(spec_gap, base);
@@ -313,6 +378,72 @@ mod tests {
         assert_eq!(s.luma, 100);
     }
 
+    fn solid(luma_byte: u8, n: usize) -> Vec<u8> {
+        std::iter::repeat([luma_byte, luma_byte, luma_byte, 255]).take(n).flatten().collect()
+    }
+
+    #[test]
+    fn a_flat_patch_of_pixels_has_no_spread() {
+        let s = measure_pixels(&solid(0, 1000)).unwrap();
+        assert_eq!(s.luma, 0);
+        assert_eq!(s.spread, 0);
+        let s = measure_pixels(&solid(255, 1000)).unwrap();
+        assert_eq!(s.luma, 100);
+        assert_eq!(s.spread, 0);
+    }
+
+    #[test]
+    fn half_black_half_white_pixels_report_full_spread() {
+        let mut px = solid(0, 500);
+        px.extend(solid(255, 500));
+        let s = measure_pixels(&px).unwrap();
+        assert!(s.spread > 95, "spread was {}", s.spread);
+        assert!((45..=55).contains(&s.luma), "luma was {}", s.luma);
+    }
+
+    #[test]
+    fn a_lone_highlight_does_not_read_as_a_busy_backdrop() {
+        // A cursor or an icon on an otherwise flat terminal. The percentile
+        // spread is what keeps a handful of bright pixels from pinning the
+        // outline on over content the text reads fine against.
+        let mut px = solid(0, 990);
+        px.extend(solid(255, 10));
+        let s = measure_pixels(&px).unwrap();
+        assert_eq!(s.spread, 0, "spread was {}", s.spread);
+    }
+
+    #[test]
+    fn measure_pixels_rejects_an_empty_read() {
+        assert!(measure_pixels(&[]).is_none());
+    }
+
+    #[test]
+    fn blending_a_window_over_part_of_a_segment_moves_the_luma() {
+        let desktop = BackdropSample { luma: 0, spread: 0 };
+        let window = BackdropSample { luma: 100, spread: 0 };
+        assert_eq!(blend(desktop, window, 0.0).luma, 0);
+        assert_eq!(blend(desktop, window, 1.0).luma, 100);
+        assert_eq!(blend(desktop, window, 0.5).luma, 50);
+    }
+
+    #[test]
+    fn a_hard_edge_between_two_flat_patches_is_itself_spread() {
+        // A black terminal ending halfway across a segment that sits on a
+        // light gap: both halves uniform, the text across the seam is not.
+        let desktop = BackdropSample { luma: 100, spread: 0 };
+        let window = BackdropSample { luma: 0, spread: 0 };
+        assert_eq!(blend(desktop, window, 0.5).spread, 100);
+        // ...and at the edges of coverage there is no seam to worry about.
+        assert_eq!(blend(desktop, window, 0.02).spread, 4);
+    }
+
+    #[test]
+    fn blending_keeps_the_worse_of_the_two_spreads() {
+        let desktop = BackdropSample { luma: 50, spread: 10 };
+        let window = BackdropSample { luma: 50, spread: 80 };
+        assert_eq!(blend(desktop, window, 0.5).spread, 80);
+    }
+
     #[test]
     fn a_real_grid_frame_measures_without_panicking() {
         // Exercises the real grid_frame output rather than a hand-built one,
diff --git a/src/server/output.rs b/src/server/output.rs
index e75cc6b..3a7bb73 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -136,6 +136,23 @@ pub struct RenderingState {
     pub tearing: bool,
 }
 
+/// One cached window-content reading — see `Output::status_win_samples`.
+pub struct StatusWinSample {
+    /// The occluding window's slotmap index.
+    pub win: u32,
+    /// The region sampled, in layout px. Part of the key: a segment that
+    /// moves, or a window that slides, is looking at different pixels.
+    pub region: crate::backdrop::Rect,
+    /// When the readback actually ran.
+    pub at: std::time::Instant,
+    /// Commit sequences of the window's surfaces, summed. A window that has
+    /// not committed cannot have changed what it is showing, so this is what
+    /// keeps a still terminal from being re-read four times a second forever.
+    pub seq: u32,
+    /// None when the content could not be read at all.
+    pub sample: Option<crate::backdrop::BackdropSample>,
+}
+
 pub struct Output {
     pub server: *mut Server,
     pub wlr_output: *mut ffi::wlr_output,
@@ -195,6 +212,13 @@ pub struct Output {
     pub cell_labels: crate::text::LabelCache,
     /// Label point size actually in use, so a zoom change can re-rasterize.
     pub last_label_px: u32,
+    /// Throttle+cache for the window-content half of the backdrop measurement.
+    /// Unlike the grid half, which is arithmetic, this one costs a texture
+    /// readback and a GPU sync, so it is re-taken at most every
+    /// `WIN_SAMPLE_MS` per segment AND only when the window has actually
+    /// committed something since. A window whose content changes faster than
+    /// that is not something the text contrast should be chasing anyway.
+    pub status_win_samples: Vec<StatusWinSample>,
 
     pub destroy: ffi::wl_listener,
     pub request_state: ffi::wl_listener,
@@ -462,6 +486,7 @@ impl Output {
             cell_label_pool: Vec::new(),
             cell_labels: Default::default(),
             last_label_px: 0,
+            status_win_samples: Vec::new(),
             destroy: std::mem::zeroed(),
             request_state: std::mem::zeroed(),
             frame: std::mem::zeroed(),
@@ -758,8 +783,8 @@ impl Output {
     }
 
     /// 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.
+    /// each status segment on this output measured against `frame`, with any
+    /// window covering part of it sampled for its actual content and folded in.
     unsafe fn store_backdrops(
         &mut self,
         frame: &crate::policy::background::GridFrame,
@@ -783,6 +808,7 @@ impl Output {
         };
 
         let mut mine: Vec<(String, u8, u8)> = Vec::new();
+        let mut live_keys: Vec<(u32, crate::backdrop::Rect)> = Vec::new();
         for &seg in wm.windows.iter() {
             if !visible(seg) || !(*seg).is_status_bar() {
                 continue;
@@ -795,9 +821,15 @@ impl Output {
                 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;
+            let desktop = crate::backdrop::measure(frame, gap, base, seg_rect, false);
+
+            // The window covering the most of this segment, if any. Stacking
+            // order is deliberately not consulted: the compositor's own
+            // hit-test answers with the segment itself (it is on top of
+            // whatever it is asking about), and where two windows both reach
+            // under one segment the larger share is the better guess at what
+            // the text is actually over.
+            let mut best: Option<(*mut crate::window::Window, i64)> = None;
             for &other in wm.windows.iter() {
                 if other == seg || !visible(other) {
                     continue;
@@ -805,16 +837,41 @@ impl Output {
                 if (*other).is_status_bar() || (*other).is_wallpaper() || (*other).is_grid() {
                     continue;
                 }
-                if rect_of(other).intersects(&seg_rect) {
-                    occluded = true;
-                    break;
+                let area = rect_of(other).intersect_area(&seg_rect);
+                if area > 0 && best.map_or(true, |(_, a)| area > a) {
+                    best = Some((other, area));
                 }
             }
 
-            let s = crate::backdrop::measure(frame, gap, base, seg_rect, occluded);
-            mine.push((app_id, s.luma, s.spread));
+            let sample = match best {
+                None => desktop,
+                Some((win, area)) => {
+                    let region = rect_of(win).intersection(&seg_rect).unwrap_or(seg_rect);
+                    let key = ((*win).ref_key.index, region);
+                    live_keys.push(key);
+                    match self.window_backdrop_sample(win, region) {
+                        // Blended, not replaced: a window covering half a
+                        // segment leaves the other half on the desktop, and
+                        // the seam between them is its own legibility problem.
+                        Some(w) => {
+                            let coverage = area as f32 / (seg_rect.w as f32 * seg_rect.h as f32).max(1.0);
+                            crate::backdrop::blend(desktop, w, coverage)
+                        }
+                        // Unreadable content (no committed buffer yet, an
+                        // unsupported read format): the honest answer is still
+                        // "unknown", exactly as before this path existed.
+                        None => crate::backdrop::UNKNOWN,
+                    }
+                }
+            };
+            mine.push((app_id, sample.luma, sample.spread));
         }
 
+        // Drop cache entries for segment/window pairs that no longer exist,
+        // so a closed window or a moved segment cannot pin a stale reading.
+        self.status_win_samples
+            .retain(|e| live_keys.iter().any(|(k, r)| *k == e.win && *r == e.region));
+
         {
             let mut store = wm.status_backdrops.borrow_mut();
             // Replace only this output's segments; another output's entries
@@ -828,6 +885,185 @@ impl Output {
         wm.update_status();
     }
 
+    /// The window-content half of the backdrop measurement: what `win` is
+    /// actually showing inside `region` (layout px), or None when it cannot be
+    /// read.
+    ///
+    /// Throttled and cached per (window, region) — this is the one part of the
+    /// measurement that costs a texture readback and its GPU sync, and it runs
+    /// inside the render path.
+    unsafe fn window_backdrop_sample(
+        &mut self,
+        win: *mut crate::window::Window,
+        region: crate::backdrop::Rect,
+    ) -> Option<crate::backdrop::BackdropSample> {
+        /// Re-read a window's content at most this often, per segment.
+        const WIN_SAMPLE_MS: u128 = 250;
+        /// Refuse to read back more than this many pixels in one sample. A bar
+        /// strip is naturally short, so this only trips on an implausibly wide
+        /// segment at a high buffer scale — where reporting "unknown" and
+        /// wearing the outline beats stalling the render thread.
+        const MAX_SAMPLE_PX: i64 = 512 * 1024;
+
+        let id = (*win).ref_key.index;
+        let now = std::time::Instant::now();
+        let seq = Self::surface_content_seq((*win).root_surface());
+        if let Some(hit) = self
+            .status_win_samples
+            .iter()
+            .find(|e| e.win == id && e.region == region)
+        {
+            // Two gates, and the content one is the load-bearing half: a
+            // window nobody is typing in never gets read a second time.
+            if hit.seq == seq || now.duration_since(hit.at).as_millis() < WIN_SAMPLE_MS {
+                return hit.sample;
+            }
+        }
+
+        let fresh = self.read_window_region(win, region, MAX_SAMPLE_PX);
+        match self
+            .status_win_samples
+            .iter_mut()
+            .find(|e| e.win == id && e.region == region)
+        {
+            Some(slot) => {
+                slot.at = now;
+                slot.seq = seq;
+                slot.sample = fresh;
+            }
+            None => self.status_win_samples.push(StatusWinSample {
+                win: id,
+                region,
+                at: now,
+                seq,
+                sample: fresh,
+            }),
+        }
+        fresh
+    }
+
+    /// Commit sequences of a surface tree, summed — a cheap "has this window
+    /// drawn anything new?" key. Walks subsurfaces too, because a toolkit that
+    /// renders into one can leave the root's own sequence untouched for the
+    /// life of the window.
+    unsafe fn surface_content_seq(root: *mut ffi::wlr_surface) -> u32 {
+        if root.is_null() {
+            return 0;
+        }
+        unsafe extern "C" fn sum_cb(
+            surface: *mut ffi::wlr_surface,
+            _sx: std::os::raw::c_int,
+            _sy: std::os::raw::c_int,
+            data: *mut std::ffi::c_void,
+        ) {
+            let total = &mut *(data as *mut u32);
+            *total = total.wrapping_add(ffi::river_wlr_surface_current_seq(surface));
+        }
+        let mut total: u32 = 0;
+        ffi::wlr_surface_for_each_surface(
+            root,
+            Some(sum_cb),
+            &mut total as *mut u32 as *mut std::ffi::c_void,
+        );
+        total
+    }
+
+    /// Read `region` (layout px) out of a window's committed surfaces and
+    /// measure it. Subsurfaces are composited in, because a toolkit that puts
+    /// its content in one would otherwise be measured as its blank root.
+    unsafe fn read_window_region(
+        &self,
+        win: *mut crate::window::Window,
+        region: crate::backdrop::Rect,
+        max_px: i64,
+    ) -> Option<crate::backdrop::BackdropSample> {
+        let root = (*win).root_surface();
+        if root.is_null() {
+            return None;
+        }
+        let (mut bw, mut bh) = (0i32, 0i32);
+        ffi::river_wlr_surface_get_buffer_size(root, &mut bw, &mut bh);
+        if bw <= 0 || bh <= 0 {
+            return None;
+        }
+        // Two scales stack here: the window's own render scale maps layout px
+        // to surface-logical px, and the buffer scale maps those to the
+        // physical pixels a texture read is addressed in.
+        let logical_w = ffi::river_wlr_surface_get_width(root).max(1);
+        let buf_scale = bw as f64 / logical_w as f64;
+        let win_scale = if (*win).scale > 0.0 { (*win).scale } else { 1.0 };
+        let to_buf = buf_scale / win_scale;
+
+        let rx = (((region.x - (*win).box_geom.x) as f64) * to_buf).round() as i32;
+        let ry = (((region.y - (*win).box_geom.y) as f64) * to_buf).round() as i32;
+        let rw = ((region.w as f64) * to_buf).round() as i32;
+        let rh = ((region.h as f64) * to_buf).round() as i32;
+        if rw <= 0 || rh <= 0 || (rw as i64) * (rh as i64) > max_px {
+            return None;
+        }
+
+        struct Collect {
+            list: Vec<(*mut ffi::wlr_surface, i32, i32)>,
+        }
+        unsafe extern "C" fn collect_cb(
+            surface: *mut ffi::wlr_surface,
+            sx: std::os::raw::c_int,
+            sy: std::os::raw::c_int,
+            data: *mut std::ffi::c_void,
+        ) {
+            let collect = &mut *(data as *mut Collect);
+            collect.list.push((surface, sx, sy));
+        }
+        let mut collect = Collect { list: Vec::new() };
+        ffi::wlr_surface_for_each_surface(
+            root,
+            Some(collect_cb),
+            &mut collect as *mut Collect as *mut std::ffi::c_void,
+        );
+
+        let mut canvas = vec![0u8; (rw as usize) * (rh as usize) * 4];
+        let mut composited = 0usize;
+        for (surface, sx, sy) in collect.list {
+            let texture = ffi::wlr_surface_get_texture(surface);
+            if texture.is_null() {
+                continue;
+            }
+            let (mut sw, mut sh) = (0i32, 0i32);
+            ffi::river_wlr_surface_get_buffer_size(surface, &mut sw, &mut sh);
+            if sw <= 0 || sh <= 0 {
+                continue;
+            }
+            // Subsurface offsets are surface-logical; buffers are physical.
+            let off_x = (sx as f64 * buf_scale).round() as i32;
+            let off_y = (sy as f64 * buf_scale).round() as i32;
+            let x0 = off_x.max(rx);
+            let y0 = off_y.max(ry);
+            let x1 = (off_x + sw).min(rx + rw);
+            let y1 = (off_y + sh).min(ry + rh);
+            if x1 <= x0 || y1 <= y0 {
+                continue;
+            }
+            let src = ffi::wlr_box {
+                x: x0 - off_x,
+                y: y0 - off_y,
+                width: x1 - x0,
+                height: y1 - y0,
+            };
+            let Some((pixels, format)) =
+                crate::screenshot::read_texture_region(texture, src, x1 - x0, y1 - y0)
+            else {
+                continue;
+            };
+            let Some(rgba) = crate::screenshot::to_rgba(pixels, format) else { continue };
+            crate::screenshot::blit(&mut canvas, rw, rh, &rgba, x1 - x0, y1 - y0, x0 - rx, y0 - ry);
+            composited += 1;
+        }
+        if composited == 0 {
+            return None;
+        }
+        crate::backdrop::measure_pixels(&canvas)
+    }
+
     pub unsafe fn draw_adjust_overlay(&mut self) {
         if self.adjust_tree.is_null() {
             return;
diff --git a/src/server/screenshot.rs b/src/server/screenshot.rs
index 8f9cd12..dd2d15d 100644
--- a/src/server/screenshot.rs
+++ b/src/server/screenshot.rs
@@ -197,10 +197,42 @@ unsafe fn read_texture(texture: *mut ffi::wlr_texture, w: i32, h: i32) -> Option
     Some((data, format))
 }
 
+/// Read back only `src` (in buffer px) of a texture, into a `w`×`h` buffer.
+///
+/// The full-texture [`read_texture`] is fine for a screenshot, which wants
+/// every pixel anyway; it is not fine for the backdrop sampler, which wants a
+/// bar-height strip out of a window that may be 4K — 33MB copied per sample to
+/// look at 0.3% of it.
+pub(crate) unsafe fn read_texture_region(
+    texture: *mut ffi::wlr_texture,
+    src: ffi::wlr_box,
+    w: i32,
+    h: i32,
+) -> Option<(Vec<u8>, u32)> {
+    if texture.is_null() || w <= 0 || h <= 0 || src.width <= 0 || src.height <= 0 {
+        return None;
+    }
+    let format = ffi::wlr_texture_preferred_read_format(texture);
+    let bpp = bytes_per_pixel(format)?;
+    let mut data = vec![0u8; (w as usize) * (h as usize) * bpp];
+    let options = ffi::wlr_texture_read_pixels_options {
+        data: data.as_mut_ptr() as *mut std::ffi::c_void,
+        format,
+        stride: (w as u32) * bpp as u32,
+        dst_x: 0,
+        dst_y: 0,
+        src_box: src,
+    };
+    if !ffi::wlr_texture_read_pixels(texture, &options) {
+        return None;
+    }
+    Some((data, format))
+}
+
 /// Convert read-back pixels to RGBA. Alpha is forced opaque — the X-variants
 /// carry garbage alpha, the 24-bit formats carry none at all, and screenshots
 /// should not be translucent.
-fn to_rgba(mut pixels: Vec<u8>, format: u32) -> Option<Vec<u8>> {
+pub(crate) fn to_rgba(mut pixels: Vec<u8>, format: u32) -> Option<Vec<u8>> {
     match format {
         DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => {
             for px in pixels.chunks_exact_mut(4) {
@@ -370,7 +402,7 @@ pub unsafe fn capture_window_rgba(window: *mut crate::window::Window) -> Result<
 }
 
 /// Copy `src` (sw×sh RGBA) into `dst` (dw×dh RGBA) at (dx, dy), clipped.
-fn blit(dst: &mut [u8], dw: i32, dh: i32, src: &[u8], sw: i32, sh: i32, dx: i32, dy: i32) {
+pub(crate) fn blit(dst: &mut [u8], dw: i32, dh: i32, src: &[u8], sw: i32, sh: i32, dx: i32, dy: i32) {
     for sy in 0..sh {
         let ty = dy + sy;
         if ty < 0 || ty >= dh {
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index dba2fcb..a7ba069 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -629,6 +629,13 @@ int river_wlr_surface_get_height(struct wlr_surface *surface) {
 	return surface->current.height;
 }
 
+// Commit sequence of a surface's current state. Cheap "has this drawn
+// anything new?" key for the status-bar backdrop sampler, which is trying
+// hard NOT to read back a texture it has already read.
+uint32_t river_wlr_surface_current_seq(struct wlr_surface *surface) {
+	return surface->current.seq;
+}
+
 void river_wlr_surface_get_buffer_size(struct wlr_surface *surface, int *width, int *height) {
 	*width = surface->current.buffer_width;
 	*height = surface->current.buffer_height;
diff --git a/wrapper.h b/wrapper.h
index 8c489d8..c1b74e4 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -247,6 +247,7 @@ struct wlr_surface *river_wlr_seat_get_keyboard_focused_surface(struct wlr_seat
 int river_wlr_surface_get_width(struct wlr_surface *surface);
 int river_wlr_surface_get_height(struct wlr_surface *surface);
 void river_wlr_surface_get_buffer_size(struct wlr_surface *surface, int *width, int *height);
+uint32_t river_wlr_surface_current_seq(struct wlr_surface *surface);
 struct wlr_keyboard *river_wlr_input_method_keyboard_grab_v2_get_keyboard(struct wlr_input_method_keyboard_grab_v2 *grab);
 struct wl_signal *river_wlr_input_method_keyboard_grab_v2_get_destroy_signal(struct wlr_input_method_keyboard_grab_v2 *grab);