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

commit3fb4404240bb04a5289b96c14b89ad3a4ca9cdb1
parent5eddceb154
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 23:45
overview: drag on the desktop to select windows, drag one to move them all

A left press on the bare desktop in overview, dragged, stretches a rubber
band; every window it touches is selected, live. Pressing the body of a
selected window then moves the whole selection by the offset the grabbed
window took, snap included, so Tiled windows step in whole cells and
re-tile on release. It is cce-designer's network-cursor region for windows.

The background press no longer exits overview on the spot: it starts a
Select seat op and the release decides. A press that never travelled is a
click, which drops the selection if there is one and leaves overview if
there is none.

The band is anchored in virtual coordinates, so the edge auto-pan scrolls
the desk under a held drag. It is drawn outside interactive_tree, since
Scene::at reads a node with no SceneNodeData as background.

ccectl selection prints the selected ids and the band's rect.

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

 CLAUDE.md                    |  50 ++++-
 src/lib.rs                   |   2 +
 src/server/cursor.rs         |  93 ++++++++--
 src/server/output.rs         |   1 +
 src/server/seat.rs           | 179 +++++++++++++-----
 src/server/selection.rs      | 434 +++++++++++++++++++++++++++++++++++++++++++
 src/server/window.rs         |   2 +
 src/server/window_manager.rs |  40 ++++
 8 files changed, 744 insertions(+), 57 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 180e717e..39a5fc1e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -213,12 +213,13 @@ Native libs via `pkg-config`: `wlroots-0.20`, `wayland-server`, `xkbcommon`,
 
 ## Tests
 
