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:
+1
-1
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -27,6 +27,8 @@ usage: wl-pick [options]
|
||||
|
||||
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
@@ -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 }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user