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

commit50ee8a6ccc4e824717fece08bf3f033c6f594197
parent47a0cabef8
authorLucas Galante <[email protected]>
date2026-08-17 15:36
feat: world-anchored grid client — protocol, patch manager, fallback swap

The desktop grid can now be rendered by a cce-ui client (cce-grid)
instead of the compositor's rect/bevel pools, without ever putting the
client in the pan/zoom loop:

- Protocol (both this XML and cce-ui's copy, manager v6 / toplevel v4):
  set_grid declares the surface the grid layer; grid_patch(serial, x,
  y, w, h, scale) tells it what virtual region to render at what
  resolution; ack_grid_patch + the client's next commit latch the
  anchor, so an in-flight older buffer is never shown at a new anchor.
- Patch manager (update_grid_patches, start of arrange_views): issues a
  patch when none covers the viewport with a quarter-view margin or the
  buffer resolution drifts 2x from the zoom; one in flight per window;
  period-aligned, margin shrunk to an 8k buffer cap, resolution
  quantized to powers of two so zoom wobbles don't re-render.
- Grid windows are input-transparent at the scene.at level (the same
  disable-and-retry trick as circular windows), excluded from focus,
  overview fit, displacement, the switcher, and session save/restore.
- The compositor cell lattice yields to a live grid client
  (grid_cells_enabled from the arrange plan, redraw forced on swap);
  the gap backdrop always draws — it is the safety net beyond patch
  edges during fast pans, and the whole grid returns if the client
  dies.

Shadow-verified end to end: declare -> map -> patch -> ack -> latch ->
client pixels on screen (frame diff against the fallback), and the
fallback swaps back in when the client is killed.

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

 protocol/cce-window-management-v1.xml |  48 ++++++++++++-
 src/server/cce_window_management.rs   |  90 +++++++++++++++++++++++++
 src/server/output.rs                  |   7 +-
 src/server/scene.rs                   |   9 +++
 src/server/seat.rs                    |   2 +-
 src/server/window.rs                  |  29 +++++++-
 src/server/window_manager.rs          | 123 ++++++++++++++++++++++++++++++++--
 src/server/xdg_toplevel.rs            |   9 +++
 8 files changed, 304 insertions(+), 13 deletions(-)

diff --git a/protocol/cce-window-management-v1.xml b/protocol/cce-window-management-v1.xml
index ce824a2..f930381 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="5">
+  <interface name="zcce_window_manager_v1" version="6">
     <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="3">
+  <interface name="zcce_toplevel_v1" version="4">
     <description summary="toplevel window management controls">
       An interface to control and listen to CCE-specific window management states
       for a client surface.
@@ -1548,11 +1548,55 @@
         Request the compositor to return the window to normal tiling mode.
       </description>
     </request>
+    <request name="set_grid" since="4">
+      <description summary="declare the desktop-grid surface">
+        Declare this surface the desktop-grid layer. The compositor anchors
+        the surface to the virtual desktop: it is positioned and scaled with
+        the camera every frame, exactly like window content, so the client is
+        never part of the pan/zoom loop. The surface becomes
+        input-transparent, is stacked above the wallpaper and below all
+        windows, is excluded from focus, session save and the overview, and
+        the compositor stops dictating sizes to it.
+
+        The compositor tells the client WHAT to render via grid_patch
+        events; the surface stays unmapped-invisible until the first
+        acknowledged patch is committed.
+      </description>
+    </request>
+    <request name="ack_grid_patch" since="4">
+      <description summary="acknowledge a grid patch">
+        Acknowledge a grid_patch event. The next buffer the client commits
+        after this request is taken to be rendered for the acknowledged
+        patch: the compositor latches that patch's anchor rectangle at that
+        commit, so an in-flight older buffer is never shown at a new
+        anchor.
+      </description>
+      <arg name="serial" type="uint" summary="the serial from the grid_patch event"/>
+    </request>
     <event name="floating_state">
       <description summary="floating status event">
         Sent by the compositor to inform the client of its current floating status.
       </description>
       <arg name="state" type="uint" summary="1 if floating, 0 otherwise"/>
     </event>
+    <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
+        render, and at what resolution. x/y/width/height are virtual-surface
+        coordinates; scale is buffer pixels per virtual unit, so the
+        expected buffer size is (width * scale) x (height * scale). The
+        client renders the region, sends ack_grid_patch with the serial, and
+        commits the new buffer.
+
+        A newer grid_patch supersedes an unacknowledged older one; the
+        client should render only the latest.
+      </description>
+      <arg name="serial" type="uint" summary="serial to pass to ack_grid_patch"/>
+      <arg name="x" type="fixed" summary="patch origin x, virtual units"/>
+      <arg name="y" type="fixed" summary="patch origin y, virtual units"/>
+      <arg name="width" type="fixed" summary="patch width, virtual units"/>
+      <arg name="height" type="fixed" summary="patch height, virtual units"/>
+      <arg name="scale" type="fixed" summary="buffer px per virtual unit"/>
+    </event>
   </interface>
 </protocol>
diff --git a/src/server/cce_window_management.rs b/src/server/cce_window_management.rs
index e2c41f2..f81e1e7 100644
--- a/src/server/cce_window_management.rs
+++ b/src/server/cce_window_management.rs
@@ -24,6 +24,52 @@ impl CceWindowManagement {
     }
 }
 