-Eleven modules carry unit tests — `window_manager.rs` (the most of any, among
+Twelve modules carry unit tests — `window_manager.rs` (the most of any, among
 them the saved-state matchers: same-program borrowing, untitled entries),
 `backdrop.rs` (the measurement and the desktop/window blend), `config.rs`,
 `xwayland_window.rs`, `screenshot.rs`, `window.rs`, `migrate_input.rs`,
 `text.rs`, `global_shortcuts.rs` (trigger parsing),
-`cursor.rs` (the swipe lean's direction, `swipe_lean`), `min_sizes.rs`. They cluster where the logic is
+`cursor.rs` (the swipe lean's direction, `swipe_lean`), `min_sizes.rs`,
+`selection.rs` (the rubber band's rect and its hit rule). They cluster where the logic is
 pure and the FFI is not, which is the only kind of thing testable in a crate
 this deep in wlroots. The arrange/slotmap tests live in the sibling
 `cce-window-manager` crate — run them with `cargo test -p cce-window-manager`.
@@ -565,6 +566,51 @@ sat outside the edges and the ring that followed hugged them.
 None of this is policy — `cce-window-manager` was untouched. The mode is
 already in `ActionCtx`, but what a *pointer* may grab is mechanism.
 
+### Overview drag-selection
+
+A left press on the bare desktop in overview, dragged, stretches a rubber
+band from the press point, and every window the band touches is selected,
+live (`src/server/selection.rs`; state on `WindowManager::selection`). It is
+cce-designer's network-cursor region brought to windows: a new drag replaces
+the selection, a press on a window outside it drops it, no modifiers. The
+hit rule differs on purpose — the designer asks whether a node's cell is
+inside the region, but a window spans many cells and the background between
+two of them is a strip, so touching is enough.
+
+- **The background press no longer exits overview on the spot.** It starts a
+  `PointerOpType::Select` seat op — the one op whose `window_ptr` is null —
+  and the RELEASE decides: past `selection::DRAG_THRESHOLD` (5 px) it was a
+  drag; short of it, a click, which drops the selection if there is one and
+  leaves overview if there is none. `Cursor::left_click_on_bg_in_overview`
+  went with the press-time exit.
+- **Pressing the body of a selected window moves the whole selection.** The
+  grab fills `Seat::group_move` with the other selected windows and their
+  virtual positions; `op_update`'s Move arm carries them by the offset the
+  grabbed window actually took, snap included, so a group of Tiled windows
+  steps in whole cells and re-tiles on release (`Seat::settle_tiling`, the
+  geometric detection `op_end` always ran, now per window). Carried windows
+  count as `is_window_being_moved` and are skipped by the overview
+  displacement. A tap moves nothing and un-tiles nothing.
+- **The band is anchored in virtual coordinates**, so the edge auto-pan
+  scrolls the desk under a held drag and the band grows with it. The Select
+  arm queues the op frame like any other op for that reason: the edge-pan
+  tick moves the camera and leaves the relayout to `op_update`.
+- **Drawn outside `interactive_tree`**, in a tree on the scene root placed
+  just above it: `Scene::at` stops at the first node it meets, and a node
+  with no `SceneNodeData` reads as background, so a highlight inside the
+  interactive tree turns a press on a selected window into a press on the
+  desktop. Each box is a fill rect plus a `wlr_scene_bevel` in its
+  glint-only focus branch, in `bevel_focus_color`;
+  `WindowManager::draw_selection` places them per frame from
+  `Output::render_and_commit`.
+- 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.
+
 ### Config
 
 Loaded on startup from **`$XDG_CONFIG_HOME/cce/config.kdl`** (falls back to
diff --git a/src/lib.rs b/src/lib.rs
index 3a072531..b8de8e9f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -93,6 +93,8 @@ pub mod wm_node;
 pub mod xwayland_window;
 #[path = "server/min_sizes.rs"]
 pub mod min_sizes;
+#[path = "server/selection.rs"]
+pub mod selection;
 #[path = "server/xwayland_override_redirect.rs"]
 pub mod xwayland_override_redirect;
 #[path = "server/text_input.rs"]
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index decf6eb5..6d214c3e 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -148,7 +148,6 @@ pub struct Cursor {
     pub adjust_hover: *mut crate::window::Window,
     pub right_click_on_bg: bool,
     pub right_click_on_border: bool,
-    pub left_click_on_bg_in_overview: bool,
     /// The grid surface's node mapping — (node_x, node_y, scale) — FROZEN at
     /// the start of an implicit grab that landed on the grid, cleared when
     /// the grab's last button releases. Motion during the grab is mapped
@@ -226,7 +225,6 @@ impl Default for Cursor {
             adjust_hover: std::ptr::null_mut(),
             right_click_on_bg: false,
             right_click_on_border: false,
-            left_click_on_bg_in_overview: false,
             grab_grid: None,
         }
     }
@@ -1644,6 +1642,31 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 // is raised for the drag in `op_start_pointer`. (In
                 // overview hover already focused it.)
                 (*server).wm.stop_panning_animation();
+                // A selected window carries the rest of the selection with
+                // it; a press on any other window drops the selection, the
+                // way a press on a node outside the region does in
+                // cce-designer. The carried Floating windows are raised
+                // here, in stacking order, and the grabbed one over them in
+                // `op_start_pointer`.
+                seat.group_move.clear();
+                if (*server).wm.is_selected(clicked_win) {
+                    let carried: Vec<*mut crate::window::Window> = (*server)
+                        .wm
+                        .selection
+                        .windows
+                        .iter()
+                        .copied()
+                        .filter(|&w| w != clicked_win && (*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::Floating {
+                            (*server).wm.raise_window(w);
+                        }
+                    }
+                } else {
+                    (*server).wm.selection_clear();
+                }
                 let cursor_x = (*cursor.wlr_cursor).x;
                 let cursor_y = (*cursor.wlr_cursor).y;
                 seat.op = Some(crate::seat::SeatOp {
@@ -1690,9 +1713,40 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                         }
                     }
                 }
-                cursor.left_click_on_bg_in_overview = true;
-                (*server).wm.execute_action(&crate::config::Action::Overview, None);
+                // A press on the bare desktop is a click (which leaves
+                // overview) or the start of a drag-selection, and only the
+                // release can say which: it arms the selection, and the
+                // release path answers a press that never travelled.
+                (*server).wm.stop_panning_animation();
+                (*server).wm.selection_press(lx, ly);
+                seat.group_move.clear();
+                let cursor_x = (*cursor.wlr_cursor).x;
+                let cursor_y = (*cursor.wlr_cursor).y;
+                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::Select,
+                    start_win_x: 0,
+                    start_win_y: 0,
+                    start_win_w: 0,
+                    start_win_h: 0,
+                    start_win_virtual_x: 0.0,
+                    start_win_virtual_y: 0.0,
+                    start_was_tiled: false,
+                    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"crosshair\0".as_ptr() as *const _);
                 return;
             }
         }
@@ -1851,6 +1905,8 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                         crate::seat::PointerOpType::Move => {
                             cursor.set_xcursor(b"grab\0".as_ptr() as *const _);
                         }
+                        // A pointer bind is a move or a resize.
+                        crate::seat::PointerOpType::Select => {}
                     }
                     return;
                 }
@@ -2158,6 +2214,26 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
             
             let op = seat.op.unwrap();
 
+            if op.op_type == crate::seat::PointerOpType::Select {
+                let dragged = (*server).wm.selection_release();
+                // Released before the op ends, so its end re-evaluates the
+                // pointer and the crosshair does not outlast the drag.
+                cursor.pressed.remove(&(*event).button);
+                seat.op_end();
+                // A press that never travelled is a click on the desktop.
+                // With windows selected it drops the selection, as a click
+                // 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() {
+                        (*server).wm.selection_clear();
+                    } else {
+                        (*server).wm.execute_action(&crate::config::Action::Overview, None);
+                    }
+                }
+                return;
+            }
+
             #[allow(unused_assignments)]
             if !op.window_ptr.is_null()
                 && (*op.window_ptr).is_status_bar()
@@ -2391,15 +2467,6 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 return;
             }
 
