Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
Compress status segments' backdrops instead of measuring them
The status bar's text contrast came from a dark scrim it drew itself,
deepened by a per-segment `backdrop` luminance reading this compositor
measured every frame (CPU geometry plus window-content readbacks) and
pushed on the status socket. The scrim darkened the whole bubble even over
a backdrop that needed no help.
Now the compositor fixes the backdrop directly. The bar's
`module { backdrop_compress }` (a contrast ratio) and `module { text_color }`
become a luminance ceiling (`backdrop_compress_params`), which
`Window::sync_backdrop_compress` sets on each status segment's blur node and
droplet lens. scenefx's tex.frag and droplet.frag pull every backdrop pixel
above half the ceiling smoothly under it, scaling rather than desaturating
(dark text lifts the shadows instead). Off (a zero ceiling) for every other
texture draw.
The backdrop measurement, the `backdrop` topic, `layout_epoch` and
`read_texture_region` existed only for the scrim and are removed. The
CLAUDE.md test-module list also picks up idle.rs and idle_inhibit_manager.rs,
which it had missed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 47 +--
WORKSPACE.md | 10 +-
scenefx/include/render/fx_renderer/shaders.h | 7 +
scenefx/include/scenefx/render/pass.h | 13 +
scenefx/include/scenefx/types/wlr_scene.h | 20 +
scenefx/render/fx_renderer/fx_pass.c | 8 +
scenefx/render/fx_renderer/shaders.c | 6 +
scenefx/render/fx_renderer/shaders/droplet.frag | 28 ++
scenefx/render/fx_renderer/shaders/tex.frag | 43 ++-
scenefx/types/scene/wlr_scene.c | 34 ++
src/lib.rs | 2 -
src/server/backdrop.rs | 467 ------------------------
src/server/config.rs | 100 +++++
src/server/output.rs | 423 ---------------------
src/server/screenshot.rs | 32 --
src/server/server.rs | 4 -
src/server/status_server.rs | 34 --
src/server/window.rs | 20 +
src/server/window_manager.rs | 12 -
src/server/wlroots_log_wrapper.c | 14 +
src/server/xdg_toplevel.rs | 1 +
wrapper.h | 1 +
22 files changed, 316 insertions(+), 1010 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8b20e78f..37e7677f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -213,10 +213,10 @@ Native libs via `pkg-config`: `wlroots-0.20`, `wayland-server`, `xkbcommon`,
## Tests
-Twelve modules carry unit tests — `window_manager.rs` (the most of any, among
+Thirteen modules carry unit tests — `window_manager.rs` (the most of any, among
them the saved-state matchers: same-program borrowing, untitled entries),
-`backdrop.rs` (the measurement and the desktop/window blend), `config.rs`,
-`xwayland_window.rs`, `screenshot.rs`, `window.rs`, `migrate_input.rs`,
+`config.rs` (among them `backdrop_compress_params`), `idle.rs`,
+`idle_inhibit_manager.rs`, `xwayland_window.rs`, `screenshot.rs`, `window.rs`, `migrate_input.rs`,
`text.rs`, `global_shortcuts.rs` (trigger parsing),
`cursor.rs` (the swipe lean's direction, `swipe_lean`), `min_sizes.rs`,
`selection.rs` (the rubber band's rect and its hit rule). They cluster where the logic is
@@ -933,7 +933,7 @@ headless seat has no keyboard and Chromium crashes in
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`, `dismiss`, or `backdrop <app_id>`) and receives text lines on every
+ `modifiers`, `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.
@@ -969,10 +969,7 @@ headless seat has no keyboard and Chromium crashes in
on every transaction: `save_state` reads `/proc` for every window, and a
drag is one transaction per pointer event.
- **Per-frame work is gated too.** `Output::render_and_commit` measures the
- status backdrops only when the window manager's `layout_epoch` (bumped per
- transaction) or the camera moved, or 250 ms passed — not every vblank.
- The `/tmp/cce-ovdbg` scene dump needs `CCE_OVDBG=1` in the environment
+ **Per-frame work is gated too.** The `/tmp/cce-ovdbg` scene dump needs `CCE_OVDBG=1` in the environment
before the file is even looked for. The window-stream tick runs only while
the stream hub has subscribers (the accept thread's eventfd arms it), and a
failed tearing test is not repeated every frame of the same fullscreen
@@ -996,27 +993,19 @@ headless seat has no keyboard and Chromium crashes in
frozen ghost; buffer commits never pass through `scene_node_update` with
damage in this scenefx, which is the path the cache's dirtying hangs off.
- **`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 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`.
+ **Status text contrast is backdrop compression** (`module { backdrop_compress }`
+ in the bar's config, the minimum WCAG ratio its text must hold). A Wayland
+ client cannot see what its translucent module boxes are composited over, so
+ the compositor fixes the backdrop instead: `backdrop_compress_params`
+ (`config.rs`) turns the ratio and the bar's `module { text_color }` into a
+ luminance ceiling, `Window::sync_backdrop_compress` sets it on the segment's
+ blur node and droplet lens (`wlr_scene_blur_set_compress` /
+ `wlr_scene_droplet_set_compress`), and scenefx's `tex.frag` / `droplet.frag`
+ (`compress_backdrop`) pull every backdrop pixel brighter than half the
+ ceiling smoothly under it — or, for dark text, lift the shadows. It replaced
+ (2026-10-01) the per-segment `backdrop` status topic, whose CPU geometry
+ measurement and window-content readbacks ran every frame to feed a bar-side
+ scrim.
### Portal global shortcuts
diff --git a/WORKSPACE.md b/WORKSPACE.md
index b7cdfa45..d40f25c6 100644
--- a/WORKSPACE.md
+++ b/WORKSPACE.md
@@ -309,17 +309,17 @@ Clients and compositor communicate over Unix sockets keyed by `$WAYLAND_DISPLAY`
`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 `layout` /
- `title` / `modifiers` / `adjust` / `dismiss` / `selection` / `backdrop <app_id>`
+ `title` / `modifiers` / `adjust` / `dismiss` / `selection`
and receive push updates. `adjust` is "on"/"off" as window-adjust mode
(overview, or Super held) comes and goes — what `cce-grid` keys its image
resize handles on. `selection` is the other grid-facing topic: the overview
drag-selection carrying the grid's images tells it where they went
(`move <id>:<x>:<y> ...`, then `drop`); the grid reports the images it holds
the other way, with `grid-items` on the control socket.
- 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.)
+ This feeds `cce-status-interface` (the status bar). (The `viewport` topic
+ went with the viewport-tag feature; the per-segment `backdrop` topic went
+ 2026-10-01, when the bar's text contrast moved to compositor-side backdrop
+ compression.)
## Window fades (DE-wide open/close dissolve)
diff --git a/scenefx/include/render/fx_renderer/shaders.h b/scenefx/include/render/fx_renderer/shaders.h
index 9f9f1fc6..c5fad7f0 100644
--- a/scenefx/include/render/fx_renderer/shaders.h
+++ b/scenefx/include/render/fx_renderer/shaders.h
@@ -127,6 +127,10 @@ struct tex_shader {
GLint discard_transparent;
+ GLint compress_ceil;
+ GLint compress_knee;
+ GLint compress_invert;
+
// Only used for the effects shader
struct {
GLint size;
@@ -221,6 +225,9 @@ struct droplet_shader {
GLint band_px;
GLint refr;
GLint ghost;
+ GLint compress_ceil;
+ GLint compress_knee;
+ GLint compress_invert;
};
bool link_droplet_program(struct droplet_shader *shader);
diff --git a/scenefx/include/scenefx/render/pass.h b/scenefx/include/scenefx/render/pass.h
index 6ba764af..0eedc629 100644
--- a/scenefx/include/scenefx/render/pass.h
+++ b/scenefx/include/scenefx/render/pass.h
@@ -41,12 +41,24 @@ struct fx_gradient {
float *colors;
};
+/**
+ * Backdrop compression: the drawn luminance is pulled under `ceil` (linear),
+ * starting at `knee`, so text of a known color keeps its contrast over all of
+ * it; `invert` lifts the shadows instead, for dark text. A zero ceil is off.
+ */
+struct fx_backdrop_compress {
+ float ceil;
+ float knee;
+ bool invert;
+};
+
struct fx_render_texture_options {
struct wlr_render_texture_options base;
const struct wlr_box *clip_box; // Used to clip csd. Ignored if NULL
struct fx_corner_fradii corners;
bool discard_transparent;
struct clipped_fregion clipped_region;
+ struct fx_backdrop_compress compress;
};
struct fx_render_rect_options {
@@ -153,6 +165,7 @@ struct fx_render_droplet_options {
float band_px;
float refr;
float ghost;
+ struct fx_backdrop_compress compress;
};
struct fx_render_blur_pass_options {
diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index 7057b2d9..4cc93809 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -219,6 +219,9 @@ struct wlr_scene_droplet {
float band_px;
float refr;
float ghost;
+ float compress_ceil;
+ float compress_knee;
+ bool compress_invert;
};
/** A resize-handle frame: a ring inside a rounded rect, cut into four corner
@@ -286,6 +289,11 @@ struct wlr_scene_blur {
bool should_only_blur_bottom_layer;
struct linked_node transparency_mask_source;
+
+ /** Backdrop compression (fx_backdrop_compress); a zero ceil is off. */
+ float compress_ceil;
+ float compress_knee;
+ bool compress_invert;
};
/** A scene-graph node telling SceneFX to render the optimized blur */
@@ -795,6 +803,9 @@ void wlr_scene_droplet_set_silhouette(struct wlr_scene_droplet *droplet,
/** Lens: falloff band px, rim refraction px, inverted-ghost strength 0-1. */
void wlr_scene_droplet_set_lens(struct wlr_scene_droplet *droplet,
float band_px, float refr, float ghost);
+/** Backdrop compression of the refracted image (see wlr_scene_blur_set_compress). */
+void wlr_scene_droplet_set_compress(struct wlr_scene_droplet *droplet,
+ float ceil, float knee, bool invert);
void wlr_scene_bevel_set_color(struct wlr_scene_bevel *bevel, const float color[static 4]);
void wlr_scene_bevel_set_focus(struct wlr_scene_bevel *bevel, float focus, float sharpness,
const float color[static 3]);
@@ -885,6 +896,15 @@ void wlr_scene_blur_set_alpha(struct wlr_scene_blur *blur, float alpha);
*/
void wlr_scene_blur_set_strength(struct wlr_scene_blur *blur, float strength);
+/**
+ * Compresses the blurred backdrop's luminance under `ceil` (linear), starting
+ * at `knee`, so text drawn over the node keeps its contrast however bright the
+ * backdrop is; `invert` lifts the shadows instead, for dark text. A ceil of 0
+ * turns it off.
+ */
+void wlr_scene_blur_set_compress(struct wlr_scene_blur *blur,
+ float ceil, float knee, bool invert);
+
/**
* Sets the region where to clip the blur.
*
diff --git a/scenefx/render/fx_renderer/fx_pass.c b/scenefx/render/fx_renderer/fx_pass.c
index eec7c9d3..1488044d 100644
--- a/scenefx/render/fx_renderer/fx_pass.c
+++ b/scenefx/render/fx_renderer/fx_pass.c
@@ -497,6 +497,9 @@ void fx_render_pass_add_texture(struct fx_gles_render_pass *pass,
glUniform1i(shader->tex, 0);
glUniform1f(shader->alpha, alpha);
+ glUniform1f(shader->compress_ceil, fx_options->compress.ceil);
+ glUniform1f(shader->compress_knee, fx_options->compress.knee);
+ glUniform1i(shader->compress_invert, fx_options->compress.invert);
glUniform1f(shader->discard_transparent, fx_options->discard_transparent);
@@ -1311,6 +1314,9 @@ void fx_render_pass_add_droplet(struct fx_gles_render_pass *pass,
glUniform1f(shader->band_px, options->band_px);
glUniform1f(shader->refr, options->refr);
glUniform1f(shader->ghost, options->ghost);
+ glUniform1f(shader->compress_ceil, options->compress.ceil);
+ glUniform1f(shader->compress_knee, options->compress.knee);
+ glUniform1i(shader->compress_invert, options->compress.invert);
render(&box, &clip_region, shader->pos_attrib);
@@ -1370,6 +1376,8 @@ void fx_render_pass_add_blur(struct fx_gles_render_pass *pass,
struct fx_render_texture_options tex_options = fx_options->tex_options;
tex_options.discard_transparent = true;
tex_options.clipped_region = fx_options->clipped_region;
+ // The mask is the window's own buffer, not the backdrop.
+ tex_options.compress = (struct fx_backdrop_compress){0};
fx_render_pass_add_texture(pass, &tex_options);
stencil_mask_close(true);
diff --git a/scenefx/render/fx_renderer/shaders.c b/scenefx/render/fx_renderer/shaders.c
index a6d478b6..43ddbc4f 100644
--- a/scenefx/render/fx_renderer/shaders.c
+++ b/scenefx/render/fx_renderer/shaders.c
@@ -294,6 +294,9 @@ bool link_tex_program(struct tex_shader *shader, enum fx_tex_shader_source sourc
shader->tex_proj = glGetUniformLocation(prog, "tex_proj");
shader->discard_transparent = glGetUniformLocation(prog, "discard_transparent");
+ shader->compress_ceil = glGetUniformLocation(prog, "compress_ceil");
+ shader->compress_knee = glGetUniformLocation(prog, "compress_knee");
+ shader->compress_invert = glGetUniformLocation(prog, "compress_invert");
if (!effects) {
return true;
@@ -428,6 +431,9 @@ bool link_droplet_program(struct droplet_shader *shader) {
shader->band_px = glGetUniformLocation(prog, "band_px");
shader->refr = glGetUniformLocation(prog, "refr");
shader->ghost = glGetUniformLocation(prog, "ghost");
+ shader->compress_ceil = glGetUniformLocation(prog, "compress_ceil");
+ shader->compress_knee = glGetUniformLocation(prog, "compress_knee");
+ shader->compress_invert = glGetUniformLocation(prog, "compress_invert");
return true;
}
diff --git a/scenefx/render/fx_renderer/shaders/droplet.frag b/scenefx/render/fx_renderer/shaders/droplet.frag
index 186909f7..91de24ef 100644
--- a/scenefx/render/fx_renderer/shaders/droplet.frag
+++ b/scenefx/render/fx_renderer/shaders/droplet.frag
@@ -37,6 +37,30 @@ uniform float curve; // corner-shape exponent (2 = circular)
uniform float band_px; // dome skirt width px (the refraction falloff scale)
uniform float refr; // rim refraction strength px (sign flips direction)
uniform float ghost; // inverted-lens ghost strength 0-1
+uniform float compress_ceil; // backdrop compression ceiling, linear luma (0 = off)
+uniform float compress_knee; // where compression starts, linear luma
+uniform bool compress_invert; // dark text: lift shadows instead
+
+// The same backdrop compression the blurred backdrop gets (tex.frag's
+// compress_backdrop — keep the two in step): the refracted image is the
+// segment's backdrop when this node is live, so it is what the text has to
+// read against.
+vec3 compress_backdrop(vec3 c) {
+ vec3 lin = pow(max(c, vec3(0.0)), vec3(2.2));
+ if (compress_invert) {
+ lin = vec3(1.0) - lin;
+ }
+ float y = dot(lin, vec3(0.2126, 0.7152, 0.0722));
+ if (y > compress_knee) {
+ float span = max(compress_ceil - compress_knee, 1e-4);
+ float y2 = compress_knee + span * (1.0 - exp(-(y - compress_knee) / span));
+ lin *= y2 / y;
+ }
+ if (compress_invert) {
+ lin = vec3(1.0) - lin;
+ }
+ return pow(clamp(lin, 0.0, 1.0), vec3(1.0 / 2.2));
+}
// Signed distance to the drop silhouette at box-local p (y DOWN from the box
// top). The rounded-box part is the same construction as the client's
@@ -116,5 +140,9 @@ void main() {
col.rgb = mix(col.rgb, gcol.rgb, ghost * t * t);
}
+ if (compress_ceil > 0.0) {
+ col.rgb = compress_backdrop(col.rgb);
+ }
+
gl_FragColor = vec4(col.rgb, 1.0) * aa;
}
diff --git a/scenefx/render/fx_renderer/shaders/tex.frag b/scenefx/render/fx_renderer/shaders/tex.frag
index 11e3102a..cc9f90cb 100644
--- a/scenefx/render/fx_renderer/shaders/tex.frag
+++ b/scenefx/render/fx_renderer/shaders/tex.frag
@@ -47,6 +47,13 @@ uniform float clip_radius_bottom_right;
uniform bool discard_transparent;
+// Backdrop compression (see compress_backdrop below). Ceiling and knee are
+// linear luminances; a ceiling of 0 turns it off, which is every texture
+// draw but a status segment's blurred backdrop.
+uniform float compress_ceil;
+uniform float compress_knee;
+uniform bool compress_invert;
+
vec4 sample_texture() {
#if SOURCE == SOURCE_TEXTURE_RGBA || SOURCE == SOURCE_TEXTURE_EXTERNAL
return texture2D(tex, v_texcoord);
@@ -55,6 +62,38 @@ vec4 sample_texture() {
#endif
}
+// Pull the backdrop's luminance under a ceiling, so text of a known color
+// keeps its contrast over every pixel of it. Below the knee nothing moves;
+// above it the luminance approaches the ceiling asymptotically (slope 1 at
+// the knee, so there is no visible seam), and the color is scaled rather
+// than desaturated, so a bright backdrop keeps its hue and only loses
+// brightness. Dark text compresses the INVERTED image, which lifts the
+// shadows toward white instead. Keep in step with droplet.frag.
+vec3 compress_backdrop(vec3 c) {
+ vec3 lin = pow(max(c, vec3(0.0)), vec3(2.2));
+ if (compress_invert) {
+ lin = vec3(1.0) - lin;
+ }
+ float y = dot(lin, vec3(0.2126, 0.7152, 0.0722));
+ if (y > compress_knee) {
+ float span = max(compress_ceil - compress_knee, 1e-4);
+ float y2 = compress_knee + span * (1.0 - exp(-(y - compress_knee) / span));
+ lin *= y2 / y;
+ }
+ if (compress_invert) {
+ lin = vec3(1.0) - lin;
+ }
+ return pow(clamp(lin, 0.0, 1.0), vec3(1.0 / 2.2));
+}
+
+vec4 sample_compressed() {
+ vec4 c = sample_texture();
+ if (compress_ceil > 0.0 && c.a > 0.0) {
+ c.rgb = compress_backdrop(c.rgb / c.a) * c.a;
+ }
+ return c;
+}
+
#if EFFECTS
float corner_alpha(vec2 size, vec2 position, bool is_cutout,
float radius_tl, float radius_tr, float radius_bl, float radius_br);
@@ -83,9 +122,9 @@ void main() {
clip_radius_bottom_right
);
- gl_FragColor = sample_texture() * alpha * quad_corner_alpha * clip_corner_alpha;
+ gl_FragColor = sample_compressed() * alpha * quad_corner_alpha * clip_corner_alpha;
#else
- gl_FragColor = sample_texture() * alpha;
+ gl_FragColor = sample_compressed() * alpha;
#endif
if (discard_transparent && gl_FragColor.a == 0.0) {
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index ce9a4bd0..7141096f 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -1425,6 +1425,18 @@ void wlr_scene_droplet_set_lens(struct wlr_scene_droplet *droplet,
scene_node_update(&droplet->node, NULL);
}
+void wlr_scene_droplet_set_compress(struct wlr_scene_droplet *droplet,
+ float ceil, float knee, bool invert) {
+ if (droplet->compress_ceil == ceil && droplet->compress_knee == knee
+ && droplet->compress_invert == invert) {
+ return;
+ }
+ droplet->compress_ceil = ceil;
+ droplet->compress_knee = knee;
+ droplet->compress_invert = invert;
+ scene_node_update(&droplet->node, NULL);
+}
+
void wlr_scene_bevel_set_color(struct wlr_scene_bevel *bevel, const float color[static 4]) {
if (memcmp(bevel->color, color, sizeof(bevel->color)) == 0) {
return;
@@ -1612,6 +1624,18 @@ void wlr_scene_blur_set_strength(struct wlr_scene_blur *blur, float strength) {
scene_node_update(&blur->node, NULL);
}
+void wlr_scene_blur_set_compress(struct wlr_scene_blur *blur,
+ float ceil, float knee, bool invert) {
+ if (blur->compress_ceil == ceil && blur->compress_knee == knee
+ && blur->compress_invert == invert) {
+ return;
+ }
+ blur->compress_ceil = ceil;
+ blur->compress_knee = knee;
+ blur->compress_invert = invert;
+ scene_node_update(&blur->node, NULL);
+}
+
void wlr_scene_blur_set_clipped_region(struct wlr_scene_blur *blur,
struct clipped_region clipped_region) {
if (fx_corner_radii_eq(blur->clipped_region.corners, clipped_region.corners) &&
@@ -3045,6 +3069,11 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
.band_px = scene_droplet->band_px * data->scale,
.refr = scene_droplet->refr * data->scale,
.ghost = scene_droplet->ghost,
+ .compress = {
+ .ceil = scene_droplet->compress_ceil,
+ .knee = scene_droplet->compress_knee,
+ .invert = scene_droplet->compress_invert,
+ },
.clip = &render_region,
};
fx_render_pass_add_droplet(fx_pass, &droplet_options);
@@ -3162,6 +3191,11 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
.clip_box = &dst_box,
.corners = fx_corner_radii_scale(blur_corners, data->scale),
.discard_transparent = false,
+ .compress = {
+ .ceil = blur->compress_ceil,
+ .knee = blur->compress_knee,
+ .invert = blur->compress_invert,
+ },
},
.use_optimized_blur = blur->should_only_blur_bottom_layer,
.blur_data = &scene->blur_data,
diff --git a/src/lib.rs b/src/lib.rs
index b8de8e9f..41f49f83 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -27,8 +27,6 @@ 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
deleted file mode 100644
index 30db0a8d..00000000
--- a/src/server/backdrop.rs
+++ /dev/null
@@ -1,467 +0,0 @@
-//! 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².
- 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
- }
-
- 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).
-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 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
-/// 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 UNKNOWN;
- }
-
- 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);
- }
-
- 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,
- // 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/config.rs b/src/server/config.rs
index 6758e87b..6f20c841 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -170,6 +170,12 @@ pub struct Layout {
/// (backdrop refraction) per status segment from the SAME spec the bar
/// draws its drops from, so the two silhouettes cannot drift.
pub status_droplet: Option<String>,
+ /// Backdrop compression for status segments, from the bar's
+ /// `module { backdrop_compress }` (the minimum WCAG contrast ratio the
+ /// module text must hold against any backdrop pixel) and its
+ /// `module { text_color }`: `(ceil, knee, invert)` in linear luminance,
+ /// see `backdrop_compress_params`. `None` = off.
+ pub status_backdrop_compress: Option<(f32, f32, bool)>,
pub cloud_position_default: Option<[i32; 2]>,
}
@@ -289,6 +295,7 @@ impl Default for Layout {
status_module_hide_mode_preview: 4,
status_module_spacing: 12,
status_droplet: None,
+ status_backdrop_compress: None,
cloud_position_default: None,
}
}
@@ -996,6 +1003,13 @@ pub struct LayoutConfig {
/// reads the same spec the bar draws from).
#[serde(default)]
pub status_droplet: Option<String>,
+ /// Backdrop compression for status segments, from the bar's
+ /// `module { backdrop_compress }` (the minimum WCAG contrast ratio the
+ /// module text must hold against any backdrop pixel) and its
+ /// `module { text_color }`: `(ceil, knee, invert)` in linear luminance,
+ /// see `backdrop_compress_params`. `None` = off.
+ #[serde(default)]
+ pub status_backdrop_compress: Option<(f32, f32, bool)>,
}
impl Default for LayoutConfig {
@@ -1023,6 +1037,7 @@ impl Default for LayoutConfig {
status_module_hide_mode_preview: default_status_module_hide_mode_preview(),
status_module_spacing: default_status_module_spacing(),
status_droplet: None,
+ status_backdrop_compress: None,
}
}
}
@@ -1084,6 +1099,46 @@ pub fn parse_hex_color(hex_str: &str) -> u32 {
}
}
+/// Backdrop compression parameters for text of color `text_hex` that must
+/// hold a WCAG contrast `ratio` against every backdrop pixel: `(ceil, knee,
+/// invert)` in linear luminance, as scenefx's `wlr_scene_blur_set_compress`
+/// takes them. Light text caps the backdrop at the brightest luminance that
+/// still gives the ratio; dark text (`invert`) floors it at the darkest, and
+/// the ceiling is then measured in the inverted image (1 - floor). The knee,
+/// where compression starts, sits at half the ceiling, so a backdrop already
+/// dark enough for the text is left exactly as it is. `None` for a ratio of
+/// 1 or less, or an unparseable color.
+///
+/// The bubble's own translucent fill and droplet lighting composite on top
+/// of the compressed backdrop and lift it, so the ratio reached is well
+/// under the one asked for (droplet style on pure white: 4.5 asked, 2.7
+/// reached; 10 asked, 4.7 reached).
+pub fn backdrop_compress_params(text_hex: &str, ratio: f64) -> Option<(f32, f32, bool)> {
+ if !(ratio > 1.0) {
+ return None;
+ }
+ let hex = text_hex.trim_matches(|c| c == '"' || c == '\'' || c == ' ').trim_start_matches('#');
+ if hex.len() < 6 {
+ return None;
+ }
+ let lin = |i: usize| -> Option<f64> {
+ let c = u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()? as f64 / 255.0;
+ Some(if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) })
+ };
+ let lt = 0.2126 * lin(0)? + 0.7152 * lin(2)? + 0.0722 * lin(4)?;
+ // Which way the text reads: against black or against white.
+ let light = (lt + 0.05) / 0.05 >= 1.05 / (lt + 0.05);
+ let ceil = if light {
+ (lt + 0.05) / ratio - 0.05
+ } else {
+ 1.0 - (ratio * (lt + 0.05) - 0.05)
+ };
+ // A ratio the text color cannot reach at all still compresses as hard
+ // as it sensibly can rather than crushing the backdrop to black.
+ let ceil = ceil.clamp(0.005, 1.0) as f32;
+ Some((ceil, ceil * 0.5, !light))
+}
+
pub fn parse_hex_color_rgba(hex_str: &str) -> [f32; 4] {
let hex = hex_str.trim_matches(|c| c == '"' || c == '\'' || c == ' ');
let hex = hex.trim_start_matches('#');
@@ -1758,6 +1813,29 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
.and_then(|v| v.as_string().map(|s| s.to_string()))
};
layout.status_droplet = module_str("droplet");
+ // Backdrop compression: the contrast ratio the text must
+ // hold, against the text color the bar draws in (its
+ // own fallback is the shared status normal_color).
+ let module_f64 = |key: &str| -> Option<f64> {
+ module
+ .entries()
+ .iter()
+ .find(|e| e.name().map(|id| id.value()) == Some(key))
+ .map(|e| e.value())
+ .or_else(|| {
+ module.children().and_then(|c| {
+ c.nodes()
+ .iter()
+ .find(|n| n.name().value() == key)
+ .and_then(|n| n.entries().first().map(|e| e.value()))
+ })
+ })
+ .and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64)))
+ };
+ let text_color = module_str("text_color")
+ .unwrap_or_else(|| layout.status_normal_color.clone());
+ layout.status_backdrop_compress = module_f64("backdrop_compress")
+ .and_then(|ratio| backdrop_compress_params(&text_color, ratio));
}
}
}
@@ -2796,6 +2874,7 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
state.layout.status_module_hide_mode_preview = config.layout.status_module_hide_mode_preview;
state.layout.status_module_spacing = config.layout.status_module_spacing;
state.layout.status_droplet = config.layout.status_droplet.clone();
+ state.layout.status_backdrop_compress = config.layout.status_backdrop_compress;
state.layout.cloud_position_default = config.surface.cloud_position_default;
state.layout.shadow_enabled = config.surface.shadow_enabled;
state.layout.shadow_sigma = config.surface.shadow_sigma.max(0.0) as f32;
@@ -3409,4 +3488,25 @@ style {
assert_eq!(config.surface.border_segment_gap, 4);
assert_eq!(config.surface.border_corner_length, 0);
}
+
+ #[test]
+ fn backdrop_compress_caps_light_text_and_floors_dark_text() {
+ // White text at 4.5:1 caps the backdrop at (1.05 / 4.5) - 0.05.
+ let (ceil, knee, invert) = backdrop_compress_params("#ffffff", 4.5).unwrap();
+ assert!(!invert);
+ assert!((ceil - 0.1833).abs() < 1e-3, "{ceil}");
+ assert!((knee - ceil * 0.5).abs() < 1e-6);
+ // Black text floors it at 4.5 * 0.05 - 0.05 = 0.175, a ceiling of
+ // 0.825 in the inverted image.
+ let (ceil, _, invert) = backdrop_compress_params("#000000", 4.5).unwrap();
+ assert!(invert);
+ assert!((ceil - 0.825).abs() < 1e-3, "{ceil}");
+ // Off, or nothing to compress for.
+ assert_eq!(backdrop_compress_params("#ffffff", 1.0), None);
+ assert_eq!(backdrop_compress_params("#ffffff", 0.0), None);
+ assert_eq!(backdrop_compress_params("nonsense", 4.5), None);
+ // A ratio the color cannot reach clamps rather than going negative.
+ let (ceil, _, _) = backdrop_compress_params("#808080", 21.0).unwrap();
+ assert!(ceil > 0.0);
+ }
}
diff --git a/src/server/output.rs b/src/server/output.rs
index 195e482d..bf4e7da2 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -136,23 +136,6 @@ 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,
@@ -199,13 +182,6 @@ pub struct Output {
/// Phase of the frame clock's vblank grid (ns within a refresh period),
/// locked to the presentation times; `u64::MAX` until the first frame.
pub frame_phase_ns: u64,
- /// What the status-backdrop measurement last ran against: the window
- /// manager's layout epoch, the camera, and when. It re-runs only when one
- /// of those moved or `BACKDROP_REFRESH` has passed (for window content
- /// under a segment), not on every vblank.
- pub backdrop_epoch: u64,
- pub backdrop_cam: (f64, f64, f64),
- pub backdrop_measured_at: Option<std::time::Instant>,
/// A tearing page-flip test failed for the current tearing episode; do
/// not repeat the atomic TEST_ONLY commit every frame. Cleared when the
/// fullscreen client's tearing request goes away.
@@ -250,13 +226,6 @@ 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,
@@ -552,9 +521,6 @@ impl Output {
present_refresh_ns: 0,
present_dropped: 0,
frame_phase_ns: u64::MAX,
- backdrop_epoch: u64::MAX,
- backdrop_cam: (f64::NAN, f64::NAN, f64::NAN),
- backdrop_measured_at: None,
tearing_test_failed: false,
idle_off: false,
last_grid_viewport_w: 0,
@@ -570,7 +536,6 @@ 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(),
@@ -620,41 +585,6 @@ impl Output {
self.draw_grid();
self.draw_adjust_overlay();
(*self.server).wm.draw_selection();
- // Right after draw_grid, whose geometry this reuses, and BEFORE the
- // needs-frame early-out: a camera move slides the lattice under a
- // segment that has no damage of its own, and the bar still has to
- // hear about it — once the camera has SETTLED. During the motion
- // itself the measurement is skipped: the sliding lattice changed the
- // quantized reading on most frames (a push and a bar repaint each),
- // and a window under a segment moved its sampled region every frame,
- // defeating the per-(window, region) readback cache — a GPU texture
- // readback inside the render path, per pan frame. The settle's full
- // repaint frame runs the measurement with the final camera.
- //
- // And not on every frame either: the desktop half of the reading is
- // pure geometry, which only moves with a transaction (`layout_epoch`)
- // or the camera; the window-content half is throttled inside
- // `window_backdrop_sample` at `BACKDROP_REFRESH` anyway. Running the
- // walk — Vecs, a String per segment, the O(segments × windows) scan,
- // then `update_status` rebuilding and comparing the whole status
- // snapshot — every vblank was the largest steady CPU cost of an idle
- // desktop with anything animating on it.
- {
- let wm = &(*self.server).wm;
- if !wm.viewport_is_active {
- let cam = (wm.desk_pan_x, wm.desk_pan_y, wm.desk_zoom);
- let due = self.backdrop_epoch != wm.layout_epoch
- || self.backdrop_cam != cam
- || self.backdrop_measured_at.map_or(true, |t| t.elapsed() >= BACKDROP_REFRESH);
- if due {
- self.backdrop_epoch = wm.layout_epoch;
- self.backdrop_cam = cam;
- self.backdrop_measured_at = Some(std::time::Instant::now());
- 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.
let pending_shot = {
@@ -851,353 +781,6 @@ 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`, 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,
- 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();
- 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;
- }
- let seg_rect = rect_of(seg);
- if !seg_rect.intersects(&out_rect) {
- continue;
- }
- let Some(app_id) = (*seg).get_app_id_string() else {
- continue;
- };
-
- 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;
- }
- if (*other).is_status_bar() || (*other).is_wallpaper() || (*other).is_grid() {
- continue;
- }
- let area = rect_of(other).intersect_area(&seg_rect);
- if area > 0 && best.map_or(true, |(_, a)| area > a) {
- best = Some((other, area));
- }
- }
-
- 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
- // 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();
- }
-
- /// 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;
@@ -1796,12 +1379,6 @@ fn ovdbg_enabled() -> bool {
*FLAG.get_or_init(|| std::env::var_os("CCE_OVDBG").is_some())
}
-/// Ceiling on how long a status segment's backdrop reading may go unmeasured
-/// while frames are being rendered; matches the readback throttle in
-/// `window_backdrop_sample`. A still desktop renders no frames and measures
-/// nothing at all.
-const BACKDROP_REFRESH: std::time::Duration = std::time::Duration::from_millis(250);
-
impl Output {
/// The refresh period to plan frames by: what the last `present`
/// reported, else the current mode's rate, else 60 Hz.
diff --git a/src/server/screenshot.rs b/src/server/screenshot.rs
index dd2d15d0..896ae0e0 100644
--- a/src/server/screenshot.rs
+++ b/src/server/screenshot.rs
@@ -197,38 +197,6 @@ 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.
diff --git a/src/server/server.rs b/src/server/server.rs
index c5f9205a..e6a2f536 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -1015,10 +1015,6 @@ 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()).wm.min_sizes, crate::min_sizes::MinSizes::default());
std::ptr::write(&mut (*server.as_mut_ptr()).layer_shell.surfaces, crate::slotmap::SlotMap::new());
diff --git a/src/server/status_server.rs b/src/server/status_server.rs
index c29af6e4..f3c4b561 100644
--- a/src/server/status_server.rs
+++ b/src/server/status_server.rs
@@ -31,14 +31,6 @@ pub struct StatusUpdate {
/// step with the windows' handles; it never holds keyboard focus, so
/// it cannot read the modifier state for itself.
pub adjust_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
@@ -70,9 +62,6 @@ pub enum StatusMsg {
}
/// Subscription types that the status bar script can request.
-///
-/// 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,
@@ -91,21 +80,12 @@ enum Subscription {
/// `selection` — one-shot group-move lines for the desktop images
/// (see `StatusMsg::Selection`); never receives state pushes.
Selection,
- /// `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 {
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,
@@ -545,16 +525,6 @@ fn format_for_subscription(sub: &Subscription, update: &StatusUpdate) -> String
Subscription::Title => update.title_text.clone(),
Subscription::Modifiers => update.modifiers_text.clone(),
Subscription::Adjust => update.adjust_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::Shortcuts
| Subscription::ClickAway
@@ -638,9 +608,5 @@ pub unsafe fn build_status_update(wm: &crate::window_manager::WindowManager) ->
title_text,
modifiers_text,
adjust_text: if wm.window_adjust_active() { "on" } else { "off" }.to_string(),
- // 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.rs b/src/server/window.rs
index ae564dbc..a68e360b 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -3308,6 +3308,7 @@ impl Window {
self.update_shadow(width, height, radius, want_shadow);
self.update_bevel(width, height, radius, want_bevel, want_decor);
self.update_droplet(width, height);
+ self.sync_backdrop_compress();
ffi::river_scene_node_set_opacity(self.tree as *mut ffi::wlr_scene_node, self.effective_opacity());
// Device px, like the blur radius above: the surface content is
@@ -3784,6 +3785,7 @@ impl Window {
self.update_shadow(width, height, radius, want_shadow);
self.update_bevel(width, height, radius, want_bevel, want_decor);
self.update_droplet(width, height);
+ self.sync_backdrop_compress();
}
self.scale_only_render_finish();
@@ -3972,6 +3974,24 @@ impl Window {
);
ffi::river_scene_node_set_position_if_changed(node, 0, 0);
}
+ /// Push the status bar's backdrop compression (`module {
+ /// backdrop_compress }`) onto this segment's backdrop: the blur node and
+ /// the droplet lens, whichever is live. Off for everything that is not a
+ /// status segment. Call after `river_scene_node_enable_blur`, which can
+ /// recreate the blur node with compression off — from every path that
+ /// calls it for a status segment.
+ pub unsafe fn sync_backdrop_compress(&self) {
+ let is_status = self.tiling_mode == crate::tiling::TilingMode::Status;
+ let (ceil, knee, invert) = if is_status {
+ (*self.server).wm.layout.status_backdrop_compress.unwrap_or((0.0, 0.0, false))
+ } else {
+ (0.0, 0.0, false)
+ };
+ ffi::river_scene_node_set_blur_compress(self.tree as *mut ffi::wlr_scene_node, ceil, knee, invert);
+ if !self.droplet.is_null() {
+ ffi::wlr_scene_droplet_set_compress(self.droplet, ceil, knee, invert);
+ }
+ }
/// Sync the droplet backdrop-refraction node for a droplet-styled status
/// segment. Called from BOTH render paths, like update_bevel — one-path
/// effects freeze at the pre-gesture zoom (the shadow's old trap).
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 666e8df7..8809643d 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -354,10 +354,6 @@ pub struct WindowManager {
/// at most once per `SAVE_STATE_DELAY_MS`, not once per transaction.
pub save_state_timer: *mut ffi::wl_event_source,
pub save_state_pending: bool,
- /// Bumped at the end of every transaction; outputs compare it to know
- /// whether window geometry can have moved since they last measured the
- /// status backdrops.
- pub layout_epoch: u64,
/// The stream hub's wake eventfd as an event source: a new subscriber
/// arms `stream_timer`, which otherwise does not tick at all.
pub stream_source: *mut ffi::wl_event_source,
@@ -426,11 +422,6 @@ 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,
/// Window-adjust mode: Super is held. The focused window shows its
@@ -839,7 +830,6 @@ impl WindowManager {
self.ipc_wake = None;
self.save_state_timer = std::ptr::null_mut();
self.save_state_pending = false;
- self.layout_epoch = 0;
self.stream_source = std::ptr::null_mut();
self.sun_timer = std::ptr::null_mut();
self.stream_hub = None;
@@ -850,7 +840,6 @@ 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.adjust_held = false;
@@ -3077,7 +3066,6 @@ impl WindowManager {
if self.scheduled.dirty || self.scheduled.dirty_lazy || self.rendering_scheduled.dirty {
self.add_dirty_idle();
}
- self.layout_epoch = self.layout_epoch.wrapping_add(1);
self.schedule_save_state();
if let Some(r) = rf0 {
log::info!("[manage] render_finish total={}us", r.elapsed().as_micros());
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index 6b102c24..a1ab0ab6 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -871,6 +871,20 @@ void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, boo
}
}
+// Backdrop compression on the tree's standard blur node (see
+// wlr_scene_blur_set_compress). Separate from river_scene_node_enable_blur so
+// its many callers need not all carry it: call it after enable_blur, which may
+// have just recreated the node with compression off.
+void river_scene_node_set_blur_compress(struct wlr_scene_node *node, float ceil, float knee, bool invert) {
+ if (node->type != WLR_SCENE_NODE_TREE) {
+ return;
+ }
+ struct wlr_scene_node *std_blur_node = find_blur_node(wlr_scene_tree_from_node(node));
+ if (std_blur_node) {
+ wlr_scene_blur_set_compress((struct wlr_scene_blur *)std_blur_node, ceil, knee, invert);
+ }
+}
+
static void set_opacity_iterator(struct wlr_scene_buffer *buffer, int sx, int sy, void *user_data) {
(void)sx;
(void)sy;
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index a1a94f23..bf3cde55 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -741,6 +741,7 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
// geom_w/h are already scaled to device px; the radius must match.
(radius as f64 * scale) as i32,
);
+ (*window).sync_backdrop_compress();
let capture_node = &mut (*(*window).capture_scene).tree as *mut ffi::wlr_scene_tree as *mut ffi::wlr_scene_node;
let mut geom = std::mem::zeroed();
diff --git a/wrapper.h b/wrapper.h
index a5e09c23..0a28bb2b 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -271,6 +271,7 @@ struct wlr_seat_client *river_wlr_drag_get_seat_client(struct wlr_drag *drag);
void river_wlr_keyboard_init(struct wlr_keyboard *keyboard, void (*led_update)(struct wlr_keyboard *keyboard, uint32_t leds), const char *name);
void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, bool optimized, bool ignore_transparent, int x, int y, int width, int height, int corner_radius);
+void river_scene_node_set_blur_compress(struct wlr_scene_node *node, float ceil, float knee, bool invert);
void river_scene_mark_optimized_blur_dirty(struct wlr_scene *scene);
void river_scene_set_blur_frozen(struct wlr_scene *scene, bool frozen);