+/// Grid patch event opcode on zcce_toplevel_v1 (floating_state is 0).
+const ZCCE_TOPLEVEL_V1_GRID_PATCH: u32 = 1;
+
+/// Toplevel interface version that carries the grid requests/events.
+const GRID_SINCE: i32 = 4;
+
+fn to_fixed(v: f64) -> i32 {
+    (v * 256.0).round() as i32
+}
+
+impl CceWindowManagement {
+    /// Post a grid_patch event to the toplevel resource of `window_key`, if
+    /// the client bound a new-enough version. Returns whether it was sent.
+    pub unsafe fn send_grid_patch(
+        &self,
+        window_key: SlotMapKey,
+        serial: u32,
+        patch: crate::policy::api::GridPatch,
+    ) -> bool {
+        for &res in &self.toplevels {
+            if res.is_null() {
+                continue;
+            }
+            let data = ffi::wl_resource_get_user_data(res) as *mut CceToplevelData;
+            if data.is_null() || (*data).window_key != window_key {
+                continue;
+            }
+            if ffi::wl_resource_get_version(res) < GRID_SINCE {
+                return false;
+            }
+            ffi::wl_resource_post_event(
+                res,
+                ZCCE_TOPLEVEL_V1_GRID_PATCH,
+                serial,
+                to_fixed(patch.x),
+                to_fixed(patch.y),
+                to_fixed(patch.w),
+                to_fixed(patch.h),
+                to_fixed(patch.scale),
+            );
+            return true;
+        }
+        false
+    }
+}
+
 struct CceToplevelData {
     server: *mut Server,
     window_key: SlotMapKey,
@@ -306,6 +352,48 @@ unsafe extern "C" fn toplevel_unset_utility(
     }
 }
 