-            if (*event).button == 0x110 && cursor.left_click_on_bg_in_overview {
-                cursor.left_click_on_bg_in_overview = false;
-                if cursor.pressed.is_empty() && seat.op.is_some() {
-                    seat.op_release = true;
-                    (*(*seat).server).wm.dirty_windowing();
-                }
-                return;
-            }
-
             if (*event).button == 0x111 && (cursor.right_click_on_bg || cursor.right_click_on_border) {
                 cursor.right_click_on_bg = false;
                 cursor.right_click_on_border = false;
diff --git a/src/server/output.rs b/src/server/output.rs
index 3c5091c8..195e482d 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -619,6 +619,7 @@ impl Output {
         // Update grid node positions and parameters first, which marks the scene output as damaged if changed
         self.draw_grid();
         self.draw_adjust_overlay();
+        (*self.server).wm.draw_selection();
         // Right after draw_grid, whose geometry this reuses, and BEFORE the
         // needs-frame early-out: a camera move slides the lattice under a
         // segment that has no damage of its own, and the bar still has to
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 17a18f45..91798f2c 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -11,6 +11,10 @@ pub enum SeatOpInput {
 pub enum PointerOpType {
     Move,
     Resize { edges: crate::window::Edges },
+    /// The overview drag-selection: a press on the bare desktop, stretching
+    /// a rubber band. The one op with no window (`window_ptr` is null); its
+    /// state is the window manager's (`crate::selection`).
+    Select,
 }
 
 #[derive(Clone, Copy, Debug)]
@@ -110,6 +114,13 @@ pub struct Seat {
     /// home. Cleared (finalizing the positions) in `op_end`. Lives on the
     /// Seat because `SeatOp` is `Copy`.
     pub overview_displaced: Vec<(*mut crate::window::Window, f64, f64)>,
+    /// The other selected windows a move op carries along, with the virtual
+    /// position each had at the grab: a press on the body of a selected
+    /// window moves the whole selection (`crate::selection`). They follow
+    /// the grabbed window's SNAPPED position rigidly, by the same offset.
+    /// 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)>,
     pub wm_sent_x: i32,
     pub wm_sent_y: i32,
 
@@ -161,6 +172,7 @@ impl Seat {
             op_release: false,
             suppress_focus_pan: false,
             overview_displaced: Vec::new(),
+            group_move: Vec::new(),
             wm_sent_x: 0,
             wm_sent_y: 0,
             request_set_cursor: std::mem::zeroed(),
@@ -1216,7 +1228,23 @@ impl Seat {
             op.y = y;
             let dx = op.x - op.start_x;
             let dy = op.y - op.start_y;
-            
+
+            if op.op_type == PointerOpType::Select {
+                // No window to move. The edge pan below still runs, and its
+                // tick comes back through here, which is what stretches the
+                // band while the desk scrolls under a still pointer — and
+                // why the frame is queued as for any other op: the tick
+                // moves the camera and leaves the relayout to this, so
+                // without it the windows stay where they were drawn while
+                // the grid scrolls away beneath them.
+                let travelled = dx.abs() > crate::selection::DRAG_THRESHOLD
+                    || dy.abs() > crate::selection::DRAG_THRESHOLD;
+                (*self.server).wm.selection_motion(x as f64, y as f64, travelled);
+                (*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
@@ -1435,9 +1463,47 @@ impl Seat {
                                     (virtual_dx, virtual_dy),
                                     &sp,
                                     &mut self.overview_displaced,
+                                    &self.group_move,
                                 );
                             }
 
+                            // A group move: the rest of the selection
+                            // follows by the offset the grabbed window
+                            // actually took, snap included, so the group
+                            // keeps its shape. Untouched until that offset
+                            // is something — the release of a tap runs
+                            // 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;
+                            }
+
                             let (final_x, final_y) = (*win).virtual_to_screen(vx, vy);
                             (*win).rendering_requested.x = final_x;
                             (*win).rendering_requested.y = final_y;
@@ -1527,6 +1593,8 @@ impl Seat {
                         };
                         (*win).set_dimensions(new_w, new_h);
                     }
+                    // Handled above: it has no window to get here with.
+                    PointerOpType::Select => {}
                 }
             }
             // The configure and the relayout go out once per output frame,
@@ -1549,6 +1617,12 @@ impl Seat {
         let mut vy = 0.0;
         let eligible = wm.layout.desktop_edge_pan
             && match self.op {
+                // The drag-selection has no window, and scrolls the desk
+                // only once the band is up — a press held still near the
+                // screen edge is a click.
+                Some(ref op) if op.op_type == PointerOpType::Select => {
+                    wm.selection.marquee.is_some()
+                }
                 Some(ref op) => {
                     !op.window_ptr.is_null()
                         && !(*op.window_ptr).closed
@@ -1574,6 +1648,50 @@ impl Seat {
         wm.set_edge_pan_velocity(vx, vy);
     }
 
+    /// Geometric mode detection: a move/resize that lands every content edge
+    /// on a visible desktop-grid cell edge makes the window Tiled (it then
+    /// reports the maximized state to its client); landing off-grid makes
+    /// it Floating, in place. Only windows resolving Floating/Tiled
+    /// participate — Popup/Overlay/Status/Fullscreen are untouched.
+    unsafe fn settle_tiling(&mut self, win: *mut crate::window::Window) {
+        let resolved = (*self.server).wm.get_mode_for_window(win);
+        if !matches!(
+            resolved,
+            crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Tiled
+        ) {
+            return;
+        }
+        // Unscaled params: alignment classifies the resting geometry, the
+        // zoom-aware grab distance is irrelevant.
+        let sp = (*self.server).wm.layout.snap_params();
+        let (w, h) = match (*win).wm_requested.dimensions {
+            // A just-finished resize may not be acked into box_geom yet;
+            // the requested size is what the window is about to become.
+            Some(d) => (d.width as f64, d.height as f64),
+            None => ((*win).box_geom.width as f64, (*win).box_geom.height as f64),
+        };
+        let aligned = crate::policy::snap::is_cell_aligned(
+            (*win).virtual_x,
+            (*win).virtual_y,
+            w,
+            h,
+            &sp,
+            1.0,
+        );
+        if aligned && resolved != crate::tiling::TilingMode::Tiled {
+            (*win).tiling_mode = crate::tiling::TilingMode::Tiled;
+            (*win).mode_locked = true;
+            (*self.server).wm.dirty_windowing();
+        } else if !aligned && resolved == crate::tiling::TilingMode::Tiled {
+            // Un-tile in place: clearing was_tiled keeps the arrange Exit
+            // transition from restoring the old floating geometry.
+            (*win).was_tiled = false;
+            (*win).tiling_mode = crate::tiling::TilingMode::Floating;
+            (*win).mode_locked = true;
+            (*self.server).wm.dirty_windowing();
+        }
+    }
+
     pub unsafe fn op_end(&mut self) {
         // Wherever everything sits now is final.
         self.overview_displaced.clear();
@@ -1594,48 +1712,19 @@ impl Seat {
                         (*self.server).wm.dirty_windowing();
                     }
                 }
-                // Geometric mode detection: a move/resize that lands every
-                // content edge on a visible desktop-grid cell edge makes the
-                // window Tiled (it then reports the maximized state to its
-                // client); landing off-grid makes it Floating, in place.
-                // Only windows resolving Floating/Tiled participate —
-                // Popup/Overlay/Status/Fullscreen are untouched.
-                let resolved = (*self.server).wm.get_mode_for_window(win);
-                if matches!(
-                    resolved,
-                    crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Tiled
-                ) {
-                    // Unscaled params: alignment classifies the resting
-                    // geometry, the zoom-aware grab distance is irrelevant.
-                    let sp = (*self.server).wm.layout.snap_params();
-                    let (w, h) = match (*win).wm_requested.dimensions {
-                        // A just-finished resize may not be acked into
-                        // box_geom yet; the requested size is what the
-                        // window is about to become.
-                        Some(d) => (d.width as f64, d.height as f64),
-                        None => ((*win).box_geom.width as f64, (*win).box_geom.height as f64),
-                    };
-                    let aligned = crate::policy::snap::is_cell_aligned(
-                        (*win).virtual_x,
-                        (*win).virtual_y,
-                        w,
-                        h,
-                        &sp,
-                        1.0,
-                    );
-                    if aligned && resolved != crate::tiling::TilingMode::Tiled {
-                        (*win).tiling_mode = crate::tiling::TilingMode::Tiled;
-                        (*win).mode_locked = true;
-                        (*self.server).wm.dirty_windowing();
-                    } else if !aligned && resolved == crate::tiling::TilingMode::Tiled {
-                        // Un-tile in place: clearing was_tiled keeps the
-                        // arrange Exit transition from restoring the old
-                        // floating geometry.
-                        (*win).was_tiled = false;
-                        (*win).tiling_mode = crate::tiling::TilingMode::Floating;
-                        (*win).mode_locked = true;
-                        (*self.server).wm.dirty_windowing();
+                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
@@ -1664,6 +1753,7 @@ impl Seat {
                 }
             }
         }
+        self.group_move.clear();
     }
 }
 
@@ -1690,6 +1780,7 @@ unsafe fn displace_covered(
     drag_delta: (f64, f64),
     sp: &crate::policy::snap::SnapParams,
     ledger: &mut Vec<(*mut crate::window::Window, f64, f64)>,
+    group: &[(*mut crate::window::Window, f64, f64)],
 ) {
     let wm = &mut (*server).wm;
     // A window can close mid-drag; drop its entry before any deref.
@@ -1706,6 +1797,10 @@ unsafe fn displace_covered(
         if w.is_null() || w == win || (*w).closed || (*w).minimized {
             continue;
         }
+        // A window moving WITH the drag is not in its way.
+        if group.iter().any(|&(g, _, _)| g == w) {
+            continue;
+        }
         if !matches!((*w).state, crate::window::WindowState::Mapped) {
             continue;
         }
diff --git a/src/server/selection.rs b/src/server/selection.rs
new file mode 100644
index 00000000..3fca3e1b
--- /dev/null
+++ b/src/server/selection.rs
@@ -0,0 +1,434 @@
+//! Drag-selection of windows in overview.
+//!
+//! A left press on the bare desktop in overview, dragged, stretches a
+//! rectangle from the press point to the pointer; every window the
+//! rectangle touches is selected, live, while the button is held. It is the
+//! compositor's version of cce-designer's network cursor region, with one
+//! difference that follows from what is being selected: a node sits on one
+//! lattice cell, so the designer asks whether that cell is inside the
+//! region, while a window spans many cells and in overview the background
+//! between two of them is a narrow strip — so here touching is enough.
+//!
+//! What the selection is for: pressing the body of a selected window moves
+//! every selected window together (`Seat::group_move`, filled at the grab).
+//! Like the designer, a new drag replaces the selection, a press on a window
+//! outside it drops it, and there are no modifiers.
+//!
+//! 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.
+//!
+//! The rectangle is anchored in VIRTUAL (desk) coordinates, so it stays put
+//! on the desk while the edge auto-pan scrolls the camera under a held drag.
+//! Selection exists only in overview and is dropped when the mode is left
+//! (`WindowManager::set_mode`).
+//!
+//! Drawing is a pool of (fill rect, glint bevel) pairs in one tree that
+//! hangs off the scene ROOT, outside `interactive_tree`: `Scene::at` stops
+//! at the first node it meets, and a node with no `SceneNodeData` reads as
+//! "nothing here", so a highlight inside the interactive tree would turn a
+//! press on a selected window into a press on the background.
+
+use crate::ffi;
+use crate::server::WlList;
+use crate::window::Window;
+use crate::window_manager::{WindowManager, WindowManagerMode};
+
+/// Pointer travel, in layout px on either axis, past which a background
+/// press is a drag and not a click. The same figure the overview tap on a
+/// window uses (`handle_button`'s release path).
+pub const DRAG_THRESHOLD: i32 = 5;
+
+/// Fill opacity of the rubber band, and of the wash over a selected window.
+const MARQUEE_FILL: f32 = 0.14;
+const SELECTED_FILL: f32 = 0.12;
+/// Corner radius of the rubber band, screen px.
+const MARQUEE_RADIUS: f64 = 6.0;
+
+/// The rubber band: the press point and the pointer, in virtual coordinates.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Marquee {
+    pub anchor: (f64, f64),
+    pub far: (f64, f64),
+}
+
+impl Marquee {
+    /// `(x, y, width, height)`, whichever way the drag went.
+    pub fn rect(&self) -> (f64, f64, f64, f64) {
+        let x = self.anchor.0.min(self.far.0);
+        let y = self.anchor.1.min(self.far.1);
+        (
+            x,
+            y,
+            (self.anchor.0 - self.far.0).abs(),
+            (self.anchor.1 - self.far.1).abs(),
+        )
+    }
+}
+
+/// Does `rect` touch `win`? Both `(x, y, width, height)`. Sharing only an
+/// edge is not touching, and a window with no area is never touched. The
+/// band itself may be a line: a drag straight down through a window has no
+/// width and still crosses it.
+pub fn touches(rect: (f64, f64, f64, f64), win: (f64, f64, f64, f64)) -> bool {
+    let (rx, ry, rw, rh) = rect;
+    let (wx, wy, ww, wh) = win;
+    ww > 0.0 && wh > 0.0 && rx < wx + ww && wx < rx + rw && ry < wy + wh && wy < ry + rh
+}
+
+pub struct Selection {
+    /// The selected windows, in `wm.windows` order. Entries are dropped in
+    /// `Window::destroy`; a window that is merely unmapped or minimized
+    /// stays listed and is skipped where it matters.
+    pub windows: Vec<*mut Window>,
+    /// Where the armed background press landed (virtual). Set on press,
+    /// cleared on release.
+    pub anchor: Option<(f64, f64)>,
+    /// The rubber band, once the press has travelled past the threshold.
+    pub marquee: Option<Marquee>,
+    tree: *mut ffi::wlr_scene_tree,
+    boxes: Vec<(*mut ffi::wlr_scene_rect, *mut ffi::wlr_scene_bevel)>,
+}
+
+impl Default for Selection {
+    fn default() -> Self {
+        Self {
+            windows: Vec::new(),
+            anchor: None,
+            marquee: None,
+            tree: std::ptr::null_mut(),
+            boxes: Vec::new(),
+        }
+    }
+}
+
+impl WindowManager {
+    /// Layout box origin of the first enabled output, the one the camera is
+    /// measured against (`Window::virtual_to_screen` uses the same).
+    unsafe fn selection_output_origin(&self) -> (f64, f64) {
+        let outputs_list = &(*self.server).om.outputs as *const ffi::wl_list as *mut WlList;
+        let mut curr = (*outputs_list).next;
+        while curr != outputs_list {
+            let output = crate::container_of!(curr, crate::output::Output, link);
+            if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+                let b = (*output).sent.box_layout();
+                return (b.x as f64, b.y as f64);
+            }
+            curr = (*curr).next;
+        }
+        (0.0, 0.0)
+    }
+
+    pub unsafe fn layout_to_virtual(&self, lx: f64, ly: f64) -> (f64, f64) {
+        let (cam, _, _) = self.layout_camera();
+        let zoom = cam.zoom.max(0.01);
+        let (ox, oy) = self.selection_output_origin();
+        (cam.pan_x + (lx - ox) / zoom, cam.pan_y + (ly - oy) / zoom)
+    }
+
+    pub unsafe fn virtual_to_layout(&self, vx: f64, vy: f64) -> (f64, f64) {
+        let (cam, _, _) = self.layout_camera();
+        let (ox, oy) = self.selection_output_origin();
+        (ox + (vx - cam.pan_x) * cam.zoom, oy + (vy - cam.pan_y) * cam.zoom)
+    }
+
+    pub fn is_selected(&self, window: *mut Window) -> bool {
+        self.selection.windows.iter().any(|&w| w == window)
+    }
+
+    /// 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) {
+        self.selection.anchor = Some(self.layout_to_virtual(lx, ly));
+        self.selection.marquee = None;
+    }
+
+    /// The pointer moved with the press held. `travelled` is whether it has
+    /// left the click threshold; once it has, the band is up for the rest of
+    /// the press even if the pointer comes back to where it started.
+    pub unsafe fn selection_motion(&mut self, lx: f64, ly: f64, travelled: bool) {
+        let Some(anchor) = self.selection.anchor else { return };
+        if self.selection.marquee.is_none() && !travelled {
+            return;
+        }
+        let marquee = Marquee { anchor, far: self.layout_to_virtual(lx, ly) };
+        self.selection.marquee = Some(marquee);
+        let rect = marquee.rect();
+        let mut picked: Vec<*mut Window> = Vec::new();
+        for &w in self.windows.iter() {
+            if !self.selectable(w) {
+                continue;
+            }
+            let win = (
+                (*w).virtual_x,
+                (*w).virtual_y,
+                (*w).box_geom.width as f64,
+                (*w).box_geom.height as f64,
+            );
+            if touches(rect, win) {
+                picked.push(w);
+            }
+        }
+        if picked != self.selection.windows {
+            log::debug!("selection: {} window(s)", picked.len());
+            self.selection.windows = picked;
+        }
+        self.schedule_frame_all_outputs();
+    }
+
+    /// The press ended. Returns whether it was a drag: a press that never
+    /// travelled is a click on the background, which the caller answers by
+    /// leaving overview.
+    pub unsafe fn selection_release(&mut self) -> bool {
+        let dragged = self.selection.marquee.is_some();
+        self.selection.anchor = None;
+        self.selection.marquee = None;
+        self.schedule_frame_all_outputs();
+        dragged
+    }
+
+    pub unsafe fn selection_clear(&mut self) {
+        if self.selection.windows.is_empty() && self.selection.marquee.is_none() {
+            return;
+        }
+        self.selection.windows.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.
+    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 {
+            self.schedule_frame_all_outputs();
+        }
+    }
+
+    /// Windows the band can pick up, and a group move can carry: the ones
+    /// with a place on the desk. The same set the overview displacement
+    /// considers (`seat::displace_covered`).
+    pub unsafe fn selectable(&self, w: *mut Window) -> bool {
+        if w.is_null() || (*w).closed || (*w).minimized {
+            return false;
+        }
+        if !matches!((*w).state, crate::window::WindowState::Mapped) {
+            return false;
+        }
+        if (*w).is_status_bar() || (*w).is_wallpaper() || (*w).is_grid() {
+            return false;
+        }
+        matches!(
+            self.get_mode_for_window(w),
+            crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Tiled
+        )
+    }
+
+    /// `selectable`, for a window a group move is already carrying: the
+    /// drag floats a Tiled one, which `selectable` would still accept, but
+    /// the pointer may dangle if the window closed mid-drag — so this one
+    /// checks it is still a window before looking at it.
+    pub unsafe fn selectable_in_drag(&self, w: *mut Window) -> bool {
+        self.windows.iter().any(|&p| p == w) && self.selectable(w)
+    }
+
+    /// Place the band and the selected windows' highlights for the frame
+    /// about to render. Called from `Output::render_and_commit` beside the
+    /// grid, so the highlights follow a window through a drag and the camera
+    /// through a pan; with nothing selected it is one disabled tree.
+    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());
+        if !showing {
+            if !self.selection.tree.is_null() {
+                ffi::wlr_scene_node_set_enabled(
+                    self.selection.tree as *mut ffi::wlr_scene_node,
+                    false,
+                );
+            }
+            return;
+        }
+
+        // (x, y, width, height, corner radius, fill opacity), layout px.
+        let mut wanted: Vec<(i32, i32, i32, i32, i32, f32)> = Vec::new();
+        for &w in self.selection.windows.iter() {
+            if !self.selectable(w) || (*w).rendering_requested.hidden {
+                continue;
+            }
+            let sc = if (*w).scale > 0.0 { (*w).scale } else { 1.0 };
+            let g = (*w).box_geom;
+            let radius = crate::window::widen_corner_radius(
+                (*w).root_plate_radius_base(),
+                g.width,
+                g.height,
+            );
+            wanted.push((
+                g.x,
+                g.y,
+                (g.width as f64 * sc) as i32,
+                (g.height as f64 * sc) as i32,
+                (radius as f64 * sc) as i32,
+                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);
+            let (x1, y1) = self.virtual_to_layout(vx + vw, vy + vh);
+            let (w, h) = ((x1 - x0).round() as i32, (y1 - y0).round() as i32);
+            let radius = MARQUEE_RADIUS.min(w.min(h) as f64 / 2.0) as i32;
+            wanted.push((x0.round() as i32, y0.round() as i32, w, h, radius, MARQUEE_FILL));
+        }
+
+        if self.selection.tree.is_null() {
+            let scene = &(*self.server).scene;
+            let tree = ffi::wlr_scene_tree_create(&mut (*scene.wlr_scene).tree);
+            if tree.is_null() {
+                return;
+            }
+            // Over everything interactive, under a drag icon.
+            ffi::wlr_scene_node_place_above(
+                tree as *mut ffi::wlr_scene_node,
+                scene.interactive_tree as *mut ffi::wlr_scene_node,
+            );
+            // Desk content, like the windows it marks: rendered with the
+            // camera's sub-pixel offset.
+            ffi::river_scene_tree_set_desk_offset(tree, true);
+            self.selection.tree = tree;
+        }
+        let tree = self.selection.tree;
+        ffi::wlr_scene_node_set_enabled(tree as *mut ffi::wlr_scene_node, true);
+
+        let layout = &self.layout;
+        let accent = layout.bevel_focus_color;
+        let (light_x, light_y) = {
+            let (lx, ly) = (layout.bevel_light_x, layout.bevel_light_y);
+            let len = (lx * lx + ly * ly).sqrt();
+            if len > 1e-6 { (lx / len, ly / len) } else { (-0.7071, -0.7071) }
+        };
+        // The glint has to read whatever the window bevels are set to,
+        // including off: it is the selection's outline, not a bevel.
+        let thickness = layout.bevel_thickness.max(2.0);
+        let light = layout.bevel_light_intensity.max(0.6);
+
+        let mut used = 0;
+        for &(x, y, w, h, radius, fill) in wanted.iter() {
+            if w < 1 || h < 1 {
+                continue;
+            }
+            // wlr_scene_rect colours are premultiplied.
+            let color = [accent[0] * fill, accent[1] * fill, accent[2] * fill, fill];
+            if used == self.selection.boxes.len() {
+                let rect = ffi::wlr_scene_rect_create(tree, w, h, color.as_ptr());
+                let bevel = ffi::wlr_scene_bevel_create(
+                    tree,
+                    w,
+                    h,
+                    radius,
+                    thickness,
+                    layout.bevel_color.as_ptr(),
+                );
+                if rect.is_null() || bevel.is_null() {
+                    if !rect.is_null() {
+                        ffi::wlr_scene_node_destroy(rect as *mut ffi::wlr_scene_node);
+                    }
+                    if !bevel.is_null() {
+                        ffi::wlr_scene_node_destroy(&mut (*bevel).node as *mut ffi::wlr_scene_node);
+                    }
+                    break;
+                }
+                self.selection.boxes.push((rect, bevel));
+            }
+            let (rect, bevel) = self.selection.boxes[used];
+            used += 1;
+
+            let rect_node = rect as *mut ffi::wlr_scene_node;
+            ffi::wlr_scene_node_set_enabled(rect_node, true);
+            ffi::river_scene_node_set_position_if_changed(rect_node, x, y);
+            ffi::river_scene_rect_set_size_if_changed(rect, w, h);
+            ffi::river_scene_rect_set_corner_radius(rect, radius);
+            ffi::wlr_scene_rect_set_color(rect, color.as_ptr());
+
+            let bevel_node = &mut (*bevel).node as *mut ffi::wlr_scene_node;
+            ffi::wlr_scene_node_set_enabled(bevel_node, true);
+            ffi::river_scene_node_set_position_if_changed(bevel_node, x, y);
+            ffi::wlr_scene_bevel_set_size(bevel, w, h);
+            ffi::wlr_scene_bevel_set_corner_radius(bevel, radius);
+            ffi::wlr_scene_bevel_set_thickness(bevel, thickness);
+            ffi::wlr_scene_bevel_set_light(
+                bevel,
+                light_x,
+                light_y,
+                light,
+                layout.bevel_shade_intensity,
+            );
+            ffi::wlr_scene_bevel_set_shoulder(bevel, layout.bevel_shoulder);
+            ffi::wlr_scene_bevel_set_color(bevel, layout.bevel_color.as_ptr());
+            // Focus 1 is the shader's glint-only branch: the accent on the
+            // rim and nothing else, the tint the designer marks a region
+            // and its nodes with.
+            ffi::wlr_scene_bevel_set_focus(
+                bevel,
+                1.0,
+                layout.bevel_focus_sharpness,
+                accent.as_ptr(),
+            );
+        }
+        for &(rect, bevel) in self.selection.boxes.iter().skip(used) {
+            ffi::wlr_scene_node_set_enabled(rect as *mut ffi::wlr_scene_node, false);
+            ffi::wlr_scene_node_set_enabled(&mut (*bevel).node as *mut ffi::wlr_scene_node, false);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn rect_is_the_same_whichever_way_the_drag_went() {
+        let down_right = Marquee { anchor: (10.0, 20.0), far: (110.0, 70.0) };
+        let up_left = Marquee { anchor: (110.0, 70.0), far: (10.0, 20.0) };
+        let mixed = Marquee { anchor: (110.0, 20.0), far: (10.0, 70.0) };
+        let want = (10.0, 20.0, 100.0, 50.0);
+        assert_eq!(down_right.rect(), want);
+        assert_eq!(up_left.rect(), want);
+        assert_eq!(mixed.rect(), want);
+    }
+
+    #[test]
+    fn a_band_that_clips_a_corner_selects_the_window() {
+        let win = (100.0, 100.0, 400.0, 300.0);
+        assert!(touches((50.0, 50.0, 60.0, 60.0), win));
+        // Entirely inside the window's bounds is unreachable from a
+        // background press, but it is still a touch.
+        assert!(touches((200.0, 200.0, 10.0, 10.0), win));
+        // And the window entirely inside the band.
+        assert!(touches((0.0, 0.0, 1000.0, 1000.0), win));
+    }
+
+    #[test]
+    fn a_band_in_the_gap_beside_a_window_selects_nothing() {
+        let win = (100.0, 100.0, 400.0, 300.0);
+        assert!(!touches((0.0, 100.0, 90.0, 300.0), win));
+        // Sharing an edge is not touching.
+        assert!(!touches((0.0, 100.0, 100.0, 300.0), win));
+        assert!(!touches((100.0, 400.0, 400.0, 50.0), win));
+    }
+
+    #[test]
+    fn a_straight_drag_through_a_window_selects_it() {
+        let win = (100.0, 100.0, 400.0, 300.0);
+        // Pressed above the window, dragged straight down through it.
+        assert!(touches((200.0, 50.0, 0.0, 200.0), win));
+        // The same line beside the window.
+        assert!(!touches((50.0, 50.0, 0.0, 200.0), win));
+    }
+
+    #[test]
+    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)));
+    }
+}
diff --git a/src/server/window.rs b/src/server/window.rs
index b2aeac30..f2301728 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -2227,6 +2227,7 @@ impl Window {
         (*window).node.deinit();
 
         (*(*window).server).wm.remove_from_history(window);
+        (*(*window).server).wm.selection_forget(window);
         // A seat cursor may still name this window as its adjust target.
         // The next hover evaluation would replace it, but a window allocated
         // at the same address in the meantime must not inherit the ring.
@@ -2238,6 +2239,7 @@ impl Window {
                 if (*seat).cursor.adjust_hover == window {
                     (*seat).cursor.adjust_hover = std::ptr::null_mut();
                 }
+                (*seat).group_move.retain(|&(w, _, _)| w != window);
                 curr = (*curr).next;
             }
         }
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index a42cc3cf..b2cf7530 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -311,6 +311,10 @@ pub struct WindowManager {
     pub object: *mut ffi::wl_resource,
     pub state: WindowManagerState,
     pub windows: SlotMap<*mut Window>,
+    /// The overview drag-selection: the selected windows, the rubber band
+    /// while one is being dragged out, and the nodes that draw both. See
+    /// [`crate::selection`].
+    pub selection: crate::selection::Selection,
     pub focus_history: Vec<*mut Window>,
     pub scheduled: WindowManagerScheduled,
     pub sent: WindowManagerSent,
@@ -745,6 +749,7 @@ impl WindowManager {
         self.object = std::ptr::null_mut();
         self.state = WindowManagerState::Idle;
         self.windows = SlotMap::new();
+        std::ptr::write(&mut self.selection, crate::selection::Selection::default());
         self.focus_history = Vec::new();
         self.scheduled = WindowManagerScheduled {
             dirty: false,
@@ -2414,6 +2419,11 @@ impl WindowManager {
             return;
         }
         self.mode = mode;
+        // The selection belongs to overview: it is made there, and the
+        // group move it exists for is an overview drag.
+        if mode != WindowManagerMode::Overview {
+            self.selection_clear();
+        }
         self.arm_border_fade();
         // The `adjust` status topic follows window_adjust_active().
         self.update_status();
@@ -3214,6 +3224,13 @@ impl WindowManager {
                         return true;
                     }
                 }
+                // 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 {
+                    if (*seat).group_move.iter().any(|&(w, _, _)| w == win_ptr) {
+                        return true;
+                    }
+                }
             }
             curr_seat = (*curr_seat).next;
         }
@@ -5264,6 +5281,29 @@ 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.
+            "selection" => {
+                let ids: Vec<String> = self
+                    .selection
+                    .windows
+                    .iter()
+                    .map(|&w| (*w).ref_key.index.to_string())
+                    .collect();
+                let band = match self.selection.marquee {
+                    Some(m) => {
+                        let (x, y, w, h) = m.rect();
+                        format!("{:.0},{:.0},{:.0}x{:.0}", x, y, w, h)
+                    }
+                    None => "-".to_string(),
+                };
+                format!(
+                    "selected={} band={}\n",
+                    if ids.is_empty() { "-".to_string() } else { ids.join(",") },
+                    band
+                )
+            }
             // The camera as it stands and where it is easing to — what a
             // shadow reads back to assert a swipe's peek and its return.
             "camera" => {