srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-04-20 00:48:00 +0200
committersrdusr <[email protected]>2026-04-20 00:48:00 +0200
commit436d42da6ef61a5ea20d5102c4baed7bf0993606 (patch)
tree5b013ae1877d81bfb67e422fdf56896d761076ed
parent1f708f8aa09bf8bbce82314a76b7f34def90b798 (diff)
downloadsrdwm-436d42da6ef61a5ea20d5102c4baed7bf0993606.tar.gz
srdwm-436d42da6ef61a5ea20d5102c4baed7bf0993606.zip
Confirm Nemo's popup works; fix the two bugs that hid it, and shadow bleed across a monitor seam
Nemo's right-click context menu was the last open punch-list item, parked twice as untestable. It works: verified end to end in a throwaway nested compositor, menu and submenu both, at the correct position and stacking. The popup path itself needed no fix, so the POPUP-GEOM-DIAG/POPUP-GRAB-DIAG diagnostics are removed. Two real bugs turned up in the way of testing it. zwlr_virtual_pointer was a silent no-op on the winit backend. Every Motion/MotionAbsolute handler read UdevState::bounds() behind an early return when state.udev was None, and that field is Some only for the DRM backend. The protocol advertised its global, accepted create_virtual_pointer and accepted every request, then discarded all motion with no error and no log. That is the backend a nested instance runs on, so the only safe way to drive a throwaway compositor - a Wayland client of that compositor, which cannot reach any other session, unlike ydotool's /dev/uinput writes - did not work at all. Bounds now come from WindowManager::monitors() when udev is absent; both backends fill that list from Platform::monitors(). The winit backend's screencopy pass rendered no popups and no shadows. It re-renders the scene offscreen, and that second scene was missing tiers, so grim on a nested instance reported the opposite of the truth: a menu drawing perfectly on screen photographed as absent. The DRM backend never had this, since it serves screencopy from the on-screen frame it just drew. Border strips are still missing from that pass, called out in the code rather than left silent. Also fixed, from the "windows show a bit in the other monitor" report: shadow_rect expanded by SHADOW_SIZE on every side with no monitor-boundary awareness, so a window flush against a seam put its 24px shadow strip on the neighbouring screen. shadow_rect_clipped clips to the bounding box of the monitors the window's geometry actually touches - not just its assigned one, since a window straddling a seam really does occupy both and clipping there would cut its shadow off mid-body. The bitmap's own extent stays unclipped, because the src rectangle indexes into it; only the fragment list is clipped. Six tests on the incident's own numbers. Not confirmed on screen: the nested backend cannot produce a second monitor. New tool: tools/virtual-pointer-click, a scriptable virtual-pointer driver that acknowledges each command after its round-trip, so a test script can put a screenshot between a move and the click that follows it. 489 tests pass, clippy clean.
-rw-r--r--crates/wayland/src/decoration.rs2
-rw-r--r--crates/wayland/src/decoration/shadow.rs114
-rw-r--r--crates/wayland/src/protocols/xdg_shell.rs28
-rw-r--r--crates/wayland/src/udev/mod.rs2
-rw-r--r--crates/wayland/src/udev/render.rs15
-rw-r--r--crates/wayland/src/virtual_pointer.rs42
-rw-r--r--crates/wayland/src/winit/capture.rs52
-rw-r--r--crates/wayland/src/winit/render.rs9
-rw-r--r--docs/TODO.md96
-rw-r--r--tools/virtual-pointer-click/Cargo.lock216
-rw-r--r--tools/virtual-pointer-click/Cargo.toml18
-rw-r--r--tools/virtual-pointer-click/src/main.rs161
12 files changed, 714 insertions, 41 deletions
diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs
index 49cf416..9ff43b6 100644
--- a/crates/wayland/src/decoration.rs
+++ b/crates/wayland/src/decoration.rs
@@ -43,7 +43,7 @@ pub(crate) use buttons::HOVER_GLYPH_DURATION;
pub(crate) use color::{mix_rgb, rgb_to_bgra};
pub(crate) use corners::{round_bottom_corners, round_top_corners};
pub(crate) use font::{blit_glyph, find_system_font, measure_text_width, FONT_PIXELS, TEXT_LEFT_PADDING};
-pub use shadow::{shadow_bitmap, shadow_rect};
+pub use shadow::{shadow_bitmap, shadow_rect, shadow_rect_clipped};
pub(crate) use shadow::{SHADOW_MAX_ALPHA, SHADOW_SIZE};
pub use titlebar::render_titlebar;
diff --git a/crates/wayland/src/decoration/shadow.rs b/crates/wayland/src/decoration/shadow.rs
index df0a0dd..7c89ae0 100644
--- a/crates/wayland/src/decoration/shadow.rs
+++ b/crates/wayland/src/decoration/shadow.rs
@@ -30,6 +30,61 @@ pub fn shadow_rect(geometry: srdwm_core::Rect) -> srdwm_core::Rect {
srdwm_core::Rect::new(geometry.x - s, geometry.y - s, geometry.width + SHADOW_SIZE * 2, geometry.height + SHADOW_SIZE * 2)
}
+/// [`shadow_rect`], clipped so a shadow can never land on a monitor the
+/// window itself does not occupy.
+///
+/// Reported live as "windows show a bit in the other monitor" with a real
+/// second monitor connected: `srd clients` showed several windows sitting
+/// at exactly `x: 1920`, the seam between two 1920-wide outputs, and a
+/// window flush against that seam has nowhere to put its 24px shadow strip
+/// except the neighbouring screen. The earlier shadow work only ever
+/// considered a window's neighbouring *tile*; [`shadow_rect`] expands by
+/// [`SHADOW_SIZE`] on every side with no monitor-boundary awareness at
+/// all, so this survived it.
+///
+/// `bounds` is every monitor's `full_geometry`. The clip box is the
+/// bounding box of the monitors the window's own geometry actually
+/// touches, not just of the one it is assigned to: a window straddling a
+/// seam genuinely occupies both screens, and clipping such a window's
+/// shadow at the seam would cut it off in the middle of its own visible
+/// body. A window touching no monitor at all (off-screen, or no monitors
+/// yet) is returned unclipped - there is nothing to clip against, and
+/// silently collapsing it to an empty rect would drop the shadow instead.
+pub fn shadow_rect_clipped(geometry: srdwm_core::Rect, bounds: &[srdwm_core::Rect]) -> srdwm_core::Rect {
+ let rect = shadow_rect(geometry);
+ let mut clip: Option<srdwm_core::Rect> = None;
+ for m in bounds.iter().filter(|m| overlaps(**m, geometry)) {
+ clip = Some(match clip {
+ None => *m,
+ Some(c) => union(c, *m),
+ });
+ }
+ match clip {
+ Some(c) => intersect(rect, c),
+ None => rect,
+ }
+}
+
+fn overlaps(a: srdwm_core::Rect, b: srdwm_core::Rect) -> bool {
+ a.x < b.x + b.width as i32 && b.x < a.x + a.width as i32 && a.y < b.y + b.height as i32 && b.y < a.y + a.height as i32
+}
+
+fn union(a: srdwm_core::Rect, b: srdwm_core::Rect) -> srdwm_core::Rect {
+ let x = a.x.min(b.x);
+ let y = a.y.min(b.y);
+ let right = (a.x + a.width as i32).max(b.x + b.width as i32);
+ let bottom = (a.y + a.height as i32).max(b.y + b.height as i32);
+ srdwm_core::Rect::new(x, y, (right - x).max(0) as u32, (bottom - y).max(0) as u32)
+}
+
+fn intersect(a: srdwm_core::Rect, b: srdwm_core::Rect) -> srdwm_core::Rect {
+ let x = a.x.max(b.x);
+ let y = a.y.max(b.y);
+ let right = (a.x + a.width as i32).min(b.x + b.width as i32);
+ let bottom = (a.y + a.height as i32).min(b.y + b.height as i32);
+ srdwm_core::Rect::new(x, y, (right - x).max(0) as u32, (bottom - y).max(0) as u32)
+}
+
/// Renders a window's drop shadow as a BGRA8 bitmap: black at an alpha that
/// falls off linearly from [`SHADOW_MAX_ALPHA`] right at the window's own
/// edge to fully transparent [`SHADOW_SIZE`] pixels out. `win_width`/
@@ -184,3 +239,62 @@ fn edge_distance(pos: u32, margin: u32, extent: u32) -> u32 {
0
}
}
+
+#[cfg(test)]
+mod clip_tests {
+ use super::shadow_rect_clipped;
+ use srdwm_core::Rect;
+
+ /// Two 1920x1080 outputs side by side, the exact arrangement the
+ /// "windows show a bit in the other monitor" report was taken on.
+ fn two_monitors() -> Vec<Rect> {
+ vec![Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 1920, 1080)]
+ }
+
+ #[test]
+ fn a_window_flush_against_the_seam_does_not_shadow_the_next_monitor() {
+ // Right edge exactly on the seam at x=1920.
+ let w = Rect::new(1120, 100, 800, 600);
+ let r = shadow_rect_clipped(w, &two_monitors());
+ assert_eq!(r.x + r.width as i32, 1920, "shadow crossed the seam");
+ assert_eq!(r.x, 1120 - 24, "the left side should still get its full shadow");
+ }
+
+ #[test]
+ fn a_window_at_the_left_edge_of_the_second_monitor_does_not_shadow_the_first() {
+ let w = Rect::new(1920, 100, 800, 600);
+ let r = shadow_rect_clipped(w, &two_monitors());
+ assert_eq!(r.x, 1920, "shadow crossed the seam");
+ }
+
+ #[test]
+ fn a_window_in_the_middle_of_a_monitor_is_unclipped() {
+ let w = Rect::new(500, 300, 400, 300);
+ let r = shadow_rect_clipped(w, &two_monitors());
+ assert_eq!((r.x, r.y, r.width, r.height), (500 - 24, 300 - 24, 400 + 48, 300 + 48));
+ }
+
+ #[test]
+ fn a_window_straddling_the_seam_keeps_its_shadow_on_both_monitors() {
+ let w = Rect::new(1720, 100, 400, 600);
+ let r = shadow_rect_clipped(w, &two_monitors());
+ assert_eq!(r.x, 1720 - 24);
+ assert_eq!(r.x + r.width as i32, 2120 + 24);
+ }
+
+ #[test]
+ fn the_outer_edges_of_the_whole_desktop_still_clip() {
+ // Nothing to bleed onto past x=0, but the clip must not invent
+ // space that no monitor covers either.
+ let w = Rect::new(0, 0, 400, 300);
+ let r = shadow_rect_clipped(w, &two_monitors());
+ assert_eq!((r.x, r.y), (0, 0));
+ }
+
+ #[test]
+ fn no_monitors_leaves_the_rect_unclipped() {
+ let w = Rect::new(10, 10, 100, 100);
+ let r = shadow_rect_clipped(w, &[]);
+ assert_eq!((r.x, r.y, r.width, r.height), (10 - 24, 10 - 24, 100 + 48, 100 + 48));
+ }
+}
diff --git a/crates/wayland/src/protocols/xdg_shell.rs b/crates/wayland/src/protocols/xdg_shell.rs
index 4fe0f6f..0236736 100644
--- a/crates/wayland/src/protocols/xdg_shell.rs
+++ b/crates/wayland/src/protocols/xdg_shell.rs
@@ -8,7 +8,6 @@ use smithay::input::pointer::Focus;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
use smithay::reexports::wayland_server::protocol::wl_seat;
-use smithay::reexports::wayland_server::Resource;
use smithay::utils::Serial;
use smithay::wayland::shell::xdg::{PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState};
@@ -185,22 +184,11 @@ impl XdgShellHandler for CompState {
/// real follow-up, not this fix); an occasional popup placed near a
/// screen edge may render partly off it, which is cosmetic, not a hang.
fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) {
- // Temporary: live report is that Nemo's right-click context menu
- // never appears at all (not mispositioned - entirely invisible),
- // while the exact same xdg_popup mechanism works for Firefox. Logs
- // the unconstrained geometry this popup gets so a live repro tells
- // us whether it's landing off-screen/degenerate (the known gap this
- // function's own doc comment already flags) or something else
- // entirely. Remove once resolved.
- let geom = positioner.get_geometry();
- let parent = surface.get_parent_surface();
- log::warn!("POPUP-GEOM-DIAG geometry={geom:?} parent={:?}", parent.as_ref().map(|s| s.id()));
surface.with_pending_state(|state| {
- state.geometry = geom;
+ state.geometry = positioner.get_geometry();
state.positioner = positioner;
});
if surface.send_configure().is_err() {
- log::warn!("POPUP-GEOM-DIAG send_configure failed");
return;
}
let _ = self.popups.track_popup(smithay::desktop::PopupKind::Xdg(surface));
@@ -223,19 +211,9 @@ impl XdgShellHandler for CompState {
/// `resize_request` already ignore the same parameter.
fn grab(&mut self, surface: PopupSurface, _seat: wl_seat::WlSeat, serial: Serial) {
let popup = PopupKind::Xdg(surface);
- let Ok(root) = find_popup_root_surface(&popup) else {
- log::warn!("POPUP-GRAB-DIAG find_popup_root_surface failed");
- return;
- };
+ let Ok(root) = find_popup_root_surface(&popup) else { return };
let seat = self.seat.clone();
- let grab = match self.popups.grab_popup(root, popup, &seat, serial) {
- Ok(g) => g,
- Err(e) => {
- log::warn!("POPUP-GRAB-DIAG grab_popup failed: {e:?}");
- return;
- }
- };
- log::warn!("POPUP-GRAB-DIAG grab established, has_pointer={} has_keyboard={}", seat.get_pointer().is_some(), seat.get_keyboard().is_some());
+ let Ok(grab) = self.popups.grab_popup(root, popup, &seat, serial) else { return };
if let Some(keyboard) = seat.get_keyboard() {
keyboard.set_grab(self, PopupKeyboardGrab::new(&grab), serial);
}
diff --git a/crates/wayland/src/udev/mod.rs b/crates/wayland/src/udev/mod.rs
index 22df0e1..cae7177 100644
--- a/crates/wayland/src/udev/mod.rs
+++ b/crates/wayland/src/udev/mod.rs
@@ -338,7 +338,7 @@ impl UdevState {
/// `(x, y, width, height)` tuples rather than real `UdevHead`s - pulled
/// out so it's testable without a real DRM/`Card` handle, which every
/// `UdevHead` in this module otherwise needs to even construct.
-fn bounds_of(heads: impl Iterator<Item = (i32, i32, i32, i32)>) -> (f64, f64, f64, f64) {
+pub(crate) fn bounds_of(heads: impl Iterator<Item = (i32, i32, i32, i32)>) -> (f64, f64, f64, f64) {
let mut min_x = 0;
let mut min_y = 0;
let mut max_x = 0;
diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs
index b435276..ed62290 100644
--- a/crates/wayland/src/udev/render.rs
+++ b/crates/wayland/src/udev/render.rs
@@ -40,6 +40,11 @@ impl CompState {
// below just re-offsets these same positions by its own `origin`.
self.ensure_desktop_icons();
let desktop_icon_render_list = self.desktop_icon_render_list();
+ // Same "gather immutable state before `self.udev` is borrowed
+ // mutably" reason as everything else in this block - used by the
+ // per-window shadow push below to keep a shadow off any monitor its
+ // own window does not occupy (`decoration::shadow_rect_clipped`).
+ let monitor_bounds: Vec<srdwm_core::Rect> = self.wm.borrow().monitors().iter().map(|m| m.full_geometry).collect();
// Captured-and-blurred backgrounds collected during the per-head
// loop below, applied via `self.capture_output` only after it
// ends - `self.udev`'s mutable borrow is held for the whole loop
@@ -876,11 +881,17 @@ impl CompState {
// corners nearly meet, which this compositor's default
// cascade placement does constantly.
if let Some(shadow) = self.shadow_buffers.get(&id) {
- let rect = decoration::shadow_rect(frame);
+ // `full` is the bitmap's own extent and stays
+ // unclipped, because `src` below indexes into that
+ // bitmap; `rect` is the same box clipped to the
+ // monitors this window actually occupies, and only
+ // decides which fragments get drawn.
+ let full = decoration::shadow_rect(frame);
+ let rect = decoration::shadow_rect_clipped(frame, &monitor_bounds);
for fragment in crate::elements::visible_border_fragments(rect, &occluders) {
let pos = ((fragment.x - origin.x) as f64, (fragment.y - origin.y) as f64);
let src = Rectangle::new(
- Point::from(((fragment.x - rect.x) as f64, (fragment.y - rect.y) as f64)),
+ Point::from(((fragment.x - full.x) as f64, (fragment.y - full.y) as f64)),
Size::from((fragment.width as f64, fragment.height as f64)),
);
match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, shadow, None, Some(src), None, Kind::Unspecified) {
diff --git a/crates/wayland/src/virtual_pointer.rs b/crates/wayland/src/virtual_pointer.rs
index f30d7fb..4808ed5 100644
--- a/crates/wayland/src/virtual_pointer.rs
+++ b/crates/wayland/src/virtual_pointer.rs
@@ -225,8 +225,7 @@ impl Dispatch<ZwlrVirtualPointerV1, VirtualPointerData> for CompState {
pinned_move_to(state, data, &surface, target, time);
return;
}
- let Some(udev) = state.udev.as_ref() else { return };
- let (min_x, min_y, max_x, max_y) = udev.bounds();
+ let (min_x, min_y, max_x, max_y) = pointer_bounds(state);
let pos = last_pointer_pos(state);
let target = Point::<f64, Logical>::from((
(pos.x + dx.to_f64()).clamp(min_x, (max_x - 1.0).max(min_x)),
@@ -249,9 +248,6 @@ impl Dispatch<ZwlrVirtualPointerV1, VirtualPointerData> for CompState {
pinned_move_to(state, data, &surface, target, time);
return;
}
- if state.udev.is_none() {
- return;
- }
let (nx, ny) = (x as f64 / x_extent as f64, y as f64 / y_extent as f64);
// Mapped onto the requested output's own full geometry if
// `create_virtual_pointer_with_output` named one, otherwise
@@ -264,12 +260,12 @@ impl Dispatch<ZwlrVirtualPointerV1, VirtualPointerData> for CompState {
match state.wm.borrow().monitors().iter().find(|m| m.name == name) {
Some(m) => (m.full_geometry.x as f64, m.full_geometry.y as f64, m.full_geometry.width as f64, m.full_geometry.height as f64),
None => {
- let (min_x, min_y, max_x, max_y) = state.udev.as_ref().unwrap().bounds();
+ let (min_x, min_y, max_x, max_y) = pointer_bounds(state);
(min_x, min_y, max_x - min_x, max_y - min_y)
}
}
} else {
- let (min_x, min_y, max_x, max_y) = state.udev.as_ref().unwrap().bounds();
+ let (min_x, min_y, max_x, max_y) = pointer_bounds(state);
(min_x, min_y, max_x - min_x, max_y - min_y)
};
let target = Point::<f64, Logical>::from(((min_x + nx * w).clamp(min_x, min_x + w - 1.0), (min_y + ny * h).clamp(min_y, min_y + h - 1.0)));
@@ -384,6 +380,38 @@ impl CompState {
}
}
+/// The whole addressable pointer span, in logical coordinates - the union
+/// of every head on the DRM backend, and the union of every
+/// `WindowManager` monitor otherwise.
+///
+/// Every `Motion`/`MotionAbsolute` bounds lookup here used to read
+/// `UdevState::bounds()` directly, behind an early `return` when
+/// `state.udev` was `None`. That field is `Some` only for the DRM backend
+/// (see `state/mod.rs`), so on the nested winit backend this protocol
+/// advertised its global, accepted `create_virtual_pointer`, accepted
+/// every request, and then silently discarded all motion: no error, no
+/// log, nothing on screen. That is the exact backend a nested test
+/// instance runs on, so the one safe way to drive synthetic input at a
+/// throwaway compositor - a Wayland client of that compositor, which
+/// cannot reach any other session by construction, unlike a uinput-level
+/// tool such as `ydotool` - did not work at all. Found while trying to
+/// verify Nemo's right-click popup without clicking blind at the user's
+/// real desktop.
+///
+/// `WindowManager::monitors()` is filled from `Platform::monitors()` at
+/// startup and on every hotplug poll (`crates/srdwm/src/main.rs`), by both
+/// backends, so it is the backend-agnostic source. The DRM branch stays
+/// first and unchanged: `heads` is what that backend actually clamps its
+/// own `pointer_pos` against, and the two lists can legitimately disagree
+/// mid-hotplug.
+fn pointer_bounds(state: &CompState) -> (f64, f64, f64, f64) {
+ if let Some(udev) = state.udev.as_ref() {
+ return udev.bounds();
+ }
+ let wm = state.wm.borrow();
+ crate::udev::bounds_of(wm.monitors().iter().map(|m| (m.full_geometry.x, m.full_geometry.y, m.full_geometry.width as i32, m.full_geometry.height as i32)))
+}
+
/// This pinned stream's target window's own current content size,
/// physical pixels - `core::Window::geometry` is already physical, the
/// same convention `MotionEvent.location` and everything else in this
diff --git a/crates/wayland/src/winit/capture.rs b/crates/wayland/src/winit/capture.rs
index 3b1d38e..d21ed72 100644
--- a/crates/wayland/src/winit/capture.rs
+++ b/crates/wayland/src/winit/capture.rs
@@ -28,9 +28,34 @@ impl WaylandPlatform {
// any layer-shell surface at all.
let hide_top_layers = self.wm.borrow().visible_windows_front_to_back().any(|w| w.fullscreen);
let mut custom_elements: Vec<crate::elements::OverlayElement<GlesRenderer>> = Vec::new();
+ // Popups first, so they land above everything else - exactly the
+ // order both on-screen render loops already use.
+ //
+ // This pass had no popup step at all, so no tooltip, dropdown or
+ // right-click menu could ever appear in a screenshot taken on this
+ // backend, no matter how correctly it was drawn on screen. That is
+ // a capture-only gap, not a rendering one: the DRM backend serves
+ // screencopy out of its own on-screen frame (`udev/render.rs`
+ // drains `screencopy_pending` and hands `service_pending` the same
+ // framebuffer it just drew), so it never had the gap; this backend
+ // renders the scene a second time into an offscreen buffer, and
+ // that second scene was missing a tier.
+ //
+ // It cost real time to find. The whole point of the nested backend
+ // is validating behaviour with `grim`, and this made `grim` state
+ // the opposite of the truth about every popup: a menu that drew
+ // perfectly on screen photographed as absent, which reads exactly
+ // like the client never opened one.
+ let popup_targets = crate::elements::popup_targets(&self.state);
+ custom_elements.extend(crate::elements::popup_render_elements(&popup_targets, renderer, (0, 0)));
if !hide_top_layers {
custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Top | Layer::Overlay)));
}
+ // Front-to-back, so a window already pushed occludes everything a
+ // later one draws - accumulated for the shadow clip below, exactly
+ // as both on-screen render loops do it.
+ let monitor_bounds: Vec<srdwm_core::Rect> = self.wm.borrow().monitors().iter().map(|m| m.full_geometry).collect();
+ let mut occluders: Vec<srdwm_core::Rect> = Vec::new();
for id in self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect::<Vec<_>>() {
let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
if let Some(deco) = self.state.decorations.get(&id) {
@@ -55,6 +80,33 @@ impl WaylandPlatform {
custom_elements.extend(crate::elements::surface_content_elements(renderer, &surface, pos, w.opacity));
}
}
+ // The drop shadow, last in this window's own group so it sits
+ // under its own decoration and content but still over every
+ // window behind it. `shadow_buffers` only holds an entry for a
+ // window that is meant to have one at all (see
+ // `state/lifecycle.rs`), so no separate floating/maximized
+ // check belongs here.
+ //
+ // Shadows were the other half of this pass's missing tier: a
+ // screenshot taken on this backend showed no shadow on any
+ // window, which is exactly the thing a shadow bug gets reported
+ // and re-checked by. Border strips are still absent - a real
+ // remaining gap, called out here rather than left silent.
+ if let Some(shadow) = self.state.shadow_buffers.get(&id) {
+ let full = crate::decoration::shadow_rect(w.geometry);
+ let rect = crate::decoration::shadow_rect_clipped(w.geometry, &monitor_bounds);
+ for fragment in crate::elements::visible_border_fragments(rect, &occluders) {
+ let src = Rectangle::new(
+ Point::from(((fragment.x - full.x) as f64, (fragment.y - full.y) as f64)),
+ Size::from((fragment.width as f64, fragment.height as f64)),
+ );
+ match MemoryRenderBufferRenderElement::from_buffer(renderer, (fragment.x as f64, fragment.y as f64), shadow, None, Some(src), None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
+ Err(e) => log::warn!("screencopy: failed to import shadow buffer for window {id}: {e}"),
+ }
+ }
+ }
+ occluders.push(w.geometry);
}
custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Background | Layer::Bottom)));
diff --git a/crates/wayland/src/winit/render.rs b/crates/wayland/src/winit/render.rs
index a07e074..dff425f 100644
--- a/crates/wayland/src/winit/render.rs
+++ b/crates/wayland/src/winit/render.rs
@@ -210,6 +210,10 @@ impl WaylandPlatform {
// it correctly. The border strips and titlebar bitmap are
// different: outside `geometry`, so they still need `occluders`'
// explicit clip against whichever window is stacked in front.
+ // See the matching push in `udev/render.rs` - this backend only
+ // ever has the one output, so the clip is a no-op here, but reading
+ // the same source keeps the two shadow paths from drifting.
+ let monitor_bounds: Vec<srdwm_core::Rect> = self.wm.borrow().monitors().iter().map(|m| m.full_geometry).collect();
let mut occluders: Vec<srdwm_core::Rect> = Vec::with_capacity(ids.len());
for id in ids {
let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
@@ -405,11 +409,12 @@ impl WaylandPlatform {
// exactly where two windows' corners nearly meet, which this
// compositor's default cascade placement does constantly.
if let Some(shadow) = self.state.shadow_buffers.get(&id) {
- let rect = decoration::shadow_rect(frame);
+ let full = decoration::shadow_rect(frame);
+ let rect = decoration::shadow_rect_clipped(frame, &monitor_bounds);
for fragment in crate::elements::visible_border_fragments(rect, &occluders) {
let pos = (fragment.x as f64, fragment.y as f64);
let src = Rectangle::new(
- Point::from(((fragment.x - rect.x) as f64, (fragment.y - rect.y) as f64)),
+ Point::from(((fragment.x - full.x) as f64, (fragment.y - full.y) as f64)),
Size::from((fragment.width as f64, fragment.height as f64)),
);
match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, shadow, None, Some(src), None, Kind::Unspecified) {
diff --git a/docs/TODO.md b/docs/TODO.md
index c533d39..72b3d20 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,10 +1,100 @@
# TODO / planned features - master checklist
+## Nemo's right-click menu: confirmed working, and two real bugs found doing it (2026-08-28)
+
+The last open punch-list item. It is closed on a real end-to-end repro, not
+on reading the source, and the `POPUP-GEOM-DIAG`/`POPUP-GRAB-DIAG`
+diagnostics in `protocols/xdg_shell.rs` are removed.
+
+**Result: the popup works.** In a throwaway nested compositor, a synthetic
+right-click on a file in Nemo opens the full context menu at the click
+point, above the window; hovering "Open With" opens its submenu, correctly
+placed and stacked over its own parent menu. Screenshots taken with `grim`
+on the nested display. Nothing in the popup path needed a fix.
+
+**Bug 1, and the reason this could never be tested before: synthetic input
+was a no-op on the backend a nested instance runs on.** Every
+`Motion`/`MotionAbsolute` handler in `virtual_pointer.rs` read
+`UdevState::bounds()` behind an early `return` when `state.udev` was
+`None`. That field is `Some` only for the DRM backend, so under the nested
+winit backend `zwlr_virtual_pointer_unstable_v1` advertised its global,
+accepted `create_virtual_pointer`, accepted every request, and silently
+discarded all motion - no error, no log. A Wayland client of one specific
+compositor is the only safe way to drive a throwaway instance (unlike
+`ydotool`, which writes to `/dev/uinput` and lands wherever the real seat's
+focus is - the exact hazard that parked this item), and it did not work.
+Fixed with a `pointer_bounds()` helper: DRM heads when `state.udev` is
+`Some`, `WindowManager::monitors()` otherwise. Both backends fill that list
+from `Platform::monitors()`, so it is the backend-agnostic source.
+
+**Bug 2, which made the screenshot state the opposite of the truth: the
+nested backend's screencopy pass rendered no popups and no shadows.** The
+DRM backend serves screencopy out of the on-screen frame it just drew, so
+it never had this. The winit backend re-renders the scene into an offscreen
+buffer (`winit/capture.rs`), and that second scene was missing tiers. A
+menu that drew perfectly on screen photographed as absent - which reads
+exactly like the client never opened one, and is very close to the original
+report. The first repro run "confirmed" the bug on that evidence; only a
+per-frame render diagnostic (`elements=1`, 84 consecutive frames, at the
+correct on-screen coordinate) showed the popup was being drawn all along.
+Popups and shadows now render into that pass. Border strips are still
+missing from it - a real remaining gap, stated rather than left silent.
+
+Also settled while doing this: the single-instance gotcha this file records
+twice (Firefox/Nemo activating the live instance regardless of a
+`WAYLAND_DISPLAY` override, opening real windows on the user's actual
+desktop). Starting the client under `dbus-run-session` gives it a private
+session bus, so it has no live instance to activate against and really does
+start a new process on the nested display. Verified: `srd clients` on the
+nested socket listed the Nemo window, and nothing appeared on the live
+session.
+
+New tool: `tools/virtual-pointer-click` (`vpclick`), a scriptable
+virtual-pointer driver - `move`/`press`/`release`/`click`, one command per
+line on stdin, acknowledged after each round-trip, so a test script can put
+a `grim` between a move and the click that follows it and never click at an
+unverified position. Standalone, like the other two tools in `tools/`.
+
+## Shadow bleed across a monitor seam: fixed (2026-08-28)
+
+The "windows show a bit in the other monitor" report from the entry below,
+which was diagnosed and left unfixed. `shadow_rect` expands by
+`SHADOW_SIZE` on every side with no monitor-boundary awareness, so a window
+flush against a seam put its 24px shadow strip on the neighbouring screen.
+
+New `decoration::shadow_rect_clipped(geometry, bounds)` clips the shadow to
+the bounding box of the monitors the window's own geometry actually
+touches. Not to the one monitor it is assigned to: a window straddling a
+seam really does occupy both screens, and clipping at the seam would cut
+its shadow off in the middle of its own visible body. A window touching no
+monitor is returned unclipped rather than collapsed to nothing. Both render
+paths and the winit capture pass now use it; the bitmap's own extent stays
+unclipped, because the `src` rectangle indexes into that bitmap and only
+the fragment list is clipped.
+
+Six tests, built on the incident's own numbers (two 1920x1080 outputs, seam
+at x=1920): flush against the seam from either side, straddling it,
+mid-monitor, the desktop's outer edge, and no monitors at all.
+**Not confirmed on screen.** The nested backend cannot produce a second
+monitor - `set output split` and `create fake-monitor` both return
+`{"ok":true}` and change nothing there, because both need real head
+machinery that only the DRM backend has. Confirming it needs the user's own
+two-monitor session.
+
+Correcting the entry below, which called this moot because the user had
+turned shadows off: `srd settings` against the live session reports
+`shadows: true`. It is not moot - it is a bug they can still hit today,
+the moment a floating window sits near the seam.
+
+Full workspace build/test/clippy clean: 489 tests (247 core / 158 wayland /
+43 platform / 28 config / 13 ctl), 0 failed, 0 clippy warnings, +6 for the
+seam clip.
+
## Four live reports from a real second monitor: one diagnosis, two real fixes, one config toggle, one AGS-side finding (2026-08-28)
A second monitor was physically connected, surfacing several reports at once.
-**"Windows show a bit in the other monitor", diagnosed, not yet independently re-verified.** `srd clients` on the live session showed several real windows sitting at `x: 1920` - exactly the seam between the two 1920-wide outputs. With shadows still active on the (not-yet-restarted) live binary, each one's 24px shadow strip has nowhere to land but the neighbouring monitor. This is a real, separate gap from anything fixed earlier today: the shadow-tint fix only ever considered a window's *neighbouring tile*, never a *neighbouring monitor* - `shadow_rect` expands blindly by `SHADOW_SIZE` on every side with no monitor-boundary awareness at all, so any floating window near a multi-monitor seam would still bleed onto the adjacent screen even with today's other shadow fixes applied. Not fixed as its own thing, since `general.shadows` was already turned off for this user's own live config today (per their own "tinting no" - see the shadow-regression entry below) - moot for them specifically, but a real, still-open limitation worth flagging for anyone who re-enables shadows on a multi-monitor setup.
+**"Windows show a bit in the other monitor", diagnosed, not yet independently re-verified.** `srd clients` on the live session showed several real windows sitting at `x: 1920` - exactly the seam between the two 1920-wide outputs. With shadows still active on the (not-yet-restarted) live binary, each one's 24px shadow strip has nowhere to land but the neighbouring monitor. This is a real, separate gap from anything fixed earlier today: the shadow-tint fix only ever considered a window's *neighbouring tile*, never a *neighbouring monitor* - `shadow_rect` expands blindly by `SHADOW_SIZE` on every side with no monitor-boundary awareness at all, so any floating window near a multi-monitor seam would still bleed onto the adjacent screen even with today's other shadow fixes applied. Not fixed as its own thing, since `general.shadows` was already turned off for this user's own live config today (per their own "tinting no" - see the shadow-regression entry below) - moot for them specifically, but a real limitation for anyone who re-enables shadows on a multi-monitor setup. **Fixed since - see the shadow-seam entry at the top of this file.**
**Desktop icons stayed highlighted after clicking a window - fixed.** `select_desktop_icon(None)` (clearing the selection) was only ever called from `start_desktop_marquee` (starting a fresh rubber-band select on bare desktop) - never from anywhere a real window becoming focused would reach. Every focus path in this compositor (a click, Alt-Tab, a dock's IPC focus dispatch, scratchpad show, the Snap-Layouts flyout) already funnels through one shared `focus_window` in `crates/wayland/src/input/focus.rs` for raising - added the same deselect call there, so it's now correct regardless of *how* a window got focused, matching Windows/GNOME/macOS convention (a selected icon stays highlighted only until something else takes focus).
@@ -110,7 +200,7 @@ Verified the two remaining research items live, in a nested compositor (`WAYLAND
**Chrome/Chromium double-titlebar heuristic**: launched real `google-chrome-stable --ozone-platform=wayland` in the nested compositor and screenshotted it with `grim`. No double decoration - exactly one titlebar-equivalent band, and no `srdwm`-drawn window title text anywhere in the capture (this compositor's own SSD always draws the window title; its total absence means Chrome negotiated `ClientSide` itself and srdwm correctly didn't stack its own frame on top). The Unity-style "File Edit View History Tools Profiles Help" row Chrome renders above its own toolbar (a real, separate Chrome-on-Linux behavior tied to appmenu/dbusmenu detection, confirmed present in `srd clients`' own `global_menu` field for this window) is Chrome's own client-side chrome, not evidence of anything srdwm drew. `likely_draws_own_titlebar`'s `org.gnome.*`-only app-id list does not need a Chrome/Chromium entry added - the existing xdg-decoration negotiation already handles it correctly without one.
-**Nemo's right-click context menu** (the still-open `POPUP-GEOM-DIAG`/`POPUP-GRAB-DIAG` investigation in `xdg_shell.rs`): partially re-verified only. Confirmed no double-decoration for Nemo the same way (one clean SSD titlebar, traffic-light-style buttons, no CSD stacking). Could **not** safely test the actual reported symptom (right-click produces no menu at all) - `ydotool` is a uinput-level daemon shared with the live session, not scoped to the nested compositor, and a blind synthetic click there risks landing in the user's real desktop rather than the test window (this file's own standing warning: "never click at a position you have not verified first"). Parked (`nightshift questions`) rather than guessed at or left silently incomplete - the two live options are asking the user to right-click Nemo directly and report back, or finding a way to scope synthetic input to a nested session before trying again. The diagnostics themselves are left in place since the underlying bug's status is still genuinely unknown, not because of oversight.
+**Nemo's right-click context menu** (the `POPUP-GEOM-DIAG`/`POPUP-GRAB-DIAG` investigation in `xdg_shell.rs`, **since closed - see the entry at the top of this file**): partially re-verified only. Confirmed no double-decoration for Nemo the same way (one clean SSD titlebar, traffic-light-style buttons, no CSD stacking). Could **not** safely test the actual reported symptom (right-click produces no menu at all) - `ydotool` is a uinput-level daemon shared with the live session, not scoped to the nested compositor, and a blind synthetic click there risks landing in the user's real desktop rather than the test window (this file's own standing warning: "never click at a position you have not verified first"). Parked (`nightshift questions`) rather than guessed at or left silently incomplete - the two live options are asking the user to right-click Nemo directly and report back, or finding a way to scope synthetic input to a nested session before trying again. The diagnostics themselves are left in place since the underlying bug's status is still genuinely unknown, not because of oversight.
## workspace.per_monitor, titlebar buttons, and desktop icons: live srd set + readback (2026-08-28)
@@ -225,7 +315,7 @@ Not attempted this pass, deliberately: `gpu.rs`'s own doc comment already states
Separately, found and removed eight `log::warn!("XXX-DIAG ...")` lines left behind from live debugging in the multi-session shift that landed in commit `3c41fc4` - the same "temporary, never removed" pattern already fixed twice earlier this session (see the 2026-08-21 POS-DIAG/CURSOR-DIAG entry and the 2026-08-27 TEMP-DIAG entry further down): `DECO-DIAG` (four call sites across `manager/windows.rs::add_window`/`reapply_rules_if_pending`, one in `state/lifecycle.rs::redraw_decoration_buffer`, one in `state/toplevel.rs::sync_toplevel_metadata`), `WS-IPC-DIAG` (`platform/ipc/dispatch.rs`'s `activate_workspace`), and `LAYER-VIS-DIAG` (`state/layers.rs`). Several of these fire on genuinely constant, ordinary interaction - `reapply_rules_if_pending`'s own doc comment says outright it runs "constantly for perfectly ordinary reasons (a browser tab finishing a page load)" - so this was real, continuous log noise on every title change, every workspace switch, every layer surface hide, not just a one-off leftover.
-Deliberately left alone: `protocols/xdg_shell.rs`'s `POPUP-GEOM-DIAG`/`POPUP-GRAB-DIAG` (five call sites). Unlike the eight removed above, this one is self-documented as a live, still-open investigation ("Temporary: live report is that Nemo's right-click context menu never appears at all... Remove once resolved") with no entry anywhere in this file confirming that investigation actually concluded - removing an active diagnostic for a bug nobody has confirmed fixed would be a real regression in debuggability, not a cleanup. Left for whoever is still chasing that one.
+Deliberately left alone at the time, **removed since the investigation closed - see the entry at the top of this file**: `protocols/xdg_shell.rs`'s `POPUP-GEOM-DIAG`/`POPUP-GRAB-DIAG` (five call sites). Unlike the eight removed above, this one is self-documented as a live, still-open investigation ("Temporary: live report is that Nemo's right-click context menu never appears at all... Remove once resolved") with no entry anywhere in this file confirming that investigation actually concluded - removing an active diagnostic for a bug nobody has confirmed fixed would be a real regression in debuggability, not a cleanup. Left for whoever is still chasing that one.
Full workspace build/test/clippy clean (33 platform / 32 ctl tests, both up from before by the new split coverage).
diff --git a/tools/virtual-pointer-click/Cargo.lock b/tools/virtual-pointer-click/Cargo.lock
new file mode 100644
index 0000000..f4dbd31
--- /dev/null
+++ b/tools/virtual-pointer-click/Cargo.lock
@@ -0,0 +1,216 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "cc"
+version = "1.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "virtual-pointer-click"
+version = "0.1.0"
+dependencies = [
+ "wayland-client",
+ "wayland-protocols-wlr",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
+dependencies = [
+ "bitflags",
+ "rustix",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
diff --git a/tools/virtual-pointer-click/Cargo.toml b/tools/virtual-pointer-click/Cargo.toml
new file mode 100644
index 0000000..9f94ef2
--- /dev/null
+++ b/tools/virtual-pointer-click/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "virtual-pointer-click"
+version = "0.1.0"
+edition = "2021"
+
+# Standalone - deliberately not a member of the main workspace at
+# ../../Cargo.toml, same reasoning as ../virtual-pointer-pin-test's and
+# ../toplevel-activate's own Cargo.toml: building/testing srdwm itself never
+# has to build this too. A debug tool, not part of the shipped product.
+[workspace]
+
+[[bin]]
+name = "vpclick"
+path = "src/main.rs"
+
+[dependencies]
+wayland-client = "=0.31.14"
+wayland-protocols-wlr = { version = "=0.3.12", features = ["client"] }
diff --git a/tools/virtual-pointer-click/src/main.rs b/tools/virtual-pointer-click/src/main.rs
new file mode 100644
index 0000000..993de45
--- /dev/null
+++ b/tools/virtual-pointer-click/src/main.rs
@@ -0,0 +1,161 @@
+// A scriptable zwlr_virtual_pointer_unstable_v1 driver: reads one command
+// per line from stdin and turns it into a virtual-pointer request against
+// whatever compositor WAYLAND_DISPLAY names.
+//
+// WHY THIS EXISTS RATHER THAN ydotool
+//
+// ydotool writes to /dev/uinput. That is a kernel-level device shared by
+// every session on the machine, so a synthetic click from it lands wherever
+// the real seat's focus happens to be - which, when the target is a nested
+// throwaway compositor, is very often the user's real desktop instead. This
+// tool is an ordinary Wayland client of one specific compositor, so its
+// input physically cannot reach any other one. That makes it the safe way
+// to drive a nested test instance, which is the only reason it exists.
+//
+// ../virtual-pointer-pin-test is not this: it is a fixed left-button drag
+// used to verify pinned delivery, and it blocks on stdin exactly once. This
+// one stays alive and takes a stream of commands, so a shell script can
+// interleave a `grim` screenshot between a move and the click that follows
+// it - which is what "never click at a position you have not verified
+// first" actually requires.
+//
+// Usage:
+// vpclick (commands on stdin, one per line)
+//
+// move <x> <y> absolute, as a fraction of EXTENT (5000 5000 = centre)
+// press <button> left | right | middle
+// release <button>
+// click <button> press then release
+// sync round-trip and acknowledge, nothing else
+// quit exit
+//
+// Every command prints "ok <command>" once it has round-tripped, so a
+// driving script can wait for the compositor to have actually seen it
+// rather than sleeping and hoping.
+
+use std::io::{BufRead, Write};
+
+use wayland_client::protocol::wl_pointer::ButtonState;
+use wayland_client::protocol::{wl_registry, wl_seat::WlSeat};
+use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle};
+use wayland_protocols_wlr::virtual_pointer::v1::client::zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1;
+use wayland_protocols_wlr::virtual_pointer::v1::client::zwlr_virtual_pointer_v1::{self, ZwlrVirtualPointerV1};
+
+const EXTENT: u32 = 10000;
+
+/// Linux `input-event-codes.h` button codes - what the protocol asks for
+/// verbatim, not a wl_pointer enum.
+const BTN_LEFT: u32 = 0x110;
+const BTN_RIGHT: u32 = 0x111;
+const BTN_MIDDLE: u32 = 0x112;
+
+struct State {
+ seat: Option<WlSeat>,
+ manager: Option<ZwlrVirtualPointerManagerV1>,
+}
+
+impl Dispatch<wl_registry::WlRegistry, ()> for State {
+ fn event(state: &mut Self, registry: &wl_registry::WlRegistry, event: wl_registry::Event, _: &(), _: &Connection, qh: &QueueHandle<Self>) {
+ if let wl_registry::Event::Global { name, interface, version } = event {
+ if interface == "wl_seat" {
+ state.seat = Some(registry.bind::<WlSeat, _, _>(name, version.min(9), qh, ()));
+ } else if interface == "zwlr_virtual_pointer_manager_v1" {
+ state.manager = Some(registry.bind::<ZwlrVirtualPointerManagerV1, _, _>(name, version.min(2), qh, ()));
+ }
+ }
+ }
+}
+
+impl Dispatch<WlSeat, ()> for State {
+ fn event(_: &mut Self, _: &WlSeat, _: wayland_client::protocol::wl_seat::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
+}
+
+impl Dispatch<ZwlrVirtualPointerManagerV1, ()> for State {
+ fn event(_: &mut Self, _: &ZwlrVirtualPointerManagerV1, _: wayland_protocols_wlr::virtual_pointer::v1::client::zwlr_virtual_pointer_manager_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
+ }
+}
+
+impl Dispatch<ZwlrVirtualPointerV1, ()> for State {
+ fn event(_: &mut Self, _: &ZwlrVirtualPointerV1, _: zwlr_virtual_pointer_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
+}
+
+fn button_code(name: &str) -> Option<u32> {
+ match name {
+ "left" => Some(BTN_LEFT),
+ "right" => Some(BTN_RIGHT),
+ "middle" => Some(BTN_MIDDLE),
+ _ => None,
+ }
+}
+
+fn main() {
+ let conn = Connection::connect_to_env().expect("failed to connect to the Wayland compositor - is WAYLAND_DISPLAY set?");
+ let display = conn.display();
+ let mut queue: EventQueue<State> = conn.new_event_queue();
+ let qh = queue.handle();
+ let _registry = display.get_registry(&qh, ());
+
+ let mut state = State { seat: None, manager: None };
+ queue.roundtrip(&mut state).expect("initial roundtrip failed");
+
+ let Some(manager) = state.manager.clone() else {
+ eprintln!("compositor does not advertise zwlr_virtual_pointer_manager_v1");
+ std::process::exit(1);
+ };
+ let Some(seat) = state.seat.clone() else {
+ eprintln!("compositor does not advertise wl_seat");
+ std::process::exit(1);
+ };
+
+ let pointer = manager.create_virtual_pointer(Some(&seat), &qh, ());
+ queue.roundtrip(&mut state).expect("create_virtual_pointer roundtrip failed");
+ println!("ready pid {}", std::process::id());
+ let _ = std::io::stdout().flush();
+
+ // A monotonically rising millisecond stamp. Some compositors ignore
+ // this entirely, but a click whose press and release carry the same
+ // timestamp is indistinguishable from a double-click to anything that
+ // does look, so it is worth stepping.
+ let mut time: u32 = 1;
+ let stdin = std::io::stdin();
+ for line in stdin.lock().lines() {
+ let line = line.expect("failed to read stdin");
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ let Some(&cmd) = parts.first() else { continue };
+ match cmd {
+ "move" if parts.len() == 3 => {
+ let x: u32 = parts[1].parse().expect("x must be a number");
+ let y: u32 = parts[2].parse().expect("y must be a number");
+ pointer.motion_absolute(time, x, y, EXTENT, EXTENT);
+ pointer.frame();
+ }
+ "press" | "release" | "click" if parts.len() == 2 => {
+ let Some(code) = button_code(parts[1]) else {
+ println!("err unknown button {}", parts[1]);
+ let _ = std::io::stdout().flush();
+ continue;
+ };
+ if cmd != "release" {
+ pointer.button(time, code, ButtonState::Pressed);
+ pointer.frame();
+ time += 10;
+ }
+ if cmd != "press" {
+ pointer.button(time, code, ButtonState::Released);
+ pointer.frame();
+ }
+ }
+ "sync" => {}
+ "quit" => break,
+ _ => {
+ println!("err bad command: {line}");
+ let _ = std::io::stdout().flush();
+ continue;
+ }
+ }
+ time += 10;
+ queue.roundtrip(&mut state).expect("roundtrip failed");
+ println!("ok {line}");
+ let _ = std::io::stdout().flush();
+ }
+}