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

commit8315c295bcdd7475b0ef34417baf325d3620aaf8
parentb9b0430a71
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 12:11
The overview drag-selection picks up desktop images and carries them

The band selected windows only; cce-grid's pinned images, which the
compositor knew just as the grid surface's input region, sat outside it.
The grid now reports them (`grid-items <id>:<x>:<y>:<w>:<h> ...` on the
control socket) and Selection::desktop_items keeps the list: the band
picks them up by the same touch rule, draw_selection washes them like
windows, and a group move carries them — Seat::group_items beside
group_move, the compositor moving its rects by the group's offset and
pushing `move`/`drop` lines on a new one-shot `selection` status topic
for the grid to follow and save. A press on a selected image is the
compositor's (PointerOpType::GroupMove, no grabbed window: the pointer's
travel moves everything, cell-snapped when a carried window was Tiled);
a press on an unselected one drops the selection and goes to the grid. A
background click clears an image-only selection instead of leaving
overview.

`ccectl selection` prints the selected image ids and every reported
image; `ccectl camera` prints the mode. verify/image-selection-test
drives the lot in a shadow against a cce-grid it runs itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md                    |  45 +++++++-
 WORKSPACE.md                 |  10 +-
 src/cce_ctl.rs               |   1 +
 src/server/cursor.rs         |  83 ++++++++++++++-
 src/server/seat.rs           | 187 +++++++++++++++++++++++++--------
 src/server/selection.rs      | 168 +++++++++++++++++++++++++++++-
 src/server/status_server.rs  |  32 +++++-
 src/server/window_manager.rs |  45 ++++++--
 verify/image-selection-test  | 242 +++++++++++++++++++++++++++++++++++++++++++
 9 files changed, 746 insertions(+), 67 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 328f41bb..8b20e78f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -295,6 +295,11 @@ coordinates, which each popup finds through `XdgPopup::root_tree`; until
 Chrome's tall submenus past the top of the screen and stopped them flipping
 at the right edge.
 
