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

commit1cfd950a82277bcb1b683e1da62044837f37b69f
parentaaeab43366
authorLucas Galante <[email protected]>
date2026-08-30 20:33
feat: clip the resize ring under a client's in-surface popover

A cce-ui menu draws into the app's own surface (Phase 6x moved every app
to in-surface popovers), so the ring composited straight over an open
menu and no z-order could fix it: the menu is pixels inside the very
buffer the ring draws on top of.

New protocol request zcce_toplevel_v1.set_popover_region (manager global
now v7): the client reports the union rect of its open popovers in
surface-local logical px, zero size clears. The compositor stores it per
window; draw_borders feeds it to the frame node as an exclusion rect (in
the same zoom-scaled device px as the node's geometry) and the shader
discards inside it; get_border_zone declines there too, so nothing under
a menu can grab. The hint moves no window and changes no input routing.

Two traps found by testing rather than reading, recorded here so they are
paid for once. The manager's ADVERTISED version is a literal in
wl_global_create, not derived from the XML — bumping only the XML leaves
clients negotiating 6 and the gate version()>=7 silently never passing.
And the shader's rect-local p is y-UP (the flip mirrors gl_FragCoord
rather than cancelling it), so a top-left-origin rect compared unflipped
cut the ring at the bottom-left while the menu sat top-left, x exact.

Verified headless with cce-data-editor rebuilt against the new cce-ui:
File menu open + overview shows every entry legible with the ring cleanly
cut around the menu rect; closing the menu restores the ring (1465 ->
8524 ring px in the former rect).

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

 CLAUDE.md                                     |  7 +++++++
 protocol/cce-window-management-v1.xml         | 25 ++++++++++++++++++++++--
 scenefx/include/render/fx_renderer/shaders.h  |  1 +
 scenefx/include/scenefx/render/pass.h         |  2 ++
 scenefx/include/scenefx/types/wlr_scene.h     |  5 +++++
 scenefx/render/fx_renderer/fx_pass.c          |  2 ++
 scenefx/render/fx_renderer/shaders.c          |  1 +
 scenefx/render/fx_renderer/shaders/frame.frag | 17 ++++++++++++++++
 scenefx/types/scene/wlr_scene.c               | 15 ++++++++++++++
 src/server/cce_window_management.rs           | 28 +++++++++++++++++++++++++++
 src/server/cursor.rs                          | 11 +++++++++++
 src/server/scene.rs                           |  6 +++++-
 src/server/window.rs                          | 19 ++++++++++++++++++
 src/server/window_manager.rs                  | 13 +++++++------
 14 files changed, 143 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index dc5122a..1297dbf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -422,6 +422,13 @@ where the old band sat outside them.
   waiting for an unrelated redraw. The hit test and the invisible catcher
   rects are focused-gated too; hover-to-focus is what keeps that workable,
   since reaching a window's edge focuses it on the way.
