Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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"