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