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

commitd517e66f2eec08cab9ade8959c7a455792783cfa
parentb573b606f8
authorLucas Galante <[email protected]>
date2026-08-17 15:36
feat: WindowRole::Grid — world-anchored grid-client placement

The desktop grid can now be a CLIENT surface (app_id "cce-grid" or the
set_grid protocol declaration): arrange anchors it to its GridPatch
(virtual rect + buffer px per virtual unit) at display scale
zoom/patch.scale, so the compositor transforms it per frame in lockstep
with window content and the client is never in the camera loop. Without
a latched patch the window stays out of the scene, and it always gets
the xdg "you choose" 0x0 (a window with no planned dimensions can never
leave Ready — the map state machine requires one). ArrangePlan grows
grid_cells_enabled: the compositor's cell lattice yields while a live
grid client covers the desktop; the gap backdrop stays as the safety
net beyond patch edges.

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

 src/api.rs     |  25 ++++++++++++++
 src/arrange.rs | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 127 insertions(+)

diff --git a/src/api.rs b/src/api.rs
index cb128c7..21096fb 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -31,14 +31,24 @@ pub enum WindowRole {
     StatusBar,
     Background,
     Overlay,
+    /// The desktop-grid layer: a client surface world-anchored to a patch of
+    /// the virtual desktop (`WindowSnapshot::grid_patch`). The compositor
+    /// pans/zooms it per frame exactly like window content — the client is
+    /// never in the frame loop; it re-renders only when handed a new patch.
+    /// Input-transparent, stacked above the wallpaper and below everything
+    /// else.
+    Grid,
 }
 
 impl WindowRole {
     /// The single place the special app_id conventions are interpreted.
     /// `Overlay` is never derived from an app_id — it comes from tiling mode.
+    /// `Grid` also has a protocol declaration (`set_grid`); the app_id match
+    /// makes the fallback swap and placement correct from map time.
     pub fn from_app_id(app_id: Option<&str>) -> Self {
         match app_id {
             Some("cce-wallpaper") => WindowRole::Background,
+            Some("cce-grid") => WindowRole::Grid,
             Some(id) if id.starts_with("cce-status") => WindowRole::StatusBar,
             _ => WindowRole::Normal,
         }
@@ -187,6 +197,21 @@ pub struct Rect {
     pub height: i32,
 }
 
+/// The world-anchored patch a grid client's buffer covers: virtual origin and
+/// size, plus the buffer resolution. The compositor issues patches
+/// (grid_patch events) and latches one when the client's rendered buffer
+/// arrives; arrange places the surface at `(x, y)` with display scale
+/// `zoom / scale`, so the buffer pans and zooms in lockstep with windows.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct GridPatch {
+    pub x: f64,
+    pub y: f64,
+    pub w: f64,
+    pub h: f64,
+    /// Buffer px per virtual unit.
+    pub scale: f64,
+}
+
 /// Premultiplied-alpha RGBA, 0.0–1.0 per channel (scenefx convention).
 #[derive(Debug, Clone, Copy, PartialEq)]
 pub struct Rgba(pub [f32; 4]);
diff --git a/src/arrange.rs b/src/arrange.rs
index 99f6b15..ce73d11 100644
--- a/src/arrange.rs
+++ b/src/arrange.rs
@@ -155,6 +155,9 @@ pub fn compute_usable_area(
 pub enum WindowClass {
     Background,
     StatusBar,
+    /// The world-anchored grid client (role `Grid`): placed at its patch's
+    /// virtual origin with scale `zoom / patch.scale`.
+    Grid,
     Hidden,
     Overlay,
     Normal,
@@ -170,6 +173,13 @@ pub fn classify_window(
     match role {
         WindowRole::Background => WindowClass::Background,
         WindowRole::StatusBar => WindowClass::StatusBar,
+        WindowRole::Grid => {
+            if closing_or_init {
+                WindowClass::Hidden
+            } else {
+                WindowClass::Grid
+            }
+        }
         _ => {
             if minimized || closing_or_init {
                 WindowClass::Hidden
@@ -917,6 +927,10 @@ pub struct WindowSnapshot {
     pub was_tiled: bool,
     pub saved_floating_size: (i32, i32),
     pub saved_floating_virtual: (f64, f64),
+    /// Grid-role windows only: the latched world-anchored patch the current
+    /// buffer covers. `None` until the first rendered patch arrives (the
+    /// window stays out of the scene until then).
+    pub grid_patch: Option<crate::api::GridPatch>,
 }
 
 /// Frame-wide inputs: config knobs plus the desktop viewport.
@@ -979,6 +993,10 @@ pub struct ArrangePlan {
     /// Whether each output's fallback background rect should be shown
     /// (false while a wallpaper window exists).
     pub background_rect_enabled: bool,
+    /// Whether the compositor-drawn cell lattice should be shown: false
+    /// while a live grid client (role Grid, mapped, with a latched patch)
+    /// covers the desktop. The gap-colored backdrop stays either way.
+    pub grid_cells_enabled: bool,
 }
 
 fn is_cloud_app(app_id: Option<&str>) -> bool {
@@ -1025,6 +1043,9 @@ pub fn arrange(
     let mut plan: Vec<WindowPlan> = vec![WindowPlan::default(); windows.len()];
 
     let has_wallpaper = state.iter().any(|w| w.role == WindowRole::Background);
+    let has_grid_client = state.iter().any(|w| {
+        w.role == WindowRole::Grid && !w.closing_or_init && !w.minimized && w.grid_patch.is_some()
+    });
 
     for out in outputs {
         let phys = out.layout_box;
@@ -1078,6 +1099,47 @@ pub fn arrange(
                     wp.hidden = Some(false);
                     wp.blur = Some(p.status_blur);
                 }
+                WindowClass::Grid => {
+                    // Always the xdg "you choose" size: the compositor never
+                    // dictates a grid buffer size (patches do), but a window
+                    // with NO planned dimensions can never leave Ready —
+                    // the map state machine requires one. Same trick as
+                    // Utility windows.
+                    wp.size = Some((0, 0));
+                    match w.grid_patch {
+                        Some(patch) if patch.scale > 0.0 => {
+                            wp.scene_enabled = Some(true);
+                            wp.hidden = Some(false);
+                            wp.tiled = Some(0);
+                            wp.ssd = Some(false);
+                            w.ssd = false;
+                            let (sx, sy) = ctx.virtual_to_screen(patch.x, patch.y);
+                            wp.pos = Some((sx, sy));
+                            // The buffer holds patch.scale px per virtual
+                            // unit; displaying it at zoom/patch.scale puts
+                            // it in per-frame lockstep with window content.
+                            wp.scale = Some(ctx.zoom / patch.scale);
+                            // The content box IS the buffer: dest sizing and
+                            // culling read box_geom, which nothing else
+                            // maintains for a window the compositor never
+                            // configures.
+                            wp.box_geom = Some(Rect {
+                                x: sx,
+                                y: sy,
+                                width: (patch.w * patch.scale).round() as i32,
+                                height: (patch.h * patch.scale).round() as i32,
+                            });
+                            wp.blur = Some(false);
+                            wp.opacity = Some(1.0);
+                        }
+                        _ => {
+                            // No rendered patch yet: keep it out of the
+                            // scene — no flash of an unanchored buffer.
+                            wp.scene_enabled = Some(false);
+                            wp.hidden = Some(true);
+                        }
+                    }
+                }
                 WindowClass::Hidden => {
                     wp.scene_enabled = Some(false);
                     wp.hidden = Some(true);
@@ -1287,6 +1349,7 @@ pub fn arrange(
     ArrangePlan {
         windows: plan,
         background_rect_enabled: !has_wallpaper,
+        grid_cells_enabled: !has_grid_client,
     }
 }
 
@@ -1793,6 +1856,7 @@ mod tests {
             was_tiled: false,
             saved_floating_size: (0, 0),
             saved_floating_virtual: (0.0, 0.0),
+            grid_patch: None,
         }
     }
 
@@ -1997,6 +2061,44 @@ mod tests {
         assert_eq!(wp.size, Some((500, 400)));
     }
 
+    #[test]
+    fn arrange_grid_client_world_anchored() {
+        let mut g = snap("cce-grid");
+        assert_eq!(g.role, WindowRole::Grid);
+        // No patch yet: hidden, and the compositor cells stay on.
+        let plan = arrange(&[g.clone()], &one_output(), &arrange_params());
+        assert_eq!(plan.windows[0].scene_enabled, Some(false));
+        assert!(plan.grid_cells_enabled);
+
+        // With a latched patch: placed at the patch's virtual origin, scaled
+        // by zoom/patch.scale, and the compositor cells yield.
+        g.grid_patch = Some(crate::api::GridPatch {
+            x: -1000.0,
+            y: 500.0,
+            w: 4000.0,
+            h: 3000.0,
+            scale: 0.5,
+        });
+        let mut p = arrange_params();
+        p.pan_x = -1500.0;
+        p.pan_y = 0.0;
+        p.zoom = 1.0;
+        let plan = arrange(&[g.clone()], &one_output(), &p);
+        let wp = &plan.windows[0];
+        assert_eq!(wp.scene_enabled, Some(true));
+        // Screen pos = (virtual - pan) * zoom: (-1000 - -1500, 500 - 0).
+        assert_eq!(wp.pos, Some((500, 500)));
+        // Buffer at 0.5 px per unit shown at zoom 1 → display scale 2.
+        assert_eq!(wp.scale, Some(2.0));
+        assert_eq!(wp.tiled, Some(0));
+        assert!(!plan.grid_cells_enabled);
+
+        // A minimized or closing grid client gives the cells back.
+        g.minimized = true;
+        let plan = arrange(&[g], &one_output(), &p);
+        assert!(plan.grid_cells_enabled);
+    }
+
     #[test]
     fn arrange_status_bar_placed_unless_dragged() {
         let mut bar = snap("cce-status-left-viewport");