git.lucas.co / cce-window-manager
window management library
git clone https://git.lucas.co/cce-window-manager.git

commitb573b606f8a956c696d611bb631e780dcd2ddffd
parent708e6e083b
authorLucas Galante <[email protected]>
date2026-08-16 22:52
feat: TilingMode::Utility — the client owns the size, always

A Utility window is a tool whose shape is decided by its contents
(cce-relief is the motivating case): Status minus the docking. It
floats, moves and pans like any window, but the arrange pass restates
the xdg "you choose" 0x0 on EVERY pass — extending the fresh-floating-
spawn precedent — so the compositor can never dictate a size to it:
not from a restored state.json entry, not after an output or scale
change (the client re-commits at the new scale and the commit path
adopts that as the box). The offscreen cull still reads the real box.

Never inferred from size hints; assigned only by the explicit
set_utility request on the cce window-management protocol.

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

 src/arrange.rs | 45 +++++++++++++++++++++++++++++++++++++++++++--
 src/tiling.rs  | 13 +++++++++++--
 2 files changed, 54 insertions(+), 4 deletions(-)

diff --git a/src/arrange.rs b/src/arrange.rs
index e91acf1..99f6b15 100644
--- a/src/arrange.rs
+++ b/src/arrange.rs
@@ -494,7 +494,7 @@ pub fn place_normal_window(
             // adopts that as the box. Guessing from the min-size hint locked
             // self-sizing clients (every cce-ui app) to their minimum — they
             // obey any nonzero configure, so the guess became the box forever.
-            if snap.mode == TilingMode::Floating
+            if matches!(snap.mode, TilingMode::Floating | TilingMode::Utility)
                 && snap.active_resize.is_none()
                 && snap.box_geom.width <= 0
                 && snap.box_geom.height <= 0
@@ -534,10 +534,22 @@ pub fn place_normal_window(
 
             let (final_x, final_y) = ctx.virtual_to_screen(snap.virtual_pos.0, snap.virtual_pos.1);
 
+            // A Utility window's size is the client's alone: the plan restates
+            // the "you choose" 0x0 on EVERY pass, so the compositor never
+            // dictates a size to it — not from a restore, not after an output
+            // or scale change (the client re-commits at the new scale and the
+            // commit path adopts that as the box). The cull still uses the
+            // real box; only the configure size is withheld.
+            let size = if snap.mode == TilingMode::Utility {
+                (0, 0)
+            } else {
+                (fw as u32, fh as u32)
+            };
+
             NormalPlacement {
                 pos: (final_x, final_y),
                 scale: ctx.zoom,
-                size: (fw as u32, fh as u32),
+                size,
                 tiled_all_edges: false,
                 hidden: Some(ctx.is_offscreen(final_x, final_y, fw as f64 * ctx.zoom, fh as f64 * ctx.zoom)),
                 virtual_write: None,
@@ -1689,6 +1701,35 @@ mod tests {
         assert_eq!(placement.size, (320, 240));
     }
 
+    #[test]
+    fn utility_window_size_is_always_client_chosen() {
+        // A Utility window restates the "you choose" 0x0 on EVERY pass — even
+        // with an established box — so the compositor can never dictate a
+        // size to it (a restored size, an output change). The established box
+        // still drives the offscreen cull.
+        let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
+        let fresh = NormalSnapshot {
+            mode: TilingMode::Utility,
+            box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
+            min_size: (320, 240),
+            virtual_pos: (100.0, 200.0),
+            active_resize: None,
+            is_cloud: false,
+        };
+        let placement = place_normal_window(&fresh, &p, &ctx());
+        assert_eq!(placement.size, (0, 0));
+        assert_eq!(placement.hidden, None);
+
+        let established = NormalSnapshot {
+            box_geom: Rect { x: 0, y: 0, width: 520, height: 896 },
+            ..fresh
+        };
+        let placement = place_normal_window(&established, &p, &ctx());
+        assert_eq!(placement.size, (0, 0));
+        // The cull is computed (from the real box), unlike the unmapped case.
+        assert!(placement.hidden.is_some());
+    }
+
     #[test]
     fn pannable_window_follows_viewport() {
         let snap = NormalSnapshot {
diff --git a/src/tiling.rs b/src/tiling.rs
index f2220df..c630888 100644
--- a/src/tiling.rs
+++ b/src/tiling.rs
@@ -3,8 +3,15 @@
 // A window is either `Floating` (positioned freely on the virtual surface) or
 // `Tiled` (every content edge lies on a visible desktop-grid cell edge). Tiled
 // windows report the xdg maximized state to their client. The remaining
-// variants are internal roles (`Popup`, `Overlay`, `Status`) or the orthogonal
-// `Fullscreen` toggle.
+// variants are internal roles (`Popup`, `Overlay`, `Status`, `Utility`) or the
+// orthogonal `Fullscreen` toggle.
+//
+// `Utility` is `Status` minus the docking: a tool window whose shape is decided
+// by its contents (stacked sliders, fixed rows, nothing worth dragging). The
+// client owns the size, the compositor offers no resize affordance and saves no
+// geometry for it, but it floats and moves like any ordinary window. It is
+// never inferred from a sizing hint — a window is `Utility` only because the
+// client said so, via `set_utility` on the cce window-management protocol.
 //
 // Serde aliases keep old `state.json` files loading: the retired `Cascade` /
 // `Grid` layout modes collapse to `Floating`, and `Maximized` (the old name
@@ -20,6 +27,7 @@ pub enum TilingMode {
     Popup,
     Overlay,
     Status,
+    Utility,
 }
 
 impl TilingMode {
@@ -31,6 +39,7 @@ impl TilingMode {
             TilingMode::Popup => "Popup",
             TilingMode::Overlay => "Overlay",
             TilingMode::Status => "Status",
+            TilingMode::Utility => "Utility",
         }
     }
 }