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

commit0ce7dfeefecb4e5757ccef44e8e3bc313e194416
parentf3c35055d8
authorLucas Galante <[email protected]>
date2026-08-16 22:52
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() };