+unsafe extern "C" fn toplevel_set_grid(
+    _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) {
+        log::info!("[Grid] set_grid declared");
+        (*window).grid_declared = true;
+        (*server).wm.dirty_windowing();
+    }
+}
+
+unsafe extern "C" fn toplevel_ack_grid_patch(
+    _client: *mut ffi::wl_client,
+    resource: *mut ffi::wl_resource,
+    serial: u32,
+) {
+    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) {
+        // Only the latest outstanding patch can be acked; a stale serial
+        // (superseded patch) is ignored — the client should already be
+        // rendering the newer one.
+        if let Some((pending_serial, patch)) = (*window).grid_patch_pending {
+            log::info!("[Grid] ack #{serial} (pending #{pending_serial})");
+            if pending_serial == serial {
+                (*window).grid_patch_pending = None;
+                (*window).grid_patch_acked = Some((serial, patch));
+            }
+        }
+    }
+}
+
 static CCE_TOPLEVEL_INTERFACE: ffi::zcce_toplevel_v1_interface = ffi::zcce_toplevel_v1_interface {
     destroy: Some(toplevel_destroy),
     set_floating: Some(toplevel_set_floating),
@@ -319,6 +407,8 @@ static CCE_TOPLEVEL_INTERFACE: ffi::zcce_toplevel_v1_interface = ffi::zcce_tople
     unset_popup: Some(toplevel_unset_popup),
     set_utility: Some(toplevel_set_utility),
     unset_utility: Some(toplevel_unset_utility),
+    set_grid: Some(toplevel_set_grid),
+    ack_grid_patch: Some(toplevel_ack_grid_patch),
 };
 
 unsafe fn resolve_window(server: *mut Server, key: SlotMapKey) -> Option<*mut Window> {
diff --git a/src/server/output.rs b/src/server/output.rs
index 256a56c..01d7ca8 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -914,10 +914,13 @@ impl Output {
                 }
 
                 if force {
-                    // Backdrop in the gap color, then the cell lattice.
+                    // Backdrop in the gap color, then the cell lattice. The
+                    // backdrop always draws — while a grid client is live it
+                    // is the safety net beyond the patch edges during fast
+                    // pans; the CELLS yield to the client's rendering.
                     get_rect(frame.backdrop_w, frame.backdrop_h, grid.gap_color.0.as_ptr(), 0, 0, 0, 0);
 
-                    if let Some(cells) = &frame.cells {
+                    if let Some(cells) = frame.cells.as_ref().filter(|_| wm.grid_cells_enabled) {
                         // scenefx fade-inset wire encoding: inset px * 1000
                         // + fade-mode index; 0 disables the fade.
                         use crate::policy::api::GridFadeMode;
diff --git a/src/server/scene.rs b/src/server/scene.rs
index a56315a..cadfcd8 100644
--- a/src/server/scene.rs
+++ b/src/server/scene.rs
@@ -146,6 +146,15 @@ impl Scene {
 
             if let Some(scene_node_data) = SceneNodeData::from_node(node) {
                 if let SceneNodeDataVal::Window(window) = scene_node_data.data {
+                    // The grid layer is input-transparent: every input path
+                    // (clicks, hover, overview background-exit) sees what is
+                    // underneath it, exactly as if it were the backdrop.
+                    if (*window).is_grid() {
+                        let tree_node = (*window).tree as *mut ffi::wlr_scene_node;
+                        ffi::wlr_scene_node_set_enabled(tree_node, false);
+                        disabled_nodes.push(tree_node);
+                        continue;
+                    }
                     if (*window).rendering_requested.circular {
                         // Check if outside the circle
                         let w = (*window).box_geom.width as f64 * (*window).scale;
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 18b3798..31c7afd 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -1512,7 +1512,7 @@ unsafe fn displace_covered(
         if !matches!((*w).state, crate::window::WindowState::Mapped) {
             continue;
         }
-        if (*w).is_status_bar() || (*w).is_wallpaper() {
+        if (*w).is_status_bar() || (*w).is_wallpaper() || (*w).is_grid() {
             continue;
         }
         let mode = wm.get_mode_for_window(w);
diff --git a/src/server/window.rs b/src/server/window.rs
index 70013da..3731886 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -426,6 +426,17 @@ pub struct Window {
     pub saved_virtual_x: f64,
     pub saved_virtual_y: f64,
     pub was_tiled: bool,
+    /// Declared the desktop-grid layer via zcce_toplevel_v1.set_grid (the
+    /// app_id "cce-grid" convention also maps the role; the flag makes the
+    /// declaration explicit and app_id-independent).
+    pub grid_declared: bool,
+    /// Grid windows: patch sent to the client, awaiting ack_grid_patch.
+    pub grid_patch_pending: Option<(u32, crate::policy::api::GridPatch)>,
+    /// Acked patch awaiting the client's next commit (the rendered buffer).
+    pub grid_patch_acked: Option<(u32, crate::policy::api::GridPatch)>,
+    /// The patch the CURRENT buffer covers — what arrange anchors to.
+    pub grid_patch_current: Option<crate::policy::api::GridPatch>,
+    pub grid_patch_serial: u32,
     pub saved_floating_width: i32,
     pub saved_floating_height: i32,
     pub saved_floating_virtual_x: f64,
@@ -463,9 +474,16 @@ impl Window {
     }
 
     pub unsafe fn role(&self) -> crate::policy::api::WindowRole {
+        if self.grid_declared {
+            return crate::policy::api::WindowRole::Grid;
+        }
         crate::policy::api::WindowRole::from_app_id(self.get_app_id_string().as_deref())
     }
 
+    pub unsafe fn is_grid(&self) -> bool {
+        self.role() == crate::policy::api::WindowRole::Grid
+    }
+
     pub unsafe fn is_status_bar(&self) -> bool {
         self.role() == crate::policy::api::WindowRole::StatusBar
     }
@@ -643,6 +661,11 @@ impl Window {
             saved_virtual_x: 0.0,
             saved_virtual_y: 0.0,
             was_tiled: false,
+            grid_declared: false,
+            grid_patch_pending: None,
+            grid_patch_acked: None,
+            grid_patch_current: None,
+            grid_patch_serial: 0,
             saved_floating_width: 0,
             saved_floating_height: 0,
             saved_floating_virtual_x: 0.0,
@@ -913,7 +936,11 @@ impl Window {
             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" {
+        if app_id_str.is_empty()
+            || app_id_str.starts_with("cce-status")
+            || app_id_str == "cce-wallpaper"
+            || app_id_str == "cce-grid"
+        {
             return;
         }
         let title_str = self.get_title_string().unwrap_or_default();
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 8a5aa5a..29dc9d0 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -81,6 +81,9 @@ pub struct WindowManager {
     pub desk_pan_y: f64,
     pub desk_zoom: f64,
     pub mode: WindowManagerMode,
+    /// From the last arrange plan: false while a live grid client covers
+    /// the desktop, so `draw_grid` keeps only the backdrop (and labels).
+    pub grid_cells_enabled: bool,
     pub layout: crate::config::Layout,
     pub mode_rules: Vec<crate::config::ModeRule>,
     pub keybinds: Vec<crate::config::Keybind>,
@@ -272,6 +275,7 @@ impl WindowManager {
         self.pending_screenshot = None;
         self.pending_ipc_reply = None;
         self.mode = WindowManagerMode::Normal;
+        self.grid_cells_enabled = true;
         self.restore_queue = Vec::new();
         self.last_window_states = Vec::new();
         self.pending_placements = Vec::new();
@@ -366,11 +370,12 @@ impl WindowManager {
         self.global = ffi::wl_global_create(
             (*server).wl_server,
             &ffi::zcce_window_manager_v1_interface,
-            // 5 = set_utility exists on toplevels; clients feature-gate on
+            // 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 to a plain floating window instead of
-            // dying on an unknown opcode.
-            5,
+            // compositor degrades gracefully instead of dying on an
+            // unknown opcode.
+            6,
             self as *mut WindowManager as *mut _,
             Some(bind),
         );
@@ -613,7 +618,10 @@ impl WindowManager {
             if w.is_null() || (*w).closed || matches!((*w).state, crate::window::WindowState::Closing | crate::window::WindowState::Init) {
                 continue;
             }
-            if (*w).is_status_bar() || (*w).is_wallpaper() {
+            // The grid layer is owned by its systemd unit and anchored by
+            // live patches — saving/restoring it would spawn a duplicate
+            // and dictate a stale geometry.
+            if (*w).is_status_bar() || (*w).is_wallpaper() || (*w).is_grid() {
                 continue;
             }
             // A Utility window owns its geometry entirely; saving it would
@@ -1222,12 +1230,14 @@ impl WindowManager {
             let overview_eligible = !(*w).minimized
                 && !is_status
                 && !is_wallpaper
+                && !(*w).is_grid()
                 && visible
                 && resolved_mode != crate::tiling::TilingMode::Popup
                 && resolved_mode != crate::tiling::TilingMode::Overlay;
             let focus_cyclable = rendered.contains(&(w as usize))
                 && !(*w).minimized
                 && !is_status
+                && !(*w).is_grid()
                 && !(*w).is_overlay_ui();
             windows.push(ActionWindow {
                 id: WindowId((*w).ref_key),
@@ -1930,7 +1940,97 @@ impl WindowManager {
 
 
 
+    /// Issue grid_patch events to grid clients whose current patch no
+    /// longer comfortably covers the viewport (or whose buffer resolution
+    /// has drifted more than 2x from the zoom). One patch in flight per
+    /// window; a failed send (no toplevel resource yet, old client) simply
+    /// retries on a later pass.
+    pub unsafe fn update_grid_patches(&mut self) {
+        let mut out_box: Option<ffi::wlr_box> = None;
+        let outputs_list = &(*self.server).om.outputs as *const ffi::wl_list as *mut WlList;
+        let mut curr_out = (*outputs_list).next;
+        while curr_out != outputs_list {
+            let output = crate::container_of!(curr_out, crate::output::Output, link);
+            if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+                out_box = Some((*output).sent.box_layout());
+                break;
+            }
+            curr_out = (*curr_out).next;
+        }
+        let Some(out) = out_box else { return };
+        let zoom = crate::policy::background::sanitized_zoom(self.desk_zoom);
+        let vw = out.width as f64 / zoom;
+        let vh = out.height as f64 / zoom;
+        let (vx, vy) = (self.desk_pan_x, self.desk_pan_y);
+        // Buffer px per virtual unit: zoom quantized to a power of two so
+        // small zoom wobbles don't re-render the world.
+        let q = (2f64).powf(zoom.log2().round()).clamp(0.125, 2.0);
+        let period = self.layout.desktop_grid_scale
+            + (self.layout.desktop_gap_width as f64).max(0.0);
+        let covers = |p: &crate::policy::api::GridPatch| -> bool {
+            let mx = vw * 0.25;
+            let my = vh * 0.25;
+            p.x <= vx - mx
+                && p.y <= vy - my
+                && p.x + p.w >= vx + vw + mx
+                && p.y + p.h >= vy + vh + my
+                && (zoom / p.scale) > 0.5
+                && (zoom / p.scale) < 2.01
+        };
+        for &w in self.windows.iter() {
+            if w.is_null() || (*w).closed || !(*w).is_grid() {
+                continue;
+            }
+            if !matches!((*w).state, crate::window::WindowState::Mapped) {
+                continue;
+            }
+            if (*w).grid_patch_current.as_ref().map_or(false, &covers) {
+                continue;
+            }
+            if let Some((_, pending)) = &(*w).grid_patch_pending {
+                if covers(pending) {
+                    continue;
+                }
+            }
+            if let Some((serial, acked)) = &(*w).grid_patch_acked {
+                // Rendered but not yet committed: give it a frame.
+                let _ = (serial, acked);
+                continue;
+            }
+            // One viewport of margin per side, shrunk if the buffer would
+            // exceed the cap; then period-aligned outward so the client
+            // draws whole cells.
+            const MAX_BUF: f64 = 8192.0;
+            let m = (((MAX_BUF / q) - vw) / (2.0 * vw)).clamp(0.0, 1.0)
+                .min((((MAX_BUF / q) - vh) / (2.0 * vh)).clamp(0.0, 1.0));
+            let x0 = ((vx - m * vw) / period).floor() * period;
+            let y0 = ((vy - m * vh) / period).floor() * period;
+            let x1 = ((vx + (1.0 + m) * vw) / period).ceil() * period;
+            let y1 = ((vy + (1.0 + m) * vh) / period).ceil() * period;
+            let patch = crate::policy::api::GridPatch {
+                x: x0,
+                y: y0,
+                w: x1 - x0,
+                h: y1 - y0,
+                scale: q,
+            };
+            (*w).grid_patch_serial = (*w).grid_patch_serial.wrapping_add(1);
+            let serial = (*w).grid_patch_serial;
+            if (*self.server)
+                .cce_window_management
+                .send_grid_patch((*w).ref_key, serial, patch)
+            {
+                log::info!("[Grid] sent patch #{serial}: {:.0},{:.0} {:.0}x{:.0} @{:.3}",
+                    patch.x, patch.y, patch.w, patch.h, patch.scale);
+                (*w).grid_patch_pending = Some((serial, patch));
+            } else {
+                log::info!("[Grid] patch #{serial} not sent (no toplevel resource yet)");
+            }
+        }
+    }
+
     pub unsafe fn arrange_views(&mut self) {
+        self.update_grid_patches();
         self.update_restore_placeholders();
         if arrange_debug() {
             log::debug!("Monolithic arrange_views triggered. Windows: {}", self.windows.count());
@@ -2054,6 +2154,7 @@ impl WindowManager {
                 was_tiled: (*win_ptr).was_tiled,
                 saved_floating_size: ((*win_ptr).saved_floating_width, (*win_ptr).saved_floating_height),
                 saved_floating_virtual: ((*win_ptr).saved_floating_virtual_x, (*win_ptr).saved_floating_virtual_y),
+                grid_patch: (*win_ptr).grid_patch_current,
             });
             win_ptrs.push(win_ptr);
         }
@@ -2100,6 +2201,14 @@ impl WindowManager {
                 ffi::wlr_scene_node_set_enabled((*output).background_rect as *mut ffi::wlr_scene_node, plan.background_rect_enabled);
             }
         }
+        if self.grid_cells_enabled != plan.grid_cells_enabled {
+            self.grid_cells_enabled = plan.grid_cells_enabled;
+            // The cell pools redraw only on structure changes; force one so
+            // the swap (client grid <-> compositor cells) is immediate.
+            for &output in &active_outputs {
+                (*output).grid_force_redraw_frames = 3;
+            }
+        }
 
         for (&win_ptr, wp) in win_ptrs.iter().zip(plan.windows.iter()) {
             if let Some(enabled) = wp.scene_enabled {
@@ -2440,7 +2549,7 @@ impl WindowManager {
             let app_id = (*w).get_app_id_string();
             let is_status_bar = app_id.as_deref().map_or(false, |id| id.starts_with("cce-status"));
             let is_wallpaper = app_id.as_deref() == Some("cce-wallpaper");
-            !is_status_bar && !is_wallpaper
+            !is_status_bar && !is_wallpaper && !(*w).is_grid()
         };
         let candidate = |w: *mut Window| crate::policy::focus::FocusCandidate {
             id: crate::policy::api::WindowId((*w).ref_key),
@@ -2480,7 +2589,7 @@ impl WindowManager {
         }
         match (*w).get_app_id_string().as_deref() {
             Some(id) if id.starts_with("cce-status") => false,
-            Some("cce-wallpaper") | Some("cce-cloud") => false,
+            Some("cce-wallpaper") | Some("cce-cloud") | Some("cce-grid") => false,
             _ => true,
         }
     }
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 751e7d0..0959fd4 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -531,6 +531,15 @@ unsafe extern "C" fn handle_ack_configure(
 unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let toplevel = crate::container_of!(listener, XdgToplevel, commit);
     let window = (*toplevel).window;
+    // Grid-patch latch: the first commit after ack_grid_patch carries the
+    // buffer rendered for that patch — anchor to it from this commit on.
+    // Latching here (not at ack time) means an in-flight older buffer is
+    // never shown at the new anchor.
+    if let Some((serial, patch)) = (*window).grid_patch_acked.take() {
+        log::info!("[Grid] latched patch #{serial} on commit");
+        (*window).grid_patch_current = Some(patch);
+        (*(*window).server).wm.dirty_windowing();
+    }
     let base = ffi::river_wlr_xdg_toplevel_get_base((*toplevel).wlr_toplevel);
     let old_geometry = (*toplevel).geometry;
     let mut new_geometry = std::mem::zeroed();