+`./verify/image-selection-test` needs no client of its own: cce-grid and
+cce-terminal are the clients, and the desktop images it seeds are what the
+overview band and a group move have to pick up (see "Overview
+drag-selection" below).
+
 `./verify/escape-dismiss-test` composes the two to prove all three gates of
 the Escape-closes-status-menus arm (`handle_builtin_binding`): a chorded
 Escape stays out of the arm, a plain Escape while expanded pushes exactly one
@@ -625,10 +630,38 @@ two of them is a strip, so touching is enough.
 - The selection lives only in overview (`set_mode` clears it) and a
   destroyed window is dropped from it and from `group_move`
   (`Window::destroy`).
-- `ccectl selection` prints `selected=<ids|-> band=<virtual rect|->`. In a
-  shadow: `pointer-move-to`, `pointer-press left`, `pointer-move-to`,
-  `selection`, `pointer-release left`. Keep the band off the screen edges or
-  the edge pan scrolls the desk mid-assertion.
+- **The desktop images select too** (since 2026-09-30). They are
+  `cce-grid`'s pinned items, which the compositor otherwise knows only as
+  the grid surface's input region, so the grid reports them over the
+  control socket — `grid-items <id>:<x>:<y>:<w>:<h> ...`, virtual units,
+  the whole list on every change, ids per grid process — and
+  `Selection::desktop_items` keeps the list (dropped with the grid window,
+  `selection_forget`). The band picks them up by the same touch rule,
+  `draw_selection` washes them like windows (square-cornered: they are
+  quads), and a group move carries them: `Seat::group_items` is filled
+  beside `group_move` at the grab, `carry_group_items` moves the
+  compositor's rects by the group's offset (the grabbed window's, snap
+  included) and pushes `move <id>:<x>:<y> ...` on the status socket's
+  `selection` topic, and `op_end` pushes `drop`, on which the grid saves
+  its sidecar and reports afresh. A press on a SELECTED image is the
+  compositor's, not the grid's: `PointerOpType::GroupMove`, the one op
+  besides Select with no window — the pointer's own travel moves windows
+  and images alike, snapped to whole cells when a carried window was Tiled
+  (measured on that window, kept in `start_win_virtual_*`). A press on an
+  unselected image drops the selection and goes to the grid, which drags
+  it as before. A background click clears images too (`has_selection`), so
+  a click with only images selected drops them rather than leaving
+  overview. See `../cce-grid/CLAUDE.md` for the grid's half.
+- `ccectl selection` prints `selected=<ids|-> items=<ids|->
+  band=<virtual rect|-> desk=<id@x,y,wxh;...|->` — `items` are the selected
+  images, `desk` every image the grid has reported, which is how a shadow
+  sees the report land and where a group move left them; `ccectl camera`
+  prints `mode=` too. In a shadow: `pointer-move-to`, `pointer-press left`,
+  `pointer-move-to`, `selection`, `pointer-release left`. Keep the band off
+  the screen edges or the edge pan scrolls the desk mid-assertion.
+  `./verify/image-selection-test` drives the whole of the above — the
+  report, the band, both grab sides, the click rules, the exit — against a
+  cce-grid it runs itself (`CCE_GRID`, else the workspace's release build).
 
 ### Config
 
@@ -904,6 +937,10 @@ headless seat has no keyboard and Chromium crashes in
   change. This feeds the status bar (`cce-status-interface`). The main loop pushes
   updates through a `StatusSender` mpsc handle.
 
+  **`selection`** is a one-shot topic too, for the desktop grid alone: the
+  overview drag-selection carrying its images pushes `move <id>:<x>:<y>
+  ...` per step and `drop` at the release (see "Overview drag-selection").
+
   **`clickaway`** is a one-shot topic (like `dismiss` and `shortcuts`): a
   `press` line for each button press that lands on NO X11 surface while some
   override-redirect X window is showing (`handle_button`). Xwayland only sees
diff --git a/WORKSPACE.md b/WORKSPACE.md
index 0ea14170..b7cdfa45 100644
--- a/WORKSPACE.md
+++ b/WORKSPACE.md
@@ -309,9 +309,13 @@ Clients and compositor communicate over Unix sockets keyed by `$WAYLAND_DISPLAY`
   `ccectl` binary (in `cce-compositor/`) is the CLI client; run `ccectl` with no args for the
   command list.
 - **Status**: `/tmp/cce-status-{WAYLAND_DISPLAY}.sock` — subscribe to `layout` /
-  `title` / `modifiers` / `adjust` / `dismiss` / `backdrop <app_id>` and receive push
-  updates. `adjust` is "on"/"off" as window-adjust mode (overview, or Super
-  held) comes and goes — what `cce-grid` keys its image resize handles on.
+  `title` / `modifiers` / `adjust` / `dismiss` / `selection` / `backdrop <app_id>`
+  and receive push updates. `adjust` is "on"/"off" as window-adjust mode
+  (overview, or Super held) comes and goes — what `cce-grid` keys its image
+  resize handles on. `selection` is the other grid-facing topic: the overview
+  drag-selection carrying the grid's images tells it where they went
+  (`move <id>:<x>:<y> ...`, then `drop`); the grid reports the images it holds
+  the other way, with `grid-items` on the control socket.
   This feeds `cce-status-interface` (the status bar). `backdrop` is the odd one
   out: it takes the asking segment's app_id and reports what that segment is
   composited over, which is the one thing a Wayland client can never see for
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 2522a4dd..45004157 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -88,6 +88,7 @@ fn usage(name: &str, to_stderr: bool) {
     print("  mode <floating|tiled|fullscreen|popup|overlay> <app_id> [title]");
     print("  pointer-location");
     print("  camera                             (pan, zoom, and the pan target being eased to)");
+    print("  selection                          (overview drag-selection: window ids, image ids, band)");
     print("  pointer-move-to <x> <y>            (layout pixels)");
     print("  pointer-move-by <dx> <dy>");
     print("  pointer-scroll <dy> [dx] [finger] [natural]");
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 6d214c3e..5475f79d 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -1632,6 +1632,76 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
             } else {
                 BorderZone::None
             };
+            // A press on a SELECTED desktop image grabs the whole selection
+            // — windows and images — from the image's side: the grid never
+            // sees the press, the compositor moves everything and tells the
+            // grid where its images went (`crate::selection`). A press on an
+            // unselected image drops the selection, as one on an unselected
+            // window does, and goes to the grid as before.
+            if clicked_grid && in_overview {
+                let (vx, vy) = (*server).wm.layout_to_virtual(lx, ly);
+                if (*server).wm.selected_item_at(vx, vy).is_some() {
+                    (*server).wm.stop_panning_animation();
+                    seat.group_move.clear();
+                    seat.group_items.clear();
+                    // The first Tiled window carried is the snap anchor.
+                    let mut tiled_anchor: Option<(f64, f64)> = None;
+                    let carried: Vec<*mut crate::window::Window> = (*server)
+                        .wm
+                        .selection
+                        .windows
+                        .iter()
+                        .copied()
+                        .filter(|&w| (*server).wm.selectable(w))
+                        .collect();
+                    for w in carried {
+                        seat.group_move.push((w, (*w).virtual_x, (*w).virtual_y));
+                        if (*w).tiling_mode == crate::tiling::TilingMode::Tiled {
+                            if tiled_anchor.is_none() {
+                                tiled_anchor = Some(((*w).virtual_x, (*w).virtual_y));
+                            }
+                        } else if (*w).tiling_mode == crate::tiling::TilingMode::Floating {
+                            (*server).wm.raise_window(w);
+                        }
+                    }
+                    let ids: Vec<u64> = (*server).wm.selection.items.clone();
+                    for id in ids {
+                        if let Some(item) = (*server).wm.desktop_item(id) {
+                            seat.group_items.push((id, item.x, item.y));
+                        }
+                    }
+                    let cursor_x = (*cursor.wlr_cursor).x;
+                    let cursor_y = (*cursor.wlr_cursor).y;
+                    let (anchor_x, anchor_y) = tiled_anchor.unwrap_or((0.0, 0.0));
+                    seat.op = Some(crate::seat::SeatOp {
+                        sent_release: false,
+                        input: crate::seat::SeatOpInput::Pointer,
+                        start_x: cursor_x as i32,
+                        start_y: cursor_y as i32,
+                        x: cursor_x as i32,
+                        y: cursor_y as i32,
+                        window_ptr: std::ptr::null_mut(),
+                        op_type: crate::seat::PointerOpType::GroupMove,
+                        start_win_x: 0,
+                        start_win_y: 0,
+                        start_win_w: 0,
+                        start_win_h: 0,
+                        start_win_virtual_x: anchor_x,
+                        start_win_virtual_y: anchor_y,
+                        start_was_tiled: tiled_anchor.is_some(),
+                        start_pan_x: (*server).wm.desk_pan_x,
+                        start_pan_y: (*server).wm.desk_pan_y,
+                        start_tiling_mode: crate::tiling::TilingMode::Floating,
+                        start_mode_locked: false,
+                        started_in_overview: true,
+                    });
+                    cursor.op_start_pointer();
+                    cursor.pressed.insert((*event).button, None);
+                    cursor.set_xcursor(b"grab\0".as_ptr() as *const _);
+                    return;
+                }
+                (*server).wm.selection_clear();
+            }
             if overview_chrome || clicked_grid {
                 // fall through
             } else if overview_win_valid && matches!(overview_border_zone, BorderZone::None) {
@@ -1649,6 +1719,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 // here, in stacking order, and the grabbed one over them in
                 // `op_start_pointer`.
                 seat.group_move.clear();
+                seat.group_items.clear();
                 if (*server).wm.is_selected(clicked_win) {
                     let carried: Vec<*mut crate::window::Window> = (*server)
                         .wm
@@ -1664,6 +1735,13 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                             (*server).wm.raise_window(w);
                         }
                     }
+                    // And the selected desktop images, by the same offset.
+                    let ids: Vec<u64> = (*server).wm.selection.items.clone();
+                    for id in ids {
+                        if let Some(item) = (*server).wm.desktop_item(id) {
+                            seat.group_items.push((id, item.x, item.y));
+                        }
+                    }
                 } else {
                     (*server).wm.selection_clear();
                 }
@@ -1906,7 +1984,8 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                             cursor.set_xcursor(b"grab\0".as_ptr() as *const _);
                         }
                         // A pointer bind is a move or a resize.
-                        crate::seat::PointerOpType::Select => {}
+                        crate::seat::PointerOpType::Select
+                        | crate::seat::PointerOpType::GroupMove => {}
                     }
                     return;
                 }