+- A client drawing an in-surface popover (a cce-ui menu — one buffer with
+  the window since cce-ui's Phase 6x) hints its rect via
+  `zcce_toplevel_v1.set_popover_region` (manager v7); the ring is clipped
+  away beneath it (shader `exclusion`) and its band does not grab there, so
+  the menu reads as in front of the chrome. The protocol XML lives in BOTH
+  repos — cce-ui's copy strips the `enum="river_output_v1..."` attribute its
+  scanner cannot resolve; never sync the file over it wholesale.
 - The per-side foam clipping the outside band carried is gone: it split a gap
   SHARED with a neighbouring window, and an inside ring shares nothing.
 
diff --git a/protocol/cce-window-management-v1.xml b/protocol/cce-window-management-v1.xml
index f930381..c1189f7 100644
--- a/protocol/cce-window-management-v1.xml
+++ b/protocol/cce-window-management-v1.xml
@@ -28,7 +28,7 @@
     "should", "should not", "recommended", "may", and "optional" in this
     document are to be interpreted as described in IETF RFC 2119.
   </description>
-  <interface name="zcce_window_manager_v1" version="6">
+  <interface name="zcce_window_manager_v1" version="7">
     <description summary="window manager global interface">
       This global interface should only be advertised to the window manager
       process. Only one window management client may be active at a time. The
@@ -1490,7 +1490,7 @@
       </description>
     </event>
   </interface>
-  <interface name="zcce_toplevel_v1" version="4">
+  <interface name="zcce_toplevel_v1" version="5">
     <description summary="toplevel window management controls">
       An interface to control and listen to CCE-specific window management states
       for a client surface.
@@ -1579,6 +1579,27 @@
       </description>
       <arg name="state" type="uint" summary="1 if floating, 0 otherwise"/>
     </event>
+    <request name="set_popover_region" since="5">
+      <description summary="hint where an in-surface popover is drawn">
+        Tell the compositor that the client is drawing a popover (menu,
+        dropdown, context menu) into its own surface at this rect, in
+        surface-local logical coordinates. The compositor keeps its
+        window chrome — the overview resize ring — from drawing over
+        that rect, so the menu reads as being in front of it. With more
+        than one popover open, send the union. A zero width or height
+        clears the hint; closing the last popover must send that clear,
+        or the chrome stays cut where the menu used to be.
+
+        This is a rendering hint only: it moves no window, changes no
+        input routing, and carries no promise the compositor draws
+        anything differently at all.
+      </description>
+      <arg name="x" type="int"/>
+      <arg name="y" type="int"/>
+      <arg name="width" type="int"/>
+      <arg name="height" type="int"/>
+    </request>
+
     <event name="grid_patch" since="4">
       <description summary="render this patch of the desktop grid">
         Instructs the grid client what region of the virtual desktop to
diff --git a/scenefx/include/render/fx_renderer/shaders.h b/scenefx/include/render/fx_renderer/shaders.h
index 14a15aa..9f9f1fc 100644
--- a/scenefx/include/render/fx_renderer/shaders.h
+++ b/scenefx/include/render/fx_renderer/shaders.h
@@ -200,6 +200,7 @@ struct frame_shader {
 	GLint hover_color;
 	GLint swell_curve;
 	GLint bulge;
+	GLint exclusion;
 };
 
 bool link_frame_program(struct frame_shader *shader);
diff --git a/scenefx/include/scenefx/render/pass.h b/scenefx/include/scenefx/render/pass.h
index 864446f..2e66c4e 100644
--- a/scenefx/include/scenefx/render/pass.h
+++ b/scenefx/include/scenefx/render/pass.h
@@ -130,6 +130,8 @@ struct fx_render_frame_options {
 	float swell_curve;
 	/* Radius of the round pad on each corner, 0 to disable. */
 	float bulge;
+	/* Box-local rect the ring must not draw over; w/h <= 0 disables. */
+	float exclusion[4];
 	struct wlr_render_color color;
 };
 
diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index 86a338c..4bc38e9 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -213,6 +213,9 @@ struct wlr_scene_frame {
 	float swell_curve;
 	/** Radius of the round pad on each corner, 0 to disable. */
 	float bulge;
+	/** Node-local rect (x, y, w, h) the ring must not draw over — an
+	 * in-surface client popover. w or h <= 0 disables. */
+	float exclusion[4];
 	float color[4];
 };
 
@@ -693,6 +696,8 @@ void wlr_scene_frame_set_shape(struct wlr_scene_frame *frame, float band,
 void wlr_scene_frame_set_color(struct wlr_scene_frame *frame, const float color[static 4]);
 void wlr_scene_frame_set_hover(struct wlr_scene_frame *frame, float hovered,
 	const float color[static 4]);
+void wlr_scene_frame_set_exclusion(struct wlr_scene_frame *frame,
+	const float rect[static 4]);
 
 void wlr_scene_bevel_set_size(struct wlr_scene_bevel *bevel, int width, int height);
 void wlr_scene_bevel_set_corner_radius(struct wlr_scene_bevel *bevel, int radius);
diff --git a/scenefx/render/fx_renderer/fx_pass.c b/scenefx/render/fx_renderer/fx_pass.c
index 42a90ee..cd9aa44 100644
--- a/scenefx/render/fx_renderer/fx_pass.c
+++ b/scenefx/render/fx_renderer/fx_pass.c
@@ -1154,6 +1154,8 @@ void fx_render_pass_add_frame(struct fx_gles_render_pass *pass,
 	glUniform1f(renderer->shaders.frame.hovered, options->hovered);
 	glUniform1f(renderer->shaders.frame.swell_curve, options->swell_curve);
 	glUniform1f(renderer->shaders.frame.bulge, options->bulge);
+	glUniform4f(renderer->shaders.frame.exclusion, options->exclusion[0],
+			options->exclusion[1], options->exclusion[2], options->exclusion[3]);
 	glUniform4f(renderer->shaders.frame.hover_color,
 			options->hover_color[0], options->hover_color[1],
 			options->hover_color[2], options->hover_color[3]);
diff --git a/scenefx/render/fx_renderer/shaders.c b/scenefx/render/fx_renderer/shaders.c
index 460b9fe..a6d478b 100644
--- a/scenefx/render/fx_renderer/shaders.c
+++ b/scenefx/render/fx_renderer/shaders.c
@@ -403,6 +403,7 @@ bool link_frame_program(struct frame_shader *shader) {
 	shader->hover_color = glGetUniformLocation(prog, "hover_color");
 	shader->swell_curve = glGetUniformLocation(prog, "swell_curve");
 	shader->bulge = glGetUniformLocation(prog, "bulge");
+	shader->exclusion = glGetUniformLocation(prog, "exclusion");
 
 	return true;
 }
diff --git a/scenefx/render/fx_renderer/shaders/frame.frag b/scenefx/render/fx_renderer/shaders/frame.frag
index 94f70e5..618a430 100644
--- a/scenefx/render/fx_renderer/shaders/frame.frag
+++ b/scenefx/render/fx_renderer/shaders/frame.frag
@@ -41,6 +41,10 @@ uniform float gap;
 uniform float swell_curve;
 // Radius of the round pad on each corner, 0 to disable.
 uniform float bulge;
+// Node-local rect (x, y, w, h) the ring must not draw over — the client is
+// drawing an in-surface popover there and the menu must read as in FRONT of
+// the chrome. w or h <= 0 disables.
+uniform vec4 exclusion;
 // Zone under the pointer (see ZONE_* below), or < 0 for none.
 uniform float hovered;
 uniform vec4 hover_color;
@@ -84,6 +88,19 @@ void main() {
     vec2 p = gl_FragCoord.xy - position;
     p.y = size.y - p.y;
 
+    // A client popover owns this rect; the ring yields to it wholesale.
+    // The rect arrives top-left-origin (y down); p here is y-UP — the flip
+    // above mirrors gl_FragCoord's orientation rather than cancelling it —
+    // so the rect's y converts. (Found empirically: an un-flipped compare
+    // cut the bottom-left when the menu was top-left, with x exact.)
+    if (exclusion.z > 0.0 && exclusion.w > 0.0
+            && p.x >= exclusion.x && p.x < exclusion.x + exclusion.z) {
+        float ey = size.y - exclusion.y - exclusion.w;
+        if (p.y >= ey && p.y < ey + exclusion.w) {
+            discard;
+        }
+    }
+
     // Which side owns this fragment: whichever edge it sits nearer. The
     // comparison is on distance to the edge, so the split runs along the
     // diagonals and every corner is shared consistently by its two sides.
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index b9b0b66..15523cc 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -1196,6 +1196,15 @@ void wlr_scene_frame_set_shape(struct wlr_scene_frame *frame, float band,
 	scene_node_update(&frame->node, NULL);
 }
 
+void wlr_scene_frame_set_exclusion(struct wlr_scene_frame *frame,
+		const float rect[static 4]) {
+	if (memcmp(frame->exclusion, rect, sizeof(frame->exclusion)) == 0) {
+		return;
+	}
+	memcpy(frame->exclusion, rect, sizeof(frame->exclusion));
+	scene_node_update(&frame->node, NULL);
+}
+
 void wlr_scene_frame_set_color(struct wlr_scene_frame *frame, const float color[static 4]) {
 	if (memcmp(frame->color, color, sizeof(frame->color)) == 0) {
 		return;
@@ -2613,6 +2622,12 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 			.hovered = scene_frame->hovered,
 			.swell_curve = scene_frame->swell_curve,
 			.bulge = scene_frame->bulge,
+			.exclusion = {
+				scene_frame->exclusion[0] * data->scale,
+				scene_frame->exclusion[1] * data->scale,
+				scene_frame->exclusion[2] * data->scale,
+				scene_frame->exclusion[3] * data->scale,
+			},
 			.hover_color = {
 				scene_frame->hover_color[0],
 				scene_frame->hover_color[1],
diff --git a/src/server/cce_window_management.rs b/src/server/cce_window_management.rs
index fbab96f..5d43b40 100644
--- a/src/server/cce_window_management.rs
+++ b/src/server/cce_window_management.rs
@@ -406,6 +406,33 @@ unsafe extern "C" fn toplevel_ack_grid_patch(
     }
 }
 
+unsafe extern "C" fn toplevel_set_popover_region(
+    _client: *mut ffi::wl_client,
+    resource: *mut ffi::wl_resource,
+    x: i32,
+    y: i32,
+    width: i32,
+    height: i32,
+) {
+    let data = ffi::wl_resource_get_user_data(resource) as *mut CceToplevelData;
+    if data.is_null() {
+        return;
+    }
+    let server = (*data).server;
+    let window_key = (*data).window_key;
+    if let Some(window) = resolve_window(server, window_key) {
+        // Surface-local LOGICAL px; the consumers scale it themselves. No
+        // dirty_windowing: this changes no geometry, and the commit that
+        // drew the popover already schedules the frame that will redraw
+        // the ring through draw_borders.
+        (*window).popover_region = if width > 0 && height > 0 {
+            Some(ffi::wlr_box { x, y, width, height })
+        } else {
+            None
+        };
+    }
+}
+
 static CCE_TOPLEVEL_INTERFACE: ffi::zcce_toplevel_v1_interface = ffi::zcce_toplevel_v1_interface {
     destroy: Some(toplevel_destroy),
     set_floating: Some(toplevel_set_floating),
@@ -421,6 +448,7 @@ static CCE_TOPLEVEL_INTERFACE: ffi::zcce_toplevel_v1_interface = ffi::zcce_tople
     unset_utility: Some(toplevel_unset_utility),
     set_grid: Some(toplevel_set_grid),
     ack_grid_patch: Some(toplevel_ack_grid_patch),
+    set_popover_region: Some(toplevel_set_popover_region),
 };
 
 unsafe fn resolve_window(server: *mut Server, key: SlotMapKey) -> Option<*mut Window> {
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 96f7f75..3a9faa9 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -2847,6 +2847,17 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
     if rx < 0.0 || rx >= content_w || ry < 0.0 || ry >= content_h {
         return BorderZone::None;
     }
+    // A client popover (set_popover_region) owns its rect outright: the menu
+    // reads as in front of the chrome, so nothing under it may grab. Checked
+    // before the pads and the band — it beats both.
+    if let Some(r) = (*window).popover_region {
+        let (ex, ey) = (r.x as f64 * scale, r.y as f64 * scale);
+        let (ew, eh) = (r.width as f64 * scale, r.height as f64 * scale);
+        if rx >= ex && rx < ex + ew && ry >= ey && ry < ey + eh {
+            return BorderZone::None;
+        }
+    }
+
     // The corner pads reach further inward than the band (their radius plus
     // the corner arc), and what is drawn must grab: a point within a pad's
     // radius of its corner resizes on that corner's two edges.
diff --git a/src/server/scene.rs b/src/server/scene.rs
index e720a2d..f181f53 100644
--- a/src/server/scene.rs
+++ b/src/server/scene.rs
@@ -103,9 +103,13 @@ impl Scene {
         self.layers.top = ffi::wlr_scene_tree_create(normal_tree);
         self.layers.fullscreen = ffi::wlr_scene_tree_create(normal_tree);
         self.layers.overlay = ffi::wlr_scene_tree_create(normal_tree);
+        // Window decorations (the resize ring) sit above every window but
+        // BELOW popups: a cce-ui dropdown is a separate Popup-mode window in
+        // layers.popups, and a menu must never be drawn under the chrome of
+        // the window that opened it. Creation order is stacking order.
+        self.layers.border_overlay = ffi::wlr_scene_tree_create(normal_tree);
         self.layers.popups = ffi::wlr_scene_tree_create(normal_tree);
         self.layers.override_redirect = ffi::wlr_scene_tree_create(normal_tree);
-        self.layers.border_overlay = ffi::wlr_scene_tree_create(normal_tree);
 
         if self.layers.border_overlay.is_null()
             || self.layers.background.is_null()
diff --git a/src/server/window.rs b/src/server/window.rs
index 4a029b6..05ff877 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -428,6 +428,11 @@ pub struct Window {
     pub resize_start_w: u32,
     pub resize_start_h: u32,
     pub resize_edges: Option<Edges>,
+    /// Client hint: an in-surface popover (menu/dropdown) covers this rect,
+    /// surface-local logical px (zcce set_popover_region). The overview
+    /// resize ring is clipped away beneath it and its band does not grab
+    /// there — the menu reads as in front of the chrome.
+    pub popover_region: Option<ffi::wlr_box>,
     /// The client resized itself and the new-size buffer is already on screen, so
     /// `render_finish` must take the size from the live commit rather than the
     /// render-start snapshot (`rendering_sent`), which still holds the previous
@@ -693,6 +698,7 @@ impl Window {
             resize_start_w: 0,
             resize_start_h: 0,
             resize_edges: None,
+            popover_region: None,
             self_resized: false,
             status_collapsed_len: 0,
             stream_dirty: true,
@@ -3824,6 +3830,19 @@ impl Window {
                 layout.border_swell_curve,
                 (layout.border_corner_bulge as f64).min(short_side * 0.3) as f32,
             );
+            // The popover hint arrives in surface-local LOGICAL px; the node
+            // space is zoom-scaled device px like everything else here, so it
+            // takes the same px() mapping. Zeroed when clear.
+            let ex = match self.popover_region {
+                Some(r) => [
+                    px(r.x) as f32,
+                    px(r.y) as f32,
+                    px(r.width) as f32,
+                    px(r.height) as f32,
+                ],
+                None => [0.0; 4],
+            };
+            ffi::wlr_scene_frame_set_exclusion(self.border.frame, ex.as_ptr());
             ffi::wlr_scene_frame_set_color(self.border.frame, premul(&border_color).as_ptr());
             let hovered = self
                 .hovered_border_element
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 5d45165..18edc1a 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -450,12 +450,13 @@ impl WindowManager {
         self.global = ffi::wl_global_create(
             (*server).wl_server,
             &ffi::zcce_window_manager_v1_interface,
-            // 6 = grid support (toplevel v4: set_grid/grid_patch/ack);
-            // 5 = set_utility exists on toplevels. Clients feature-gate on
-            // the negotiated version, so one launched into an older
-            // compositor degrades gracefully instead of dying on an
-            // unknown opcode.
-            6,
+            // 7 = set_popover_region on toplevels (the in-surface menu
+            // hint); 6 = grid support (toplevel v4: set_grid/grid_patch/
+            // ack); 5 = set_utility exists on toplevels. Clients
+            // feature-gate on the negotiated version, so one launched into
+            // an older compositor degrades gracefully instead of dying on
+            // an unknown opcode.
+            7,
             self as *mut WindowManager as *mut _,
             Some(bind),
         );