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]
|
[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"] }
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 }),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user