@@ -2225,7 +2304,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 // on empty grid does in cce-designer; with none it leaves
                 // overview, as it always has.
                 if !dragged && (*event).button == 0x110 {
-                    if !(*server).wm.selection.windows.is_empty() {
+                    if (*server).wm.has_selection() {
                         (*server).wm.selection_clear();
                     } else {
                         (*server).wm.execute_action(&crate::config::Action::Overview, None);
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 91798f2c..f25fe1a5 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -15,6 +15,13 @@ pub enum PointerOpType {
     /// a rubber band. The one op with no window (`window_ptr` is null); its
     /// state is the window manager's (`crate::selection`).
     Select,
+    /// A group move grabbed by a selected desktop IMAGE: no window under
+    /// the pointer (`window_ptr` is null), the whole selection — windows
+    /// (`Seat::group_move`) and images (`Seat::group_items`) — follows the
+    /// pointer's own travel. `start_was_tiled` says a carried window was
+    /// Tiled at the grab and `start_win_virtual_*` is where it stood, so
+    /// the travel snaps to whole cells the way a grab on that window would.
+    GroupMove,
 }
 
 #[derive(Clone, Copy, Debug)]
@@ -121,6 +128,11 @@ pub struct Seat {
     /// Empty for an ordinary move. Filled at the grab, cleared in `op_end`;
     /// on the Seat for the reason `overview_displaced` is.
     pub group_move: Vec<(*mut crate::window::Window, f64, f64)>,
+    /// The selected desktop images a group move carries: the grid's id and
+    /// the virtual position each had at the grab. Filled beside
+    /// `group_move`; the positions go to the grid over the `selection`
+    /// status topic as the move steps (`carry_group_items`).
+    pub group_items: Vec<(u64, f64, f64)>,
     pub wm_sent_x: i32,
     pub wm_sent_y: i32,
 
@@ -173,6 +185,7 @@ impl Seat {
             suppress_focus_pan: false,
             overview_displaced: Vec::new(),
             group_move: Vec::new(),
+            group_items: Vec::new(),
             wm_sent_x: 0,
             wm_sent_y: 0,
             request_set_cursor: std::mem::zeroed(),
@@ -1245,6 +1258,34 @@ impl Seat {
                 return;
             }
 
+            if op.op_type == PointerOpType::GroupMove {
+                // Grabbed by an image: the pointer's own travel is the
+                // group's, snapped to whole cells when a Tiled window is
+                // along (measured on that window, so it lands on cells).
+                let op = *op;
+                let scale = (*self.server).wm.desk_zoom;
+                let pan_x = (*self.server).wm.desk_pan_x;
+                let pan_y = (*self.server).wm.desk_pan_y;
+                let virtual_dx = dx as f64 / scale + (pan_x - op.start_pan_x);
+                let virtual_dy = dy as f64 / scale + (pan_y - op.start_pan_y);
+                let (gdx, gdy) = if op.start_was_tiled {
+                    let (sx, sy) = crate::policy::snap::snap_move_tiled(
+                        op.start_win_virtual_x + virtual_dx,
+                        op.start_win_virtual_y + virtual_dy,
+                        &sp,
+                    );
+                    (sx - op.start_win_virtual_x, sy - op.start_win_virtual_y)
+                } else {
+                    (virtual_dx, virtual_dy)
+                };
+                (*self.server).wm.arm_border_fade();
+                carry_group_windows(self.server, &self.group_move, std::ptr::null_mut(), gdx, gdy);
+                carry_group_items(self.server, &self.group_items, gdx, gdy);
+                (*self.server).wm.queue_op_frame();
+                self.update_edge_pan(x as f64, y as f64);
+                return;
+            }
+
             let win = op.window_ptr;
             if !win.is_null() && !(*win).closed {
                 // Every drag step can bring a Floating window over the
@@ -1475,34 +1516,8 @@ impl Seat {
                             // through here too, and must not un-tile them.
                             let gdx = vx - op.start_win_virtual_x;
                             let gdy = vy - op.start_win_virtual_y;
-                            for &(w, start_vx, start_vy) in self.group_move.iter() {
-                                if w == win || !(*self.server).wm.selectable_in_drag(w) {
-                                    continue;
-                                }
-                                if gdx == 0.0
-                                    && gdy == 0.0
-                                    && (*w).virtual_x == start_vx
-                                    && (*w).virtual_y == start_vy
-                                {
-                                    continue;
-                                }
-                                if (*w).tiling_mode == crate::tiling::TilingMode::Tiled {
-                                    // As for the grabbed window above:
-                                    // floating for the drag, geometry kept,
-                                    // re-tiled in op_end if it lands aligned.
-                                    (*w).was_tiled = false;
-                                    (*w).tiling_mode = crate::tiling::TilingMode::Floating;
-                                    (*w).mode_locked = true;
-                                    (*self.server).wm.raise_window(w);
-                                }
-                                (*w).virtual_x = start_vx + gdx;
-                                (*w).virtual_y = start_vy + gdy;
-                                let (fx, fy) = (*w).virtual_to_screen((*w).virtual_x, (*w).virtual_y);
-                                (*w).rendering_requested.x = fx;
-                                (*w).rendering_requested.y = fy;
-                                (*w).box_geom.x = fx;
-                                (*w).box_geom.y = fy;
-                            }
+                            carry_group_windows(self.server, &self.group_move, win, gdx, gdy);
+                            carry_group_items(self.server, &self.group_items, gdx, gdy);
 
                             let (final_x, final_y) = (*win).virtual_to_screen(vx, vy);
                             (*win).rendering_requested.x = final_x;
@@ -1593,8 +1608,8 @@ impl Seat {
                         };
                         (*win).set_dimensions(new_w, new_h);
                     }
-                    // Handled above: it has no window to get here with.
-                    PointerOpType::Select => {}
+                    // Handled above: neither has a window to get here with.
+                    PointerOpType::Select | PointerOpType::GroupMove => {}
                 }
             }
             // The configure and the relayout go out once per output frame,
@@ -1623,6 +1638,8 @@ impl Seat {
                 Some(ref op) if op.op_type == PointerOpType::Select => {
                     wm.selection.marquee.is_some()
                 }
+                // Grabbed by an image: no window, always a drag.
+                Some(ref op) if op.op_type == PointerOpType::GroupMove => true,
                 Some(ref op) => {
                     !op.window_ptr.is_null()
                         && !(*op.window_ptr).closed
@@ -1713,19 +1730,6 @@ impl Seat {
                     }
                 }
                 self.settle_tiling(win);
-                // The windows a group move carried land by the same rule,
-                // each on its own geometry.
-                let group = std::mem::take(&mut self.group_move);
-                for &(w, start_vx, start_vy) in group.iter() {
-                    if w == win || !(*self.server).wm.selectable_in_drag(w) {
-                        continue;
-                    }
-                    if (*w).virtual_x == start_vx && (*w).virtual_y == start_vy {
-                        continue;
-                    }
-                    self.settle_tiling(w);
-                    (*self.server).wm.dirty_windowing();
-                }
                 // A TAP — press+release without meaningful motion — is a
                 // click, not a drag. A drag never focuses the window it
                 // moves or resizes (the press grabs without focusing), but
@@ -1747,6 +1751,34 @@ impl Seat {
                     self.focus_follow_pan(win);
                 }
             }
+            // The windows a group move carried land by the same rule as
+            // the grabbed one, each on its own geometry — whether the grab
+            // was a window or an image (then `win` is null).
+            let group = std::mem::take(&mut self.group_move);
+            for &(w, start_vx, start_vy) in group.iter() {
+                if w == win || !(*self.server).wm.selectable_in_drag(w) {
+                    continue;
+                }
+                if (*w).virtual_x == start_vx && (*w).virtual_y == start_vy {
+                    continue;
+                }
+                self.settle_tiling(w);
+                (*self.server).wm.dirty_windowing();
+            }
+            // The images it carried are the grid's to keep: `drop` is its
+            // cue to save them, if any actually moved.
+            let items = std::mem::take(&mut self.group_items);
+            let moved = items.iter().any(|&(id, sx, sy)| {
+                (*self.server)
+                    .wm
+                    .desktop_item(id)
+                    .map_or(false, |i| i.x != sx || i.y != sy)
+            });
+            if moved {
+                if let Some(ref sender) = (*self.server).wm.status_sender {
+                    sender.send_selection_line("drop");
+                }
+            }
             match op.input {
                 SeatOpInput::Pointer => {
                     self.cursor.op_end_pointer();
@@ -1754,7 +1786,76 @@ impl Seat {
             }
         }
         self.group_move.clear();
+        self.group_items.clear();
+    }
+}
+
+/// One step of a group move for the windows it carries: each lands at its
+/// grab position plus `(gdx, gdy)` — the offset the grabbed window actually
+/// took, snap included, so the group keeps its shape; `grabbed` (null for
+/// a grab by an image) is skipped, it is placed by its own arm. Untouched
+/// until that offset is something: the release of a tap runs through here
+/// too, and must not un-tile them.
+unsafe fn carry_group_windows(
+    server: *mut crate::server::Server,
+    group: &[(*mut crate::window::Window, f64, f64)],
+    grabbed: *mut crate::window::Window,
+    gdx: f64,
+    gdy: f64,
+) {
+    for &(w, start_vx, start_vy) in group.iter() {
+        if w == grabbed || !(*server).wm.selectable_in_drag(w) {
+            continue;
+        }
+        if gdx == 0.0 && gdy == 0.0 && (*w).virtual_x == start_vx && (*w).virtual_y == start_vy {
+            continue;
+        }
+        if (*w).tiling_mode == crate::tiling::TilingMode::Tiled {
+            // As for a grabbed window: floating for the drag, geometry
+            // kept, re-tiled in op_end if it lands aligned.
+            (*w).was_tiled = false;
+            (*w).tiling_mode = crate::tiling::TilingMode::Floating;
+            (*w).mode_locked = true;
+            (*server).wm.raise_window(w);
+        }
+        (*w).virtual_x = start_vx + gdx;
+        (*w).virtual_y = start_vy + gdy;
+        let (fx, fy) = (*w).virtual_to_screen((*w).virtual_x, (*w).virtual_y);
+        (*w).rendering_requested.x = fx;
+        (*w).rendering_requested.y = fy;
+        (*w).box_geom.x = fx;
+        (*w).box_geom.y = fy;
+    }
+}
+
+/// The same step for the desktop images the move carries: the rects the
+/// compositor holds move (so the highlight follows this frame), and the
+/// grid is told where they are now over the `selection` status topic.
+/// Nothing is sent for a step that moved nothing.
+unsafe fn carry_group_items(server: *mut crate::server::Server, items: &[(u64, f64, f64)], gdx: f64, gdy: f64) {
+    if items.is_empty() {
+        return;
+    }
+    let wm = &mut (*server).wm;
+    let mut line = String::from("move");
+    let mut changed = false;
+    for &(id, start_x, start_y) in items.iter() {
+        let Some(item) = wm.desktop_item_mut(id) else { continue };
+        let (nx, ny) = (start_x + gdx, start_y + gdy);
+        if item.x != nx || item.y != ny {
+            changed = true;
+        }
+        item.x = nx;
+        item.y = ny;
+        line.push_str(&format!(" {}:{:.2}:{:.2}", id, nx, ny));
+    }
+    if !changed {
+        return;
+    }
+    if let Some(ref sender) = wm.status_sender {
+        sender.send_selection_line(&line);
     }
+    wm.schedule_frame_all_outputs();
 }
 
 /// Live overview displacement for one motion step of a move op: every
diff --git a/src/server/selection.rs b/src/server/selection.rs
index 3fca3e1b..3f3da729 100644
--- a/src/server/selection.rs
+++ b/src/server/selection.rs
@@ -14,6 +14,21 @@
 //! Like the designer, a new drag replaces the selection, a press on a window
 //! outside it drops it, and there are no modifiers.
 //!
+//! The desktop IMAGES select the same way. They are `cce-grid`'s (its
+//! desktop items, pinned to the virtual canvas), and the compositor never
+//! sees them as anything but the grid surface's input region — so the grid
+//! reports them over the control socket (`grid-items <id>:<x>:<y>:<w>:<h>
+//! ...`, virtual units, on every change) and this module keeps the list
+//! (`Selection::desktop_items`). The band picks them up by the same touch
+//! rule, the highlight is drawn here beside the windows', and a group move
+//! carries them: the compositor moves the rects it holds and pushes the new
+//! positions over the status socket's `selection` topic (`move
+//! <id>:<x>:<y> ...`, then `drop` at the release, on which the grid saves
+//! its sidecar and reports the list afresh). A press on a selected image
+//! starts the group move from the image's side (`PointerOpType::GroupMove`
+//! — no grabbed window, the delta is the pointer's); a press on an
+//! unselected one drops the selection and goes to the grid as before.
+//!
 //! The press that starts the drag used to exit overview on the spot. It
 //! still does, on RELEASE, when the pointer never travelled: the press
 //! cannot know which of the two it is.
@@ -45,6 +60,50 @@ const SELECTED_FILL: f32 = 0.12;
 /// Corner radius of the rubber band, screen px.
 const MARQUEE_RADIUS: f64 = 6.0;
 
+/// One of the grid's desktop images, as it last reported it: a
+/// per-process id the grid assigns, and its rect in virtual units.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct DesktopItem {
+    pub id: u64,
+    pub x: f64,
+    pub y: f64,
+    pub w: f64,
+    pub h: f64,
+}
+
+impl DesktopItem {
+    pub fn rect(&self) -> (f64, f64, f64, f64) {
+        (self.x, self.y, self.w, self.h)
+    }
+}
+
+/// Parse the grid's report: whitespace-separated `id:x:y:w:h` tokens, any
+/// malformed one skipped. An empty report is an empty desk.
+pub fn parse_desktop_items(tokens: &[&str]) -> Vec<DesktopItem> {
+    tokens
+        .iter()
+        .filter_map(|tok| {
+            let mut f = tok.split(':');
+            let id = f.next()?.parse::<u64>().ok()?;
+            let x = f.next()?.parse::<f64>().ok()?;
+            let y = f.next()?.parse::<f64>().ok()?;
+            let w = f.next()?.parse::<f64>().ok()?;
+            let h = f.next()?.parse::<f64>().ok()?;
+            Some(DesktopItem { id, x, y, w, h })
+        })
+        .collect()
+}
+
+/// The topmost item under a virtual point. The grid draws its list in
+/// order, last on top, and reports it in that order.
+pub fn item_at(items: &[DesktopItem], vx: f64, vy: f64) -> Option<u64> {
+    items
+        .iter()
+        .rev()
+        .find(|i| vx >= i.x && vx < i.x + i.w && vy >= i.y && vy < i.y + i.h)
+        .map(|i| i.id)
+}
+
 /// The rubber band: the press point and the pointer, in virtual coordinates.
 #[derive(Clone, Copy, Debug, PartialEq)]
 pub struct Marquee {
@@ -81,6 +140,12 @@ pub struct Selection {
     /// `Window::destroy`; a window that is merely unmapped or minimized
     /// stays listed and is skipped where it matters.
     pub windows: Vec<*mut Window>,
+    /// The selected desktop images, by the grid's id, in report order.
+    pub items: Vec<u64>,
+    /// Every desktop image the grid has reported, in its draw order. Moved
+    /// here during a group move so the highlight follows; the grid's next
+    /// report replaces the lot.
+    pub desktop_items: Vec<DesktopItem>,
     /// Where the armed background press landed (virtual). Set on press,
     /// cleared on release.
     pub anchor: Option<(f64, f64)>,
@@ -94,6 +159,8 @@ impl Default for Selection {
     fn default() -> Self {
         Self {
             windows: Vec::new(),
+            items: Vec::new(),
+            desktop_items: Vec::new(),
             anchor: None,
             marquee: None,
             tree: std::ptr::null_mut(),
@@ -136,6 +203,39 @@ impl WindowManager {
         self.selection.windows.iter().any(|&w| w == window)
     }
 
+    pub fn is_item_selected(&self, id: u64) -> bool {
+        self.selection.items.iter().any(|&i| i == id)
+    }
+
+    /// Anything selected at all — what a background click drops, and what
+    /// a press on a selected image carries.
+    pub fn has_selection(&self) -> bool {
+        !self.selection.windows.is_empty() || !self.selection.items.is_empty()
+    }
+
+    /// The selected desktop image under a virtual point, if the topmost
+    /// one there is selected.
+    pub fn selected_item_at(&self, vx: f64, vy: f64) -> Option<u64> {
+        item_at(&self.selection.desktop_items, vx, vy).filter(|&id| self.is_item_selected(id))
+    }
+
+    pub fn desktop_item(&self, id: u64) -> Option<&DesktopItem> {
+        self.selection.desktop_items.iter().find(|i| i.id == id)
+    }
+
+    pub fn desktop_item_mut(&mut self, id: u64) -> Option<&mut DesktopItem> {
+        self.selection.desktop_items.iter_mut().find(|i| i.id == id)
+    }
+
+    /// The grid reported its images (`grid-items`). The list is replaced
+    /// wholesale; a selected id that is no longer in it was removed or
+    /// belongs to a restarted grid, and is dropped.
+    pub unsafe fn set_desktop_items(&mut self, items: Vec<DesktopItem>) {
+        self.selection.items.retain(|id| items.iter().any(|i| i.id == *id));
+        self.selection.desktop_items = items;
+        self.schedule_frame_all_outputs();
+    }
+
     /// Arm a drag-selection at a background press. Nothing is selected or
     /// drawn until the pointer travels (`selection_motion`).
     pub unsafe fn selection_press(&mut self, lx: f64, ly: f64) {
@@ -173,6 +273,17 @@ impl WindowManager {
             log::debug!("selection: {} window(s)", picked.len());
             self.selection.windows = picked;
         }
+        let picked_items: Vec<u64> = self
+            .selection
+            .desktop_items
+            .iter()
+            .filter(|i| touches(rect, i.rect()))
+            .map(|i| i.id)
+            .collect();
+        if picked_items != self.selection.items {
+            log::debug!("selection: {} image(s)", picked_items.len());
+            self.selection.items = picked_items;
+        }
         self.schedule_frame_all_outputs();
     }
 
@@ -188,20 +299,28 @@ impl WindowManager {
     }
 
     pub unsafe fn selection_clear(&mut self) {
-        if self.selection.windows.is_empty() && self.selection.marquee.is_none() {
+        if !self.has_selection() && self.selection.marquee.is_none() {
             return;
         }
         self.selection.windows.clear();
+        self.selection.items.clear();
         self.selection.marquee = None;
         self.schedule_frame_all_outputs();
     }
 
     /// `window` is being destroyed: it must not stay listed for a window
-    /// allocated at the same address to inherit.
+    /// allocated at the same address to inherit. The grid going away takes
+    /// its images with it.
     pub unsafe fn selection_forget(&mut self, window: *mut Window) {
         let before = self.selection.windows.len();
         self.selection.windows.retain(|&w| w != window);
-        if self.selection.windows.len() != before {
+        let mut changed = self.selection.windows.len() != before;
+        if (*window).is_grid() && !self.selection.desktop_items.is_empty() {
+            self.selection.desktop_items.clear();
+            self.selection.items.clear();
+            changed = true;
+        }
+        if changed {
             self.schedule_frame_all_outputs();
         }
     }
@@ -240,7 +359,7 @@ impl WindowManager {
     pub unsafe fn draw_selection(&mut self) {
         let showing = self.mode == WindowManagerMode::Overview
             && (*self.server).lock_manager.state == crate::lock_manager::LockState::Unlocked
-            && (self.selection.marquee.is_some() || !self.selection.windows.is_empty());
+            && (self.selection.marquee.is_some() || self.has_selection());
         if !showing {
             if !self.selection.tree.is_null() {
                 ffi::wlr_scene_node_set_enabled(
@@ -273,6 +392,21 @@ impl WindowManager {
                 SELECTED_FILL,
             ));
         }
+        // The images are square-cornered quads on the grid surface, so
+        // their wash is too.
+        for &id in self.selection.items.iter() {
+            let Some(item) = self.desktop_item(id) else { continue };
+            let (x0, y0) = self.virtual_to_layout(item.x, item.y);
+            let (x1, y1) = self.virtual_to_layout(item.x + item.w, item.y + item.h);
+            wanted.push((
+                x0.round() as i32,
+                y0.round() as i32,
+                (x1 - x0).round() as i32,
+                (y1 - y0).round() as i32,
+                0,
+                SELECTED_FILL,
+            ));
+        }
         if let Some(marquee) = self.selection.marquee {
             let (vx, vy, vw, vh) = marquee.rect();
             let (x0, y0) = self.virtual_to_layout(vx, vy);
@@ -431,4 +565,30 @@ mod tests {
     fn a_window_with_no_area_is_never_touched() {
         assert!(!touches((0.0, 0.0, 1000.0, 1000.0), (100.0, 100.0, 0.0, 300.0)));
     }
+
+    #[test]
+    fn the_grid_report_parses_and_skips_a_bad_token() {
+        let items = parse_desktop_items(&["3:10.5:20:300:200", "junk", "4:0:0:1:1:extra", "x:1:2:3:4"]);
+        assert_eq!(
+            items,
+            vec![
+                DesktopItem { id: 3, x: 10.5, y: 20.0, w: 300.0, h: 200.0 },
+                DesktopItem { id: 4, x: 0.0, y: 0.0, w: 1.0, h: 1.0 },
+            ]
+        );
+        assert!(parse_desktop_items(&[]).is_empty());
+    }
+
+    #[test]
+    fn the_last_reported_image_is_on_top() {
+        let items = vec![
+            DesktopItem { id: 1, x: 0.0, y: 0.0, w: 100.0, h: 100.0 },
+            DesktopItem { id: 2, x: 50.0, y: 50.0, w: 100.0, h: 100.0 },
+        ];
+        assert_eq!(item_at(&items, 75.0, 75.0), Some(2));
+        assert_eq!(item_at(&items, 10.0, 10.0), Some(1));
+        assert_eq!(item_at(&items, 200.0, 200.0), None);
+        // Half-open: the far edge belongs to nothing.
+        assert_eq!(item_at(&items, 150.0, 150.0), None);
+    }
 }
diff --git a/src/server/status_server.rs b/src/server/status_server.rs
index d7fff23f..c29af6e4 100644
--- a/src/server/status_server.rs
+++ b/src/server/status_server.rs
@@ -2,7 +2,8 @@
 //
 // Runs in a dedicated thread. cce-status connects to
 // /tmp/cce-status-{WAYLAND_DISPLAY}.sock, sends a subscription line
-// ("layout", "title", "modifiers", "adjust", "dismiss", or "shortcuts") and receives lines whenever the status changes.
+// ("layout", "title", "modifiers", "adjust", "dismiss", "shortcuts",
+// "clickaway" or "selection") and receives lines whenever the status changes.
 //
 // The main loop sends updates through an mpsc channel. The server thread
 // owns the socket and handles all I/O independently of the Wayland event loop.
@@ -60,6 +61,12 @@ pub enum StatusMsg {
     /// Wayland surface), so the tray bridge closes the popup it opened on
     /// this cue — see `cce-status-interface`'s `cce-xembed-tray`.
     ClickAway,
+    /// The overview drag-selection carrying the grid's desktop images: one
+    /// line for every `selection` subscriber (in practice the grid) —
+    /// `move <id>:<x>:<y> ...` with the images' new virtual positions as a
+    /// group move steps, then `drop` at its release. See
+    /// [`crate::selection`].
+    Selection(String),
 }
 
 /// Subscription types that the status bar script can request.
@@ -81,6 +88,9 @@ enum Subscription {
     /// `clickaway` — one-shot `press` lines only (see
     /// `StatusMsg::ClickAway`); never receives state pushes.
     ClickAway,
+    /// `selection` — one-shot group-move lines for the desktop images
+    /// (see `StatusMsg::Selection`); never receives state pushes.
+    Selection,
     /// `backdrop <app_id>` — what THIS segment is composited over, so it can
     /// adapt its own text contrast. Lines are `<luma> <spread>`, both 0-100.
     Backdrop(String),
@@ -104,6 +114,7 @@ impl Subscription {
             "dismiss" => Subscription::Dismiss,
             "shortcuts" => Subscription::Shortcuts,
             "clickaway" => Subscription::ClickAway,
+            "selection" => Subscription::Selection,
             _ => Subscription::Unknown,
         }
     }
@@ -164,6 +175,14 @@ impl StatusSender {
             wake_fd(&self.wake);
         }
     }
+
+    /// Tell the grid where a group move put its images
+    /// (`StatusMsg::Selection`).
+    pub fn send_selection_line(&self, line: &str) {
+        if self.tx.send(StatusMsg::Selection(line.to_string())).is_ok() {
+            wake_fd(&self.wake);
+        }
+    }
 }
 
 /// Write each of `lines` to every client subscribed to `topic`, dropping
@@ -379,6 +398,7 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
         let mut dismiss_events: Vec<String> = Vec::new();
         let mut shortcut_events: Vec<String> = Vec::new();
         let mut clickaway_events: Vec<String> = Vec::new();
+        let mut selection_events: Vec<String> = Vec::new();
         loop {
             match rx.try_recv() {
                 Ok(StatusMsg::State(update)) => {
@@ -394,6 +414,9 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
                 Ok(StatusMsg::ClickAway) => {
                     clickaway_events.push("press".to_string());
                 }
+                Ok(StatusMsg::Selection(line)) => {
+                    selection_events.push(line);
+                }
                 Err(mpsc::TryRecvError::Empty) => break,
                 Err(mpsc::TryRecvError::Disconnected) => {
                     log::info!("[status] channel disconnected, exiting");
@@ -436,6 +459,7 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
         // order; click-aways only to `clickaway` subscribers.
         push_one_shot(&mut clients, &Subscription::Shortcuts, &shortcut_events);
         push_one_shot(&mut clients, &Subscription::ClickAway, &clickaway_events);
+        push_one_shot(&mut clients, &Subscription::Selection, &selection_events);
 
         // If we got a new update, push it to all clients
         if has_new_update {
@@ -447,7 +471,10 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
                     // only, never state pushes.
                     if matches!(
                         client.subscription,
-                        Subscription::Dismiss | Subscription::Shortcuts | Subscription::ClickAway
+                        Subscription::Dismiss
+                            | Subscription::Shortcuts
+                            | Subscription::ClickAway
+                            | Subscription::Selection
                     ) {
                         continue;
                     }
@@ -531,6 +558,7 @@ fn format_for_subscription(sub: &Subscription, update: &StatusUpdate) -> String
         Subscription::Dismiss
         | Subscription::Shortcuts
         | Subscription::ClickAway
+        | Subscription::Selection
         | Subscription::Unknown => String::new(),
     }
 }
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 500fce6c..d1a3556c 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -3240,8 +3240,12 @@ impl WindowManager {
                     }
                 }
                 // A window carried along by a group move is being moved as
-                // much as the one under the pointer.
-                if let crate::seat::PointerOpType::Move = op.op_type {
+                // much as the one under the pointer — or as the image under
+                // it, when the group was grabbed by one.
+                if matches!(
+                    op.op_type,
+                    crate::seat::PointerOpType::Move | crate::seat::PointerOpType::GroupMove
+                ) {
                     if (*seat).group_move.iter().any(|&(w, _, _)| w == win_ptr) {
                         return true;
                     }
@@ -5296,9 +5300,19 @@ impl WindowManager {
                 self.dirty_windowing();
                 return format!("ok {}\n", enable);
             }
-            // The overview drag-selection: the selected window ids on one
-            // line (`-` for none) and the rubber band's virtual rect while
-            // one is being dragged out.
+            // The desktop grid reporting its images: `grid-items
+            // <id>:<x>:<y>:<w>:<h> ...` in virtual units, the whole list on
+            // every change (none at all clears it). See `crate::selection`.
+            "grid-items" => {
+                let items = crate::selection::parse_desktop_items(&parts[1..]);
+                let n = items.len();
+                self.set_desktop_items(items);
+                return format!("ok {}\n", n);
+            }
+            // The overview drag-selection: the selected window ids and the
+            // selected desktop images' ids, each on one line (`-` for
+            // none), and the rubber band's virtual rect while one is being
+            // dragged out.
             "selection" => {
                 let ids: Vec<String> = self
                     .selection
@@ -5306,6 +5320,16 @@ impl WindowManager {
                     .iter()
                     .map(|&w| (*w).ref_key.index.to_string())
                     .collect();
+                let items: Vec<String> =
+                    self.selection.items.iter().map(|id| id.to_string()).collect();
+                // Every image the grid has reported, so a shadow can see
+                // the report landed and where a group move left them.
+                let desk: Vec<String> = self
+                    .selection
+                    .desktop_items
+                    .iter()
+                    .map(|i| format!("{}@{:.0},{:.0},{:.0}x{:.0}", i.id, i.x, i.y, i.w, i.h))
+                    .collect();
                 let band = match self.selection.marquee {
                     Some(m) => {
                         let (x, y, w, h) = m.rect();
@@ -5314,9 +5338,11 @@ impl WindowManager {
                     None => "-".to_string(),
                 };
                 format!(
-                    "selected={} band={}\n",
+                    "selected={} items={} band={} desk={}\n",
                     if ids.is_empty() { "-".to_string() } else { ids.join(",") },
-                    band
+                    if items.is_empty() { "-".to_string() } else { items.join(",") },
+                    band,
+                    if desk.is_empty() { "-".to_string() } else { desk.join(";") },
                 )
             }
             // The camera as it stands and where it is easing to — what a
@@ -5324,13 +5350,14 @@ impl WindowManager {
             "camera" => {
                 let fmt = |v: Option<f64>| v.map_or("-".to_string(), |v| format!("{:.1}", v));
                 format!(
-                    "pan_x={:.1} pan_y={:.1} zoom={:.3} target_x={} target_y={} anim={}\n",
+                    "pan_x={:.1} pan_y={:.1} zoom={:.3} target_x={} target_y={} anim={} mode={:?}\n",
                     self.desk_pan_x + self.pan_pending[0],
                     self.desk_pan_y + self.pan_pending[1],
                     self.desk_zoom,
                     fmt(self.target_desk_pan_x),
                     fmt(self.target_desk_pan_y),
-                    self.camera_anim_active
+                    self.camera_anim_active,
+                    self.mode
                 )
             }
             "pan-by" => {
diff --git a/verify/image-selection-test b/verify/image-selection-test
new file mode 100755
index 00000000..81a6666d
--- /dev/null
+++ b/verify/image-selection-test
@@ -0,0 +1,242 @@
+#!/usr/bin/env bash
+# image-selection-test — behavioral test for the overview drag-selection
+# picking up the desktop IMAGES (cce-grid's pinned items) beside windows,
+# and a group move carrying them (src/server/selection.rs, since
+# 2026-09-30).
+#
+# Setup: a private cce-shadow instance whose seeded desktop holds two
+# images (a 100x100 red one right of where cce-terminal maps, a 60x100 blue
+# one left of it), cce-grid running against the shadow so it reports them
+# (`grid-items` on the control socket), and one cce-terminal window. The
+# camera stays at zoom 1 in overview with one window, so everything is
+# driven in virtual units and converted through `ccectl camera`. The
+# assertions, in order:
+#
+#   1. the grid's report landed (`ccectl selection` desk=...)
+#   2. a band over the red image picks it, live, and the pick survives the
+#      release
+#   3. a press on the selected image drags it (PointerOpType::GroupMove):
+#      the compositor's rect follows mid-drag, the grid saves the sidecar
+#      on `drop`
+#   4. a band over everything, then a drag of the terminal, moves both
+#      images by exactly the terminal's offset (snap included) and the
+#      sidecar agrees
+#   5. a background click drops the selection and stays in overview — also
+#      with only images selected (has_selection), where it used to exit
+#   6. a press on an UNSELECTED image drops the selection and the grid
+#      drags the image itself, then re-reports
+#   7. leaving overview clears it
+#
+# The compositor binary is whatever cce-shadow resolves (installed first,
+# then the workspace target/release); extra args go to `cce-shadow start`,
+# so `--bin ../target/release/cce-fx` pins the tree's own build. The grid
+# is $CCE_GRID, else the workspace's target/release/cce-grid, else the one
+# on PATH — build it with `cargo build --release -p cce-grid` to test the
+# tree's.
+
+set -euo pipefail
+
+here=$(cd "$(dirname "$0")" && pwd)
+repo=$(dirname "$here")
+shadow="$repo/scripts/cce-shadow"
+out=$(mktemp -d)
+inst=""
+
+cleanup() {
+    [ -n "$inst" ] && "$shadow" --instance "$inst" stop >/dev/null 2>&1 || true
+    rm -rf "$out"
+}
+trap cleanup EXIT
+
+fail() {
+    echo "FAIL: $*" >&2
+    echo "--- grid log ---" >&2
+    cat "$out/grid.log" >&2 || true
+    echo "--- compositor log tail ---" >&2
+    [ -n "$inst" ] && "$shadow" --instance "$inst" logs 2>/dev/null | tail -20 >&2 || true
+    exit 1
+}
+
+grid=${CCE_GRID:-}
+if [ -z "$grid" ]; then
+    if [ -x "$repo/../target/release/cce-grid" ]; then
+        grid="$repo/../target/release/cce-grid"
+    else
+        grid=$(command -v cce-grid) || fail "no cce-grid: build it or set CCE_GRID"
+    fi
+fi
+command -v cce-terminal >/dev/null || fail "cce-terminal is not installed"
+
+echo "==> starting shadow instance"
+inst=$("$shadow" start --new "$@" 2>&1 | sed -n "s/.*claimed instance '\([^']*\)'.*/\1/p")
+[ -n "$inst" ] || fail "could not claim a shadow instance"
+sc() { "$shadow" --instance "$inst" "$@"; }
+home=$(sc env | sed -n 's/^export HOME=//p')
+[ -n "$home" ] || fail "could not read the shadow's HOME"
+
+echo "==> seeding two desktop images"
+mkdir -p "$home/Desktop" "$home/.local/share/cce"
+python3 - "$home/Desktop" <<'EOF'
+import struct, sys, zlib
+def png(path, w, h, rgb):
+    raw = b"".join(b"\0" + bytes(rgb) * w for _ in range(h))
+    def chunk(t, d): return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
+    open(path, "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+                            + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
+png(sys.argv[1] + "/red.png", 100, 100, (220, 60, 60))
+png(sys.argv[1] + "/blue.png", 60, 100, (60, 120, 220))
+EOF
+cat > "$home/.local/share/cce/desktop-items.json" <<EOF
+[{"path":"$home/Desktop/red.png","x":960,"y":150,"w":100,"h":100},
+ {"path":"$home/Desktop/blue.png","x":-90,"y":150,"w":60,"h":100}]
+EOF
+
+echo "==> starting the grid ($grid) and a terminal"
+sc run "$grid" >"$out/grid.log" 2>&1 &
+sc ctl spawn cce-terminal >/dev/null
+sleep 4
+
+echo "==> driving"
+python3 - "$shadow" "$inst" "$home" "$out/grid.log" <<'EOF' || fail "assertions failed"
+import json, re, subprocess, sys, time
+S, INST, H, GRIDLOG = sys.argv[1:5]
+fails = 0
+
+def ctl(*a):
+    return subprocess.run([S, "--instance", INST, "ctl", *a], capture_output=True, text=True).stdout.strip()
+
+def cam():
+    c = ctl("camera")
+    g = lambda k: re.search(rf"{k}=([-\w.]+)", c).group(1)
+    return float(g("pan_x")), float(g("pan_y")), float(g("zoom")), g("mode")
+
+def sel():
+    s = ctl("selection")
+    g = lambda k: re.search(rf"{k}=(\S+)", s).group(1)
+    desk = {}
+    if g("desk") != "-":
+        for tok in g("desk").split(";"):
+            i, rest = tok.split("@")
+            x, y, wh = rest.split(",")
+            w, h = wh.split("x")
+            desk[int(i)] = (float(x), float(y), float(w), float(h))
+    windows = [] if g("selected") == "-" else [int(v) for v in g("selected").split(",")]
+    items = [] if g("items") == "-" else [int(v) for v in g("items").split(",")]
+    return dict(raw=s, windows=windows, items=items, desk=desk, band=g("band"))
+
+def term():
+    for line in ctl("windows").splitlines():
+        if "app_id=cce-terminal" in line:
+            g = lambda k: re.search(rf"\b{k}=([-\w.]+)", line).group(1)
+            return float(g("vx")), float(g("vy")), float(g("w")), float(g("h"))
+    return None
+
+def move(vx, vy):
+    px, py, z, _ = cam()
+    ctl("pointer-move-to", str(round((vx - px) * z)), str(round((vy - py) * z)))
+    time.sleep(0.08)
+
+def press(): ctl("pointer-press", "left"); time.sleep(0.1)
+def release(): ctl("pointer-release", "left"); time.sleep(0.5)
+
+def check(name, ok, detail=""):
+    global fails
+    print(("  ok    " if ok else "  FAIL  ") + name + ("" if ok else f"  [{detail}]"))
+    if not ok: fails += 1
+
+def band(x0, y0, x1, y1):
+    move(x0, y0); press()
+    move((x0 + x1) / 2, (y0 + y1) / 2); move(x1, y1); time.sleep(0.2)
+    mid = sel(); release(); return mid, sel()
+
+def sidecar():
+    return {d["path"].rsplit("/", 1)[1]: (d["x"], d["y"]) for d in json.load(open(f"{H}/.local/share/cce/desktop-items.json"))}
+
+def center(r): return (r[0] + r[2] / 2, r[1] + r[3] / 2)
+
+# 1. the report
+for _ in range(25):
+    s0 = sel()
+    if len(s0["desk"]) == 2 and term(): break
+    time.sleep(0.2)
+check("grid reported both images", len(s0["desk"]) == 2, s0["raw"])
+check("terminal mapped", term() is not None)
+red = next(i for i, r in s0["desk"].items() if r[2] == 100)
+blue = next(i for i, r in s0["desk"].items() if r[2] == 60)
+if cam()[3] != "Overview":
+    ctl("overview"); time.sleep(1.5)
+check("in overview", cam()[3] == "Overview", str(cam()))
+
+# 2. band over the red image only (pressed to its right, clear of the terminal)
+r = s0["desk"][red]
+mid, after = band(r[0] + r[2] + 30, r[1] - 30, r[0] - 10, r[1] + r[3] + 10)
+check("band mid-drag picks the red image only", mid["items"] == [red] and mid["windows"] == [] and mid["band"] != "-", mid["raw"])
+check("selection persists after release", after["items"] == [red] and after["band"] == "-", after["raw"])
+
+# 3. press the selected image, drag it down 100
+cx, cy = center(r)
+move(cx, cy); press(); move(cx, cy + 50); move(cx, cy + 100); time.sleep(0.3)
+mid = sel()
+check("image follows the drag mid-move", abs(mid["desk"][red][1] - (r[1] + 100)) < 1 and mid["items"] == [red], mid["raw"])
+release(); time.sleep(0.5)
+after = sel()
+check("image stays where dropped, still selected", abs(after["desk"][red][1] - (r[1] + 100)) < 1 and after["items"] == [red], after["raw"])
+check("grid saved the new position", abs(sidecar()["red.png"][1] - (r[1] + 100)) < 1, str(sidecar()))
+check("grid logged the drop", "group move dropped" in open(GRIDLOG).read())
+
+# 4. band over everything, drag the terminal, images follow by its offset
+t = term(); d = sel()["desk"]
+x0 = min(d[blue][0], t[0]) - 20; y0 = min(d[blue][1], d[red][1], t[1]) - 20
+x1 = max(d[red][0] + d[red][2], t[0] + t[2]) + 20; y1 = max(d[red][1] + d[red][3], t[1] + t[3]) + 20
+mid, after = band(x0, y0, x1, y1)
+check("band over everything picks the window and both images", len(after["windows"]) == 1 and sorted(after["items"]) == sorted([red, blue]), after["raw"])
+t0 = term(); d0 = sel()["desk"]
+tcx, tcy = t0[0] + t0[2] / 2, t0[1] + t0[3] / 2
+move(tcx, tcy); press(); move(tcx, tcy + 100); move(tcx, tcy + 200); time.sleep(0.3)
+release(); time.sleep(0.6)
+t1 = term(); d1 = sel()["desk"]
+dt = (t1[0] - t0[0], t1[1] - t0[1])
+check("terminal moved", dt != (0.0, 0.0), f"dt={dt}")
+for name, i in (("red", red), ("blue", blue)):
+    di = (d1[i][0] - d0[i][0], d1[i][1] - d0[i][1])
+    check(f"{name} image moved by the terminal's offset", abs(di[0] - dt[0]) < 1 and abs(di[1] - dt[1]) < 1, f"image {di} terminal {dt}")
+sc = sidecar()
+check("sidecar matches the compositor's rects", abs(sc["red.png"][1] - d1[red][1]) < 1 and abs(sc["blue.png"][1] - d1[blue][1]) < 1, f"{sc} vs {d1}")
+
+# 5. background clicks
+t = term()
+bx, by = t[0] - 15, t[1] + t[3] / 2
+move(bx, by); press(); release()
+s = sel()
+check("background click drops the selection", s["windows"] == [] and s["items"] == [], s["raw"])
+check("still in overview after dropping", cam()[3] == "Overview", str(cam()))
+b = sel()["desk"][blue]
+mid, after = band(b[0] - 12, b[1] - 12, b[0] + b[2] + 8, b[1] + b[3] + 12)
+check("band picks the blue image only", after["items"] == [blue] and after["windows"] == [], after["raw"])
+move(bx, by + 150); press(); release()
+s = sel()
+check("click drops an image-only selection", s["items"] == [], s["raw"])
+check("and stays in overview", cam()[3] == "Overview", str(cam()))
+
+# 6. a press on an unselected image
+mid, after = band(b[0] - 12, b[1] - 12, b[0] + b[2] + 8, b[1] + b[3] + 12)
+check("blue selected again", after["items"] == [blue], after["raw"])
+rr = sel()["desk"][red]; cx, cy = center(rr)
+move(cx, cy); press(); time.sleep(0.2)
+s = sel()
+check("press on the unselected red image drops the selection", s["items"] == [], s["raw"])
+move(cx, cy - 30); move(cx, cy - 60); time.sleep(0.2); release(); time.sleep(0.8)
+d = sel()["desk"]
+check("grid dragged the red image itself and re-reported", abs(d[red][1] - (rr[1] - 60)) < 2, f"{d[red]} from {rr}")
+check("blue untouched by that drag", abs(d[blue][1] - b[1]) < 1, str(d))
+
+# 7. leaving overview
+band(rr[0] - 10, rr[1] - 70, rr[0] + rr[2] + 10, rr[1] + rr[3])
+check("something selected before exit", sel()["items"] == [red], sel()["raw"])
+ctl("overview"); time.sleep(1.5)
+s = sel()
+check("leaving overview clears the selection", s["items"] == [] and s["windows"] == [] and cam()[3] == "Normal", s["raw"] + " " + str(cam()))
+sys.exit(1 if fails else 0)
+EOF
+
+echo "PASS"