diff --git a/Cargo.toml b/Cargo.toml index 79fb793..8197e9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 0408e87..a7012b0 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/app.rs b/src/app.rs index 2cc42ef..89ed710 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, + pub(crate) pressed: Option, + /// 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, + pub(crate) cursor_device: Option, + pub(crate) labels: Option, pub(crate) surface: Option, pub(crate) chrome: Option, @@ -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); diff --git a/src/cli.rs b/src/cli.rs index 678b13e..ed22101 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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. diff --git a/src/overlay.rs b/src/overlay.rs index 86fbaf4..d6d086b 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -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 { + 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 for App { impl Dispatch for App { fn event( - _: &mut Self, + app: &mut Self, seat: &WlSeat, event: wl_seat::Event, _: &(), _: &Connection, qh: &QueueHandle, ) { - 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 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 for App { + fn event( + app: &mut Self, + _: &WlPointer, + event: wl_pointer::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 }), + _ => {} + } + } +} diff --git a/src/theme.rs b/src/theme.rs index 13f5484..08ae88a 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -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 { + 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 { + 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 { 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 {