Pick with the mouse

Click a tile to pick it, scroll to move the selection. Hovering does not
move the selection: the keyboard keeps that, and a click acts on whatever
is under the cursor instead. A click on the margin, on a gap, or on an
empty cell of a ragged last row does nothing.

Tiles are already subsurfaces, so a click on a thumbnail identifies its
tile by which surface the event arrived on — no hit-testing needed. Only
the chrome around them (padding, labels, gaps) needs Layout::hit, which
inverts the same maths elem() lays out with, and is tested against it.

Picking waits for press and release on the same tile, so sliding off a
tile before letting go is not a pick.

wp_cursor_shape_v1 sets the pointer shape, which spares us a cursor theme
and libwayland-cursor; without it the cursor keeps whatever shape the
window underneath gave it. It is optional — a compositor without it just
gets whatever shape was already there.
This commit is contained in:
Milad Alizadeh
2026-08-23 20:25:57 +01:00
parent 8d8a27c4c1
commit 25d90bdcec
6 changed files with 209 additions and 9 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ license = "MIT"
[dependencies] [dependencies]
wayland-client = "0.31" wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client", "staging"] } wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }
wayland-protocols-wlr = { version = "0.3", features = ["client"] } wayland-protocols-wlr = { version = "0.3", features = ["client"] }
memmap2 = "0.9" memmap2 = "0.9"
rustix = { version = "1", features = ["fs", "mm", "shm"] } rustix = { version = "1", features = ["fs", "mm", "shm"] }
+11 -2
View File
@@ -95,8 +95,16 @@ chooser_cmd=wl-pick --format portal
| `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile | | `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile |
| `↓` `↑` / `j` `k` | move a row | | `↓` `↑` / `j` `k` | move a row |
| `Home` `End` | first / last | | `Home` `End` | first / last |
| `Enter` | pick | | `Enter` | pick the selection |
| `Escape` / `q` | cancel | | `Escape` / `q` | cancel |
| click | pick that tile |
| scroll | next / previous tile |
Hovering deliberately does not move the selection — the keyboard keeps it, and a
click acts on whatever is under the cursor. Clicking the margin, a gap, or an
empty cell of a ragged last row does nothing. Tiles are subsurfaces, so a click
on a thumbnail identifies its tile by surface; only clicks on the chrome around
them need hit-testing.
Navigation reads raw evdev keycodes, so it is layout-independent — but it also Navigation reads raw evdev keycodes, so it is layout-independent — but it also
means virtual-keyboard clients such as `wtype` (which invent their own keymap) means virtual-keyboard clients such as `wtype` (which invent their own keymap)
@@ -181,5 +189,6 @@ shm.rs memfd allocation and the ARGB painter
``` ```
cargo build --release cargo build --release
cargo test # grid geometry, ellipsising, output formats, glyph output cargo test # grid geometry and hit-testing, ellipsising, output
# formats, glyph output
``` ```
+21
View File
@@ -33,12 +33,17 @@ use wayland_protocols::ext::image_capture_source::v1::client::{
ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1, ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
}; };
use wayland_protocols::ext::image_copy_capture::v1::client::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1; use wayland_protocols::ext::image_copy_capture::v1::client::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1;
use wayland_protocols::wp::cursor_shape::v1::client::{
wp_cursor_shape_device_v1::WpCursorShapeDeviceV1,
wp_cursor_shape_manager_v1::WpCursorShapeManagerV1,
};
use wayland_protocols::wp::viewporter::client::{ use wayland_protocols::wp::viewporter::client::{
wp_viewport::WpViewport, wp_viewporter::WpViewporter, wp_viewport::WpViewport, wp_viewporter::WpViewporter,
}; };
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
use crate::capture::{Live, Tile}; use crate::capture::{Live, Tile};
use crate::overlay;
use crate::shm; use crate::shm;
use crate::target::Target; use crate::target::Target;
use crate::text; use crate::text;
@@ -80,6 +85,16 @@ pub struct App {
pub(crate) sel: usize, pub(crate) sel: usize,
pub(crate) shift: bool, pub(crate) shift: bool,
/// Where the pointer is, and which tile it pressed. Hovering deliberately
/// does not move the keyboard selection; a click acts on what is under the
/// cursor instead.
pub(crate) hover: Option<overlay::Hover>,
pub(crate) pressed: Option<usize>,
/// Set the cursor shape without shipping a cursor theme. Optional: without
/// it the pointer keeps whatever shape it had over the window below.
pub(crate) cursor_shape: Option<WpCursorShapeManagerV1>,
pub(crate) cursor_device: Option<WpCursorShapeDeviceV1>,
pub(crate) labels: Option<text::Labels>, pub(crate) labels: Option<text::Labels>,
pub(crate) surface: Option<WlSurface>, pub(crate) surface: Option<WlSurface>,
pub(crate) chrome: Option<shm::Chrome>, pub(crate) chrome: Option<shm::Chrome>,
@@ -161,6 +176,10 @@ impl App {
scale, scale,
sel: 0, sel: 0,
shift: false, shift: false,
hover: None,
pressed: None,
cursor_shape: globals.bind(qh, 1..=2, ()).ok(),
cursor_device: None,
labels: None, labels: None,
surface: None, surface: None,
chrome: None, chrome: None,
@@ -323,4 +342,6 @@ delegate_noop!(App: ExtOutputImageCaptureSourceManagerV1);
delegate_noop!(App: ignore WlSurface); delegate_noop!(App: ignore WlSurface);
// The chrome's own buffers: two slots alternating on keypresses, so their // The chrome's own buffers: two slots alternating on keypresses, so their
// release timing does not matter. // release timing does not matter.
delegate_noop!(App: WpCursorShapeManagerV1);
delegate_noop!(App: WpCursorShapeDeviceV1);
delegate_noop!(App: ignore WlBuffer); delegate_noop!(App: ignore WlBuffer);
+4 -2
View File
@@ -25,8 +25,10 @@ usage: wl-pick [options]
-v, --verbose phase timings, tile list and capture stats -v, --verbose phase timings, tile list and capture stats
-h, --help this -h, --help this
keys: arrows, hjkl or Tab/Shift+Tab move; Home/End jump; Enter picks; keys: arrows, hjkl or Tab/Shift+Tab move; Home/End jump; Enter picks;
Escape or q cancels Escape or q cancels
mouse: click a tile to pick it, scroll to move. Hovering does not move the
selection, and a click outside a tile does nothing.
The pick goes to stdout and nothing does if you cancel, so exit status is The pick goes to stdout and nothing does if you cancel, so exit status is
0 for a pick and 1 for a cancel. Acting on it is the caller's job. 0 for a pick and 1 for a cancel. Acting on it is the caller's job.
+113 -4
View File
@@ -10,8 +10,10 @@ use std::os::fd::AsFd;
use wayland_client::protocol::{ use wayland_client::protocol::{
wl_keyboard::{self, WlKeyboard}, wl_keyboard::{self, WlKeyboard},
wl_pointer::{self, WlPointer},
wl_seat::{self, WlSeat}, wl_seat::{self, WlSeat},
wl_shm, wl_shm,
wl_surface::WlSurface,
}; };
use wayland_client::{Connection, Dispatch, QueueHandle, WEnum}; use wayland_client::{Connection, Dispatch, QueueHandle, WEnum};
use wayland_protocols_wlr::layer_shell::v1::client::{ use wayland_protocols_wlr::layer_shell::v1::client::{
@@ -19,6 +21,8 @@ use wayland_protocols_wlr::layer_shell::v1::client::{
zwlr_layer_surface_v1::{self, KeyboardInteractivity, ZwlrLayerSurfaceV1}, zwlr_layer_surface_v1::{self, KeyboardInteractivity, ZwlrLayerSurfaceV1},
}; };
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape;
use crate::app::{App, Ending}; use crate::app::{App, Ending};
use crate::shm; use crate::shm;
use crate::theme::{Rect, fit_centred}; use crate::theme::{Rect, fit_centred};
@@ -44,6 +48,18 @@ const KEY_RIGHT: u32 = 106;
const KEY_END: u32 = 107; const KEY_END: u32 = 107;
const KEY_DOWN: u32 = 108; const KEY_DOWN: u32 = 108;
/// evdev button code, as wl_pointer reports it.
const BTN_LEFT: u32 = 0x110;
/// Where the pointer is: the surface it entered, and the position within it.
/// Tiles are subsurfaces, so the surface alone usually names a tile; the
/// position is only needed over the chrome around them.
pub struct Hover {
pub surface: WlSurface,
pub x: f64,
pub y: f64,
}
impl App { impl App {
/// Map the overlay: a layer surface sized to hug the grid, plus the shm the /// Map the overlay: a layer surface sized to hug the grid, plus the shm the
/// chrome is painted into. /// chrome is painted into.
@@ -185,6 +201,36 @@ impl App {
} }
} }
/// The tile under the pointer, if it is over one. A tile's own subsurface
/// answers directly; over the parent surface — padding, labels, gaps — the
/// layout is asked instead.
fn tile_at_pointer(&self) -> Option<usize> {
let hover = self.hover.as_ref()?;
let on_tile = self
.tiles
.iter()
.position(|t| t.surface.as_ref() == Some(&hover.surface));
on_tile.or_else(|| {
(Some(&hover.surface) == self.surface.as_ref())
.then(|| self.layout.hit(hover.x as i32, hover.y as i32))
.flatten()
})
}
/// Press and release on the same tile picks it. Anywhere else — the margin,
/// a gap, an empty cell of the last row — does nothing at all.
fn click(&mut self, pressed: bool) {
if pressed {
self.pressed = self.tile_at_pointer();
return;
}
let released = self.tile_at_pointer();
if let Some(i) = self.pressed.take().filter(|i| Some(*i) == released) {
self.picked = self.tiles.get(i).map(|t| t.target.clone());
self.ending = Ending::Picked;
}
}
fn key(&mut self, code: u32) { fn key(&mut self, code: u32) {
match code { match code {
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true, KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
@@ -235,20 +281,29 @@ impl Dispatch<ZwlrLayerSurfaceV1, ()> for App {
impl Dispatch<WlSeat, ()> for App { impl Dispatch<WlSeat, ()> for App {
fn event( fn event(
_: &mut Self, app: &mut Self,
seat: &WlSeat, seat: &WlSeat,
event: wl_seat::Event, event: wl_seat::Event,
_: &(), _: &(),
_: &Connection, _: &Connection,
qh: &QueueHandle<Self>, qh: &QueueHandle<Self>,
) { ) {
if let wl_seat::Event::Capabilities { let wl_seat::Event::Capabilities {
capabilities: WEnum::Value(caps), capabilities: WEnum::Value(caps),
} = event } = event
&& caps.contains(wl_seat::Capability::Keyboard) else {
{ return;
};
if caps.contains(wl_seat::Capability::Keyboard) {
seat.get_keyboard(qh, ()); seat.get_keyboard(qh, ());
} }
if caps.contains(wl_seat::Capability::Pointer) {
let pointer = seat.get_pointer(qh, ());
app.cursor_device = app
.cursor_shape
.as_ref()
.map(|mgr| mgr.get_pointer(&pointer, qh, ()));
}
} }
} }
@@ -274,3 +329,57 @@ impl Dispatch<WlKeyboard, ()> for App {
} }
} }
} }
/// Hovering does not move the selection — that belongs to the keyboard — so the
/// pointer only tracks where it is and what it clicked. Scrolling is a
/// deliberate gesture, so that does move the selection.
impl Dispatch<WlPointer, ()> for App {
fn event(
app: &mut Self,
_: &WlPointer,
event: wl_pointer::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
wl_pointer::Event::Enter {
serial,
surface,
surface_x,
surface_y,
} => {
// A client owns the cursor over its own surfaces; without this
// the pointer keeps whatever shape the window below gave it.
if let Some(device) = &app.cursor_device {
device.set_shape(serial, Shape::Default);
}
app.hover = Some(Hover {
surface,
x: surface_x,
y: surface_y,
});
}
wl_pointer::Event::Motion {
surface_x,
surface_y,
..
} => {
if let Some(hover) = app.hover.as_mut() {
(hover.x, hover.y) = (surface_x, surface_y);
}
}
wl_pointer::Event::Leave { .. } => {
app.hover = None;
app.pressed = None;
}
wl_pointer::Event::Button {
button: BTN_LEFT,
state: WEnum::Value(state),
..
} => app.click(state == wl_pointer::ButtonState::Pressed),
wl_pointer::Event::Axis { value, .. } => app.move_sel(if value > 0.0 { 1 } else { -1 }),
_ => {}
}
}
}
+59
View File
@@ -67,6 +67,8 @@ impl Default for Theme {
pub struct Layout { pub struct Layout {
pub cols: i32, pub cols: i32,
pub rows: i32, pub rows: i32,
/// How many tiles there are, which the last row may not fill.
n: i32,
pub width: i32, pub width: i32,
pub height: i32, pub height: i32,
elem_w: i32, elem_w: i32,
@@ -96,6 +98,7 @@ impl Layout {
Self { Self {
cols, cols,
rows, rows,
n,
width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin, width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin,
height: rows * elem_h + (rows - 1) * t.gap + 2 * t.margin, height: rows * elem_h + (rows - 1) * t.gap + 2 * t.margin,
elem_w, elem_w,
@@ -132,6 +135,28 @@ impl Layout {
} }
} }
/// The tile at a point in surface-local coordinates, if any. Points in the
/// gaps between elements and in the window margin belong to nothing, and so
/// do the empty cells of a ragged last row.
pub fn hit(&self, x: i32, y: i32) -> Option<usize> {
let col = self.axis(x, self.margin, self.elem_w, self.cols)?;
let row = self.axis(y, self.margin, self.elem_h, self.rows)?;
let i = row * self.cols + col;
(i < self.n).then_some(i as usize)
}
/// Which cell along one axis a coordinate falls in, or None if it landed in
/// the margin or a gap.
fn axis(&self, v: i32, margin: i32, elem: i32, count: i32) -> Option<i32> {
let pitch = elem + self.gap;
let offset = v - margin;
if offset < 0 {
return None;
}
let cell = offset / pitch;
(cell < count && offset % pitch < elem).then_some(cell)
}
/// The single line of text under the thumbnail, if labels are drawn. /// The single line of text under the thumbnail, if labels are drawn.
pub fn label(&self, i: i32) -> Option<Rect> { pub fn label(&self, i: i32) -> Option<Rect> {
if !self.labels { if !self.labels {
@@ -253,6 +278,40 @@ mod tests {
} }
} }
#[test]
fn hit_testing_is_the_inverse_of_the_layout() {
let t = Theme::default();
// 7 tiles over 3 columns: the last row holds one, so two cells are empty.
let l = Layout::new(&t, 7);
for i in 0..7 {
let e = l.elem(i);
for (x, y, what) in [
(e.x, e.y, "top left"),
(e.x + e.w / 2, e.y + e.h / 2, "centre"),
(e.x + e.w - 1, e.y + e.h - 1, "bottom right"),
] {
assert_eq!(l.hit(x, y), Some(i as usize), "{what} of element {i}");
}
}
// The window margin, the gap between elements, and the empty cells of
// the last row all belong to no tile.
assert_eq!(l.hit(0, 0), None, "margin");
let first = l.elem(0);
assert_eq!(
l.hit(first.x + first.w + 1, first.y),
None,
"gap between columns"
);
assert_eq!(
l.hit(first.x, first.y + first.h + 1),
None,
"gap between rows"
);
let empty = l.elem(8); // row 2, column 2: past the seventh tile
assert_eq!(l.hit(empty.x + 4, empty.y + 4), None, "empty cell");
assert_eq!(l.hit(-5, -5), None, "outside");
}
#[test] #[test]
fn fit_preserves_aspect_and_centres() { fn fit_preserves_aspect_and_centres() {
let box_ = Rect { let box_ = Rect {