Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat: set_utility — content-shaped windows the compositor never resizes
zcce_toplevel_v1 v3 gains set_utility/unset_utility, modelled on
set_popup: resolve, set TilingMode::Utility, mode_locked = true (the
existing explicit-beats-heuristic latch; get_mode_for_window needs no
new arm). unset also resets the raw field to Floating — every Utility
gate reads tiling_mode directly, unlike Popup's resolution.
The manager global advertises 5 so clients can feature-gate: launched
into an older compositor, an app degrades to a plain floating window
instead of dying on an unknown opcode.
What the mode means, mechanically:
- Self-sizing: the map/initial-commit box_geom copies and the Overlay
live-geometry sync now cover Utility, so the client's committed
geometry IS the box; the arrange pass only ever sends it the
"you choose" 0x0 (cce-window-manager side of this change). The
styling half of is_status (blur config, radius 0) stays status-only.
- Movable, never resizable: get_border_zone maps the WHOLE band —
corners and side edges included — to Move (the border is the only
grab surface; dead corners would be hostile). The paths the zones
never see are rejected on their own: the Action::Resize modifier
drag, client request_resize, and request_maximize.
- Never re-classed by a drag: all four un-tile-before-motion sites
(op_update, the modifier drag, the border move, client request_move)
exempt Utility, so the drop-time geometric promotion never sees it.
The double-click tile-toggle falls through to an ordinary move.
- Never saved: the save loop skips it (clean-exit loops do not — it
must still be closed), and try_restore skips it as a belt over the
0x0 self-heal.
- Desk citizen: focus-follow pan, place-next hints, dimension-hint
arrange scheduling and xwayland's not-tiled report all include it.
Verified in a shadow session: cce-relief maps at its own 520x913, a
left-border drag moves it without resizing, the drop leaves it
Utility, a top-border double-click does not tile it, and stopping the
session writes a state.json with no cce-relief entry.
Co-Authored-By: Claude <[email protected]>
protocol/cce-window-management-v1.xml | 19 ++++++++++--
src/server/cce_window_management.rs | 43 +++++++++++++++++++++++++++
src/server/config.rs | 1 +
src/server/cursor.rs | 32 +++++++++++++++++++-
src/server/seat.rs | 8 ++++-
src/server/window.rs | 19 ++++++++++--
src/server/window_manager.rs | 13 +++++++-
src/server/xdg_toplevel.rs | 56 ++++++++++++++++++++++++++++-------
src/server/xwayland_window.rs | 2 +-
9 files changed, 174 insertions(+), 19 deletions(-)
diff --git a/protocol/cce-window-management-v1.xml b/protocol/cce-window-management-v1.xml
index 53b536f..ce824a2 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="4">
+ <interface name="zcce_window_manager_v1" version="5">
<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="2">
+ <interface name="zcce_toplevel_v1" version="3">
<description summary="toplevel window management controls">
An interface to control and listen to CCE-specific window management states
for a client surface.
@@ -1533,6 +1533,21 @@
Request the compositor to return the window to normal tiling mode.
</description>
</request>
+ <request name="set_utility" since="3">
+ <description summary="request utility mode">
+ Declare the window a utility window: a tool whose shape is decided by
+ its contents. The compositor stops dictating a size to it (every
+ configure carries the "you choose" 0x0), offers no resize affordance,
+ and never saves geometry for it. The window still floats and moves
+ like an ordinary window. The declaration is explicit by design — the
+ compositor never infers utility mode from size hints.
+ </description>
+ </request>
+ <request name="unset_utility" since="3">
+ <description summary="unset utility mode">
+ Request the compositor to return the window to normal tiling mode.
+ </description>
+ </request>
<event name="floating_state">
<description summary="floating status event">
Sent by the compositor to inform the client of its current floating status.
diff --git a/src/server/cce_window_management.rs b/src/server/cce_window_management.rs
index 3bd2ffd..e2c41f2 100644
--- a/src/server/cce_window_management.rs
+++ b/src/server/cce_window_management.rs
@@ -265,6 +265,47 @@ unsafe extern "C" fn toplevel_unset_popup(
}
}
+unsafe extern "C" fn toplevel_set_utility(
+ _client: *mut ffi::wl_client,
+ resource: *mut ffi::wl_resource,
+) {
+ 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) {
+ // mode_locked is the "explicit beats heuristic" latch (same as
+ // set_popup): get_mode_for_window's app_id guessing stands down.
+ (*window).tiling_mode = crate::tiling::TilingMode::Utility;
+ (*window).mode_locked = true;
+ (*server).wm.dirty_windowing();
+ }
+}
+
+unsafe extern "C" fn toplevel_unset_utility(
+ _client: *mut ffi::wl_client,
+ resource: *mut ffi::wl_resource,
+) {
+ 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) {
+ // Unlike unset_popup, the raw field must be reset too: every Utility
+ // gate (resize rejection, save exclusion, the un-tile exemptions)
+ // reads `tiling_mode` directly, so leaving it at Utility with the
+ // lock cleared would keep the window un-resizable and unsaved while
+ // resolving Floating.
+ (*window).tiling_mode = crate::tiling::TilingMode::Floating;
+ (*window).mode_locked = false;
+ (*server).wm.dirty_windowing();
+ }
+}
+
static CCE_TOPLEVEL_INTERFACE: ffi::zcce_toplevel_v1_interface = ffi::zcce_toplevel_v1_interface {
destroy: Some(toplevel_destroy),
set_floating: Some(toplevel_set_floating),
@@ -276,6 +317,8 @@ static CCE_TOPLEVEL_INTERFACE: ffi::zcce_toplevel_v1_interface = ffi::zcce_tople
set_minimized: Some(toplevel_set_minimized),
set_popup: Some(toplevel_set_popup),
unset_popup: Some(toplevel_unset_popup),
+ set_utility: Some(toplevel_set_utility),
+ unset_utility: Some(toplevel_unset_utility),
};
unsafe fn resolve_window(server: *mut Server, key: SlotMapKey) -> Option<*mut Window> {
diff --git a/src/server/config.rs b/src/server/config.rs
index 63a9979..90f246b 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -855,6 +855,7 @@ pub fn parse_tiling_mode(s: &str) -> TilingMode {
"popup" => TilingMode::Popup,
"sidepanel" | "side_panel" | "side-panel" | "pinned" | "overlay" => TilingMode::Overlay,
"status" => TilingMode::Status,
+ "utility" => TilingMode::Utility,
// "maximized" is the retired name for grid-locked windows.
"tiled" | "maximized" => TilingMode::Tiled,
// Everything else — including the retired "cascade"/"grid" layout
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 2456bd3..74bc847 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -1211,6 +1211,8 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
&& (*target_win).tiling_mode != crate::tiling::TilingMode::Popup
&& (*target_win).tiling_mode != crate::tiling::TilingMode::Fullscreen
&& (*target_win).tiling_mode != crate::tiling::TilingMode::Overlay
+ // A drag moves a Utility window; it must not re-class it.
+ && (*target_win).tiling_mode != crate::tiling::TilingMode::Utility
{
// A tiled window un-tiles for the drag but keeps its
// cell-quantized geometry; landing grid-aligned re-tiles
@@ -1223,6 +1225,13 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
let op_type = match pb.action {
crate::config::Action::Move => Some(crate::seat::PointerOpType::Move),
+ // The modifier binding is a resize path the border zones
+ // never see, so it carries its own Utility rejection.
+ crate::config::Action::Resize
+ if (*target_win).tiling_mode == crate::tiling::TilingMode::Utility =>
+ {
+ None
+ }
crate::config::Action::Resize => {
let edges = get_closest_edges(target_win, lx, ly);
Some(crate::seat::PointerOpType::Resize { edges })
@@ -1306,6 +1315,11 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
match zone {
BorderZone::Resize(edges) => {
if (*event).button == 0x110 { // BTN_LEFT
+ // Unreachable for Utility (get_border_zone maps its
+ // whole band to Move), and gated here regardless.
+ if initial_mode == crate::tiling::TilingMode::Utility {
+ return;
+ }
if initial_mode != crate::tiling::TilingMode::Floating {
// A tiled window un-tiles for the drag but keeps its
// cell-quantized geometry; landing grid-aligned re-tiles
@@ -1360,7 +1374,9 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
cursor.last_click_time = current_time;
cursor.last_click_window = border_target_win;
- if is_double_click {
+ // A Utility window has no Tiled state to toggle;
+ // the double-click falls through to an ordinary move.
+ if is_double_click && initial_mode != crate::tiling::TilingMode::Utility {
if initial_mode == crate::tiling::TilingMode::Tiled {
(*border_target_win).tiling_mode = crate::tiling::TilingMode::Floating;
(*border_target_win).mode_locked = true;
@@ -1377,6 +1393,9 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
if initial_mode != crate::tiling::TilingMode::Floating
&& initial_mode != crate::tiling::TilingMode::Overlay
+ // A drag moves a Utility window; it must not
+ // re-class it.
+ && initial_mode != crate::tiling::TilingMode::Utility
{
// A tiled window un-tiles for the drag but keeps its
// cell-quantized geometry; landing grid-aligned re-tiles
@@ -2595,6 +2614,11 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
{
return BorderZone::None;
}
+
+ // A Utility window is movable but never resizable, and its border is its
+ // ONLY grab surface — so instead of deadening the resize zones, the whole
+ // band (corners and side edges included) becomes a move handle.
+ let resize_allowed = (*window).tiling_mode != crate::tiling::TilingMode::Utility;
if (*window).rendering_requested.circular {
return BorderZone::None;
@@ -2650,6 +2674,9 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
let near_bottom = dist_bottom < corner_len && dist_bottom < dist_top;
if (near_left || near_right) && (near_top || near_bottom) {
+ if !resize_allowed {
+ return BorderZone::Move;
+ }
return BorderZone::Resize(crate::window::Edges {
top: near_top,
bottom: near_bottom,
@@ -2669,6 +2696,9 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
right: rx >= content_w,
};
if edges.bottom || edges.left || edges.right {
+ if !resize_allowed {
+ return BorderZone::Move;
+ }
return BorderZone::Resize(edges);
}
}
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 0729924..51c0706 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -809,7 +809,11 @@ impl Seat {
if window.is_null()
|| !matches!(
(*window).tiling_mode,
- crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Tiled
+ // Utility included: it pans on the virtual surface like any
+ // floating window, so focusing one off-view should bring it in.
+ crate::tiling::TilingMode::Floating
+ | crate::tiling::TilingMode::Tiled
+ | crate::tiling::TilingMode::Utility
)
{
return;
@@ -1020,6 +1024,8 @@ impl Seat {
if !win.is_null() && !(*win).closed {
if (*win).tiling_mode != crate::tiling::TilingMode::Floating
&& (*win).tiling_mode != crate::tiling::TilingMode::Overlay
+ // A drag moves a Utility window; it must not re-class it.
+ && (*win).tiling_mode != crate::tiling::TilingMode::Utility
{
// Un-tile for the drag but KEEP the geometry (clearing
// was_tiled suppresses the arrange Exit restore);
diff --git a/src/server/window.rs b/src/server/window.rs
index 61e1bb4..6e2f054 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -903,6 +903,15 @@ impl Window {
if self.restored {
return;
}
+ // No geometry is ever saved for a Utility window, so none may be
+ // restored over it — a pre-Utility state.json entry for the same
+ // app_id would otherwise dictate a stale size to a self-sizing
+ // client. (Belt over suspenders: the arrange pass restates the
+ // "you choose" 0x0 for Utility anyway, so even a slipped-through
+ // restore heals on the client's next commit.)
+ if self.tiling_mode == crate::tiling::TilingMode::Utility {
+ return;
+ }
let app_id_str = self.get_app_id_string().unwrap_or_default();
if app_id_str.is_empty() || app_id_str.starts_with("cce-status") || app_id_str == "cce-wallpaper" {
return;
@@ -970,7 +979,12 @@ impl Window {
/// overridden — and marks `hint_placed` so the spawn viewport pan is
/// skipped (the window is already under the user's pointer).
unsafe fn try_hint_placement(&mut self) {
- if self.tiling_mode != crate::tiling::TilingMode::Floating {
+ // Utility included: the hint moves only the POSITION, which a utility
+ // window does not own — only its size is the client's.
+ if !matches!(
+ self.tiling_mode,
+ crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Utility
+ ) {
return;
}
let app_id = self.get_app_id_string().unwrap_or_default();
@@ -1320,7 +1334,8 @@ impl Window {
if self.wm_sent.dimensions_hint != hint {
// Overlay included: a self-sizing overlay (cce-cloud) changes its hint
// on every resize, and skipping it meant no arrange pass was scheduled.
- if matches!(self.tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup | crate::tiling::TilingMode::Status | crate::tiling::TilingMode::Overlay) {
+ // Utility for the same reason: it is self-sizing by definition.
+ if matches!(self.tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup | crate::tiling::TilingMode::Status | crate::tiling::TilingMode::Overlay | crate::tiling::TilingMode::Utility) {
(*self.server).wm.dirty_windowing();
}
self.wm_sent.dimensions_hint = hint;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 1097879..5dd03d7 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -366,7 +366,11 @@ impl WindowManager {
self.global = ffi::wl_global_create(
(*server).wl_server,
&ffi::zcce_window_manager_v1_interface,
- 4,
+ // 5 = set_utility exists on toplevels; clients feature-gate on
+ // the negotiated version, so one launched into an older
+ // compositor degrades to a plain floating window instead of
+ // dying on an unknown opcode.
+ 5,
self as *mut WindowManager as *mut _,
Some(bind),
);
@@ -612,6 +616,13 @@ impl WindowManager {
if (*w).is_status_bar() || (*w).is_wallpaper() {
continue;
}
+ // A Utility window owns its geometry entirely; saving it would
+ // let a later session restore a size over the client's request —
+ // the exact bug the mode deletes. (The clean-exit loops below do
+ // NOT skip it: it is a real window and must still be closed.)
+ if (*w).tiling_mode == crate::tiling::TilingMode::Utility {
+ continue;
+ }
let app_id = (*w).get_app_id_string().unwrap_or_default();
if app_id.is_empty() {
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 21a9855..751e7d0 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -459,9 +459,13 @@ unsafe extern "C" fn handle_map(listener: *mut ffi::wl_listener, _data: *mut std
let mut new_geometry = std::mem::zeroed();
ffi::river_wlr_xdg_surface_get_geometry(base, &mut new_geometry);
(*toplevel).geometry = new_geometry;
- let is_status = (*(*toplevel).window).tiling_mode == crate::tiling::TilingMode::Status ||
- (*(*toplevel).window).get_app_id_string().map_or(false, |id| id.starts_with("cce-status"));
- if is_status {
+ // Status segments and Utility windows are SELF-sizing: their bounds
+ // track their own box, so the committed geometry is adopted as the box.
+ let is_self_sized = matches!(
+ (*(*toplevel).window).tiling_mode,
+ crate::tiling::TilingMode::Status | crate::tiling::TilingMode::Utility
+ ) || (*(*toplevel).window).get_app_id_string().map_or(false, |id| id.starts_with("cce-status"));
+ if is_self_sized {
(*(*toplevel).window).box_geom.width = new_geometry.width;
(*(*toplevel).window).box_geom.height = new_geometry.height;
(*(*(*toplevel).window).server).wm.dirty_windowing();
@@ -511,9 +515,13 @@ unsafe extern "C" fn handle_ack_configure(
ffi::river_wlr_xdg_surface_get_geometry(base, &mut new_geometry);
(*toplevel).geometry = new_geometry;
- let is_status = (*(*toplevel).window).tiling_mode == crate::tiling::TilingMode::Status ||
- (*(*toplevel).window).get_app_id_string().map_or(false, |id| id.starts_with("cce-status"));
- if is_status {
+ // Status segments and Utility windows are SELF-sizing: their bounds
+ // track their own box, so the committed geometry is adopted as the box.
+ let is_self_sized = matches!(
+ (*(*toplevel).window).tiling_mode,
+ crate::tiling::TilingMode::Status | crate::tiling::TilingMode::Utility
+ ) || (*(*toplevel).window).get_app_id_string().map_or(false, |id| id.starts_with("cce-status"));
+ if is_self_sized {
(*(*toplevel).window).box_geom.width = new_geometry.width;
(*(*toplevel).window).box_geom.height = new_geometry.height;
(*(*(*toplevel).window).server).wm.dirty_windowing();
@@ -655,9 +663,11 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
ffi::river_wlr_xdg_surface_get_geometry(base, &mut new_geometry);
(*toplevel).geometry = new_geometry;
- let is_status = (*window).tiling_mode == crate::tiling::TilingMode::Status ||
- (*window).get_app_id_string().map_or(false, |id| id.starts_with("cce-status"));
- if is_status {
+ let is_self_sized = matches!(
+ (*window).tiling_mode,
+ crate::tiling::TilingMode::Status | crate::tiling::TilingMode::Utility
+ ) || (*window).get_app_id_string().map_or(false, |id| id.starts_with("cce-status"));
+ if is_self_sized {
(*window).box_geom.width = new_geometry.width;
(*window).box_geom.height = new_geometry.height;
}
@@ -677,7 +687,13 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
// here instead and move the border with it, in the same commit that puts the
// new buffer on screen — waiting for the WM cycle (which round-trips out to
// the external window-manager client) leaves the border a size behind.
- if (*window).tiling_mode == crate::tiling::TilingMode::Overlay {
+ // Utility windows self-size the same way (the arrange pass only ever
+ // sends them the "you choose" 0x0, so every size change originates in a
+ // client commit like this one).
+ if matches!(
+ (*window).tiling_mode,
+ crate::tiling::TilingMode::Overlay | crate::tiling::TilingMode::Utility
+ ) {
let mut live = std::mem::zeroed();
ffi::river_wlr_xdg_surface_get_geometry(base, &mut live);
if live.width > 0 && live.height > 0
@@ -741,7 +757,8 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
// Overlay included so its scheduled size tracks the client's own; the
// border itself is handled by the live-geometry sync above.
let is_overlay = (*window).tiling_mode == crate::tiling::TilingMode::Overlay;
- if matches!((*window).tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup) || is_status || is_overlay {
+ let is_utility = (*window).tiling_mode == crate::tiling::TilingMode::Utility;
+ if matches!((*window).tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup) || is_status || is_overlay || is_utility {
(*window).set_dimensions(new_geometry.width as u32, new_geometry.height as u32);
if is_status {
(*window).box_geom.width = new_geometry.width;
@@ -900,6 +917,13 @@ unsafe extern "C" fn handle_request_maximize(listener: *mut ffi::wl_listener, _d
let toplevel = crate::container_of!(listener, XdgToplevel, request_maximize);
let window = (*toplevel).window;
+ // A Utility window declared itself content-shaped; a maximize would hand
+ // sizing back to the compositor. Explicitly ignored, not just unmapped
+ // from any affordance.
+ if (*window).tiling_mode == crate::tiling::TilingMode::Utility {
+ return;
+ }
+
if ffi::river_wlr_xdg_toplevel_get_requested_maximized((*toplevel).wlr_toplevel) {
(*window).tiling_mode = crate::tiling::TilingMode::Tiled;
(*window).mode_locked = true;
@@ -935,6 +959,8 @@ unsafe extern "C" fn handle_request_move(
&& initial_mode != crate::tiling::TilingMode::Popup
&& initial_mode != crate::tiling::TilingMode::Fullscreen
&& initial_mode != crate::tiling::TilingMode::Overlay
+ // A move must not cost a window its Utility mode.
+ && initial_mode != crate::tiling::TilingMode::Utility
{
(*window).tiling_mode = crate::tiling::TilingMode::Floating;
(*window).mode_locked = true;
@@ -983,6 +1009,14 @@ unsafe extern "C" fn handle_request_resize(
let toplevel = crate::container_of!(listener, XdgToplevel, request_resize);
let event = data as *mut ffi::wlr_xdg_toplevel_resize_event;
let window = (*toplevel).window;
+
+ // Nothing may interactively resize a Utility window — its size is the
+ // client's `settings()` data, not a drag. Rejected at the request, not
+ // just left without an affordance.
+ if (*window).tiling_mode == crate::tiling::TilingMode::Utility {
+ return;
+ }
+
let seat = ffi::river_wlr_seat_get_data((*(*event).seat).seat) as *mut crate::seat::Seat;
if ffi::wlr_seat_validate_pointer_grab_serial((*seat).wlr_seat, std::ptr::null_mut(), (*event).serial) {
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index e5f4bc4..dc80477 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -418,7 +418,7 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
}
let is_tiled = unsafe {
- (*window).wm_requested.tiled != 0 || !matches!((*window).tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup)
+ (*window).wm_requested.tiled != 0 || !matches!((*window).tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup | crate::tiling::TilingMode::Utility)
};
let is_fullscreen = unsafe { (*window).is_fullscreen() };