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]
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"] }
memmap2 = "0.9"
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 |
| `↓` `↑` / `j` `k` | move a row |
| `Home` `End` | first / last |
| `Enter` | pick |
| `Enter` | pick the selection |
| `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
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 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,
};
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::{
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
};
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
use crate::capture::{Live, Tile};
use crate::overlay;
use crate::shm;
use crate::target::Target;
use crate::text;
@@ -80,6 +85,16 @@ pub struct App {
pub(crate) sel: usize,
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) surface: Option<WlSurface>,
pub(crate) chrome: Option<shm::Chrome>,
@@ -161,6 +176,10 @@ impl App {
scale,
sel: 0,
shift: false,
hover: None,
pressed: None,
cursor_shape: globals.bind(qh, 1..=2, ()).ok(),
cursor_device: None,
labels: None,
surface: None,
chrome: None,
@@ -323,4 +342,6 @@ delegate_noop!(App: ExtOutputImageCaptureSourceManagerV1);
delegate_noop!(App: ignore WlSurface);
// The chrome's own buffers: two slots alternating on keypresses, so their
// release timing does not matter.
delegate_noop!(App: WpCursorShapeManagerV1);
delegate_noop!(App: WpCursorShapeDeviceV1);
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
-h, --help this
keys: arrows, hjkl or Tab/Shift+Tab move; Home/End jump; Enter picks;
Escape or q cancels
keys: arrows, hjkl or Tab/Shift+Tab move; Home/End jump; Enter picks;
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
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::{
wl_keyboard::{self, WlKeyboard},
wl_pointer::{self, WlPointer},
wl_seat::{self, WlSeat},
wl_shm,
wl_surface::WlSurface,
};
use wayland_client::{Connection, Dispatch, QueueHandle, WEnum};
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},
};
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape;
use crate::app::{App, Ending};
use crate::shm;
use crate::theme::{Rect, fit_centred};
@@ -44,6 +48,18 @@ const KEY_RIGHT: u32 = 106;
const KEY_END: u32 = 107;
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 {
/// Map the overlay: a layer surface sized to hug the grid, plus the shm the
/// 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) {
match code {
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
@@ -235,20 +281,29 @@ impl Dispatch<ZwlrLayerSurfaceV1, ()> for App {
impl Dispatch<WlSeat, ()> for App {
fn event(
_: &mut Self,
app: &mut Self,
seat: &WlSeat,
event: wl_seat::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities {
let wl_seat::Event::Capabilities {
capabilities: WEnum::Value(caps),
} = event
&& caps.contains(wl_seat::Capability::Keyboard)
{
else {
return;
};
if caps.contains(wl_seat::Capability::Keyboard) {
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 cols: i32,
pub rows: i32,
/// How many tiles there are, which the last row may not fill.
n: i32,
pub width: i32,
pub height: i32,
elem_w: i32,
@@ -96,6 +98,7 @@ impl Layout {
Self {
cols,
rows,
n,
width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin,
height: rows * elem_h + (rows - 1) * t.gap + 2 * t.margin,
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.
pub fn label(&self, i: i32) -> Option<Rect> {
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]
fn fit_preserves_aspect_and_centres() {
let box_ = Rect {