srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/input
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-02-15 14:56:00 +0200
committersrdusr <[email protected]>2025-02-15 14:56:00 +0200
commit0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch)
tree7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/input
parent413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff)
downloadsrdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz
srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has diverged with its own separate fixes today. Nothing here is reviewed or curated yet - this exists purely so none of this work can be lost to a git operation, disk issue, or worktree cleanup while that reconciliation happens.
Diffstat (limited to 'crates/wayland/src/input')
-rw-r--r--crates/wayland/src/input/focus.rs138
-rw-r--r--crates/wayland/src/input/gestures.rs131
-rw-r--r--crates/wayland/src/input/keyboard.rs174
-rw-r--r--crates/wayland/src/input/layers.rs169
-rw-r--r--crates/wayland/src/input/pointer.rs683
5 files changed, 1295 insertions, 0 deletions
diff --git a/crates/wayland/src/input/focus.rs b/crates/wayland/src/input/focus.rs
new file mode 100644
index 0000000..ade0759
--- /dev/null
+++ b/crates/wayland/src/input/focus.rs
@@ -0,0 +1,138 @@
+//! Focusing, raising, and closing a window - the small set of helpers
+//! every other input-handling module needs regardless of what triggered
+//! the focus change (a click, a keybinding, an IPC call, a closed window's
+//! fallback).
+
+use smithay::desktop::Window as DWindow;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+
+use srdwm_core::{Event as CoreEvent, WindowId};
+
+use crate::state::CompState;
+
+/// The underlying `wl_surface` for a mapped window, regardless of whether
+/// it's a native `xdg-shell` toplevel or an XWayland `X11Surface` --
+/// `desktop::Window` exposes these as two separate accessors with no
+/// shared one.
+pub(crate) fn dwindow_wl_surface(w: &DWindow) -> Option<WlSurface> {
+ if let Some(top) = w.toplevel() {
+ return Some(top.wl_surface().clone());
+ }
+ w.x11_surface().and_then(|x| x.wl_surface())
+}
+
+/// Whether `w` is actually visible right now - on the current workspace and
+/// not minimized - matching `WindowManager::visible_windows`'s own filter.
+///
+/// `state.space` (smithay's `Space`) is not workspace-aware: a window stays
+/// mapped in it, and so stays hit-testable by `Space::element_under`, from
+/// the moment it's created until it's explicitly minimized or destroyed --
+/// switching workspace never unmaps anything (see `minimize` in
+/// `udev::platform`, the only other place that calls `unmap_elem`, and the
+/// absence of any workspace-switch handler that touches `self.space` at
+/// all). Without this check, `element_under` freely returns a window sitting
+/// on a workspace that isn't even shown, and a click "through" empty desktop
+/// on the current workspace silently focuses/raises/moves motion onto that
+/// invisible window instead of whatever (if anything) is really there.
+pub(super) fn dwindow_is_visible(state: &CompState, w: &DWindow) -> bool {
+ let Some(id) = dwindow_wl_surface(w).and_then(|s| state.surface_to_id.get(&s).copied()) else { return false };
+ let wm = state.wm.borrow();
+ wm.window(id).is_some_and(|win| !win.minimized && win.workspace == wm.current_workspace())
+}
+
+/// Requests a client close its window, whichever kind it is.
+pub(crate) fn close_dwindow(w: &DWindow) {
+ if let Some(top) = w.toplevel() {
+ top.send_close();
+ } else if let Some(x11) = w.x11_surface() {
+ let _ = x11.close();
+ }
+}
+
+/// Focuses `id` in our own `WindowManager` *and* gives its surface real
+/// Wayland/X11 keyboard focus - without this, a window can be raised and
+/// tiled correctly yet never receive a single keystroke.
+pub(crate) fn focus_window(state: &mut CompState, id: WindowId) {
+ state.wm.borrow_mut().focus_window(id);
+ // Raises the window in smithay's own `Space` too, not just core's
+ // `order` - `Space` keeps a completely independent stacking order of
+ // its own, which is what actually renders on top *and* what
+ // `space.element_under` hit-tests against; `WindowManager::order`
+ // (which `focus_window` above already updates) has no effect on
+ // either. Without this, any focus path that doesn't also happen to
+ // raise `Space` manually (Alt-Tab, a dock's IPC "focus" dispatch,
+ // scratchpad show, the Snap-Layouts flyout, ...) left a window
+ // genuinely focused - keyboard input, core's own idea of "topmost"
+ // both correct - while it kept rendering *underneath* whatever was
+ // already on top, and a click on the visible (stale-topmost) window
+ // silently reached that one instead. "Focus doesn't bring a window to
+ // the front" and "clicking through a window that's fully covering
+ // another" are the same root cause, not two bugs. Previously only the
+ // plain-content-click branch in `handle_pointer_button` did this,
+ // manually, immediately before calling this function - every other
+ // caller went through unraised. Cheap even when the window is already
+ // topmost (`raise_element` on an already-last element is a no-op
+ // reinsertion), so unconditional here rather than gated on whether
+ // focus is actually changing.
+ raise_in_space(state, id);
+ state.pending.borrow_mut().push(CoreEvent::WindowFocused(id));
+ let surface = state.id_to_window.get(&id).and_then(dwindow_wl_surface);
+ // Routed through `set_keyboard_focus` (rather than calling
+ // `KeyboardHandle::set_focus` directly) so clipboard/primary-selection
+ // focus follows window focus too - see that method's doc comment.
+ state.set_keyboard_focus(surface);
+}
+
+/// Raises `id` to the top of smithay's own `Space` stacking order (see
+/// `focus_window`'s doc comment for why `Space`'s own order, separate from
+/// core's, has to be kept in sync) without touching core's focus or
+/// workspace state at all.
+///
+/// Split out of `focus_window` specifically so the udev/winit backends'
+/// post-IPC-mutation re-sync (`crate::input::focus_window`'s doc comment
+/// on *that* call site explains why it exists at all: an IPC-only focus
+/// change needs `Space` to catch up too) can re-raise the already-focused
+/// window without going through `WindowManager::focus_window` a second
+/// time. That core method has its own side effect of switching to the
+/// target's workspace if it differs from the current one - correct for a
+/// real focus change, but wrong here: calling it on a window that is
+/// already focused (just re-raising it for `Space`'s benefit) compared the
+/// still-current, still-on-its-old-workspace window against whatever
+/// `current_workspace` had just been set to by the same IPC mutation this
+/// re-sync is reacting to, and silently switched it right back --
+/// confirmed live as `srd dispatch activate workspace <id>` (and, by
+/// extension, any AGS workspace-switcher click going through the same IPC
+/// path) visibly changing `current_workspace` for a moment and then
+/// reverting within milliseconds, every time, unless the same IPC call
+/// also happened to change which window was focused. Exactly the same bug
+/// `main.rs`'s `sync()` was already fixed for (see its own doc comment) --
+/// this is a second call site with the identical unconditional-`focus_
+/// window`-reassertion shape, never fixed at the same time.
+pub(crate) fn raise_in_space(state: &mut CompState, id: WindowId) {
+ if let Some(w) = state.id_to_window.get(&id).cloned() {
+ state.space.raise_element(&w, true);
+ state.raise_pinned();
+ }
+}
+
+/// Re-syncs real Wayland/X11 keyboard focus to whatever `WindowManager`
+/// already considers focused, without changing what that is.
+///
+/// For callers where core's own focus already moved on its own --
+/// specifically `WindowManager::remove_window`'s fallback to
+/// `self.order.last()` when the just-closed window was the focused one --
+/// and only the Wayland/X11 side needs to catch up to it. Without this, the
+/// window core now considers focused (and renders as such) never actually
+/// receives a keystroke until it's clicked, since nothing told
+/// `set_keyboard_focus` focus had moved.
+///
+/// `focus_window` above is for the opposite direction: driving core's
+/// focus deliberately (a click, a keybinding) and syncing outward from
+/// that. This is "core already decided, catch the rest of the compositor
+/// up" - `wm.focus_window` must not be called again here, since the id
+/// core picked (or `None`, if nothing is left) is exactly what should win.
+pub(crate) fn sync_keyboard_focus(state: &mut CompState) {
+ let focused = state.wm.borrow().focused_id();
+ let surface = focused.and_then(|id| state.id_to_window.get(&id)).and_then(dwindow_wl_surface);
+ state.set_keyboard_focus(surface);
+}
diff --git a/crates/wayland/src/input/gestures.rs b/crates/wayland/src/input/gestures.rs
new file mode 100644
index 0000000..f88c633
--- /dev/null
+++ b/crates/wayland/src/input/gestures.rs
@@ -0,0 +1,131 @@
+//! `SUPER`+scroll and touchpad-swipe workspace switching.
+
+use crate::state::CompState;
+
+use super::keyboard::core_modifiers_from_xkb;
+use super::{notify_idle_activity, DRAG_MODIFIER};
+
+/// Modifier+scroll cycles workspaces, consuming the event.
+///
+/// Returns `true` if it handled the scroll, in which case the caller must
+/// *not* also forward it to the client. Generic over the input backend for
+/// the same reason the keyboard handler is: both backends deliver scroll
+/// through smithay's `PointerAxisEvent` trait.
+pub(crate) fn handle_workspace_scroll<B, E>(state: &mut CompState, event: &E) -> bool
+where
+ B: smithay::backend::input::InputBackend,
+ E: smithay::backend::input::PointerAxisEvent<B>,
+{
+ use smithay::backend::input::Axis;
+
+ notify_idle_activity(state);
+ if state.lock.locked {
+ return false;
+ }
+ let mods = state.seat.get_keyboard().map(|k| core_modifiers_from_xkb(&k.modifier_state()));
+ if !mods.is_some_and(|m| m.contains(DRAG_MODIFIER)) {
+ return false;
+ }
+ let Some(v) = event.amount(Axis::Vertical).filter(|v| *v != 0.0) else { return false };
+ // Scrolling down (positive) advances, matching `workspace, e+1`.
+ switch_workspace_relative(state, v > 0.0)
+}
+
+/// Switches to the next (`forward`) or previous workspace in id order,
+/// wrapping around, and fires the two follow-up broadcasts a plain
+/// `WindowManager::switch_workspace` call alone doesn't cover. The shared
+/// body behind every *relative* workspace switch - `SUPER`+scroll above,
+/// and a 3+-finger touchpad swipe (`handle_gesture_swipe_end` below) --
+/// pulled out here rather than duplicated a second time: both gaps below
+/// were found missing for the scroll gesture specifically during this same
+/// session, and nothing about either is scroll-only, so a second call site
+/// copy-pasting the same steps would have been one missed broadcast away
+/// from reintroducing the exact bug that was just fixed once already.
+/// Returns `false` (and does nothing) if there are no workspaces at all.
+fn switch_workspace_relative(state: &mut CompState, forward: bool) -> bool {
+ let mut wm = state.wm.borrow_mut();
+ let ids: Vec<_> = wm.workspaces().iter().map(|w| w.id).collect();
+ if ids.is_empty() {
+ return false;
+ }
+ let current = ids.iter().position(|&id| id == wm.current_workspace()).unwrap_or(0);
+ let next = if forward { (current + 1) % ids.len() } else { (current + ids.len() - 1) % ids.len() };
+ wm.switch_workspace(ids[next]);
+ drop(wm);
+ // Without this, the switch above is invisible: nothing shows or hides
+ // a single window for the new workspace until `main.rs`'s `sync()`
+ // runs, which only happens when a polled event sets `dirty` - see
+ // `srdwm_core::Event::WorkspaceChanged`'s doc comment. Found live-
+ // testing the unrelated `ext_workspace_v1` protocol's own `activate`
+ // request, which has the identical problem; the scroll gesture had the
+ // exact same bug already, just never one anyone traced back this far.
+ state.pending.borrow_mut().push(srdwm_core::Event::WorkspaceChanged);
+ // Same reasoning as `foreign_toplevel::send_state`'s call sites: without
+ // this, a dock's workspace pill only ever tracked switches driven
+ // through `ext_workspace_handle_v1.activate` itself, going stale the
+ // moment a gesture (or any other non-protocol trigger) changed the
+ // active workspace instead.
+ crate::workspace::broadcast_active_workspace(state);
+ true
+}
+
+/// A 3+-finger touchpad swipe just started - resets the running horizontal
+/// offset `handle_gesture_swipe_update` accumulates into, or leaves it
+/// `None` while the session is locked so a swipe over the lock screen does
+/// nothing (matching every other pointer/keyboard path's "locked: no normal
+/// handling" rule - see this module's own doc comment).
+pub(crate) fn handle_gesture_swipe_begin<B, E>(state: &mut CompState, event: &E)
+where
+ B: smithay::backend::input::InputBackend,
+ E: smithay::backend::input::GestureBeginEvent<B>,
+{
+ notify_idle_activity(state);
+ state.gesture_swipe = if state.lock.locked { None } else { Some((event.fingers(), 0.0)) };
+}
+
+/// Accumulates one update's worth of horizontal motion into the swipe
+/// started by `handle_gesture_swipe_begin` - `delta_x` is relative to the
+/// *previous* update, not a running total (see `gesture_swipe`'s own doc
+/// comment on `CompState`), so summing here is the only way to know the
+/// swipe's real total distance once it ends.
+pub(crate) fn handle_gesture_swipe_update<B, E>(state: &mut CompState, event: &E)
+where
+ B: smithay::backend::input::InputBackend,
+ E: smithay::backend::input::GestureSwipeUpdateEvent<B>,
+{
+ if let Some((_, total_dx)) = state.gesture_swipe.as_mut() {
+ *total_dx += event.delta_x();
+ }
+}
+
+/// A touchpad swipe just ended - switches workspace if it was a genuine
+/// 3+-finger swipe past `SWIPE_THRESHOLD` and wasn't cancelled (a libinput
+/// gesture is marked cancelled when it doesn't resolve to a clean single
+/// direction, e.g. the fingers moved back and forth). Below the threshold
+/// or below 3 fingers, this does nothing - the same "did you mean it"
+/// floor a mis-clicked drag gets elsewhere in this file, and 2-finger
+/// motion is already handled as ordinary scroll (`PointerAxis`) rather
+/// than reaching here at all on a correctly configured touchpad.
+///
+/// Deliberately claimed entirely by the compositor rather than forwarded to
+/// the focused client, unlike pinch/hold (forwarded as-is in
+/// `udev::session`): `wp_pointer_gestures` swipe is specifically the
+/// 3/4-finger overview-style gesture, and the handful of desktops that
+/// support it at all (GNOME, sway, Hyprland) all reserve it for workspace
+/// switching the same way - there is no real client-side consumer to lose
+/// by not forwarding it. Swipe left (negative `total_dx`) advances to the
+/// next workspace, right goes back, matching macOS's own convention for
+/// swiping between spaces.
+const SWIPE_THRESHOLD: f64 = 60.0;
+
+pub(crate) fn handle_gesture_swipe_end<B, E>(state: &mut CompState, event: &E)
+where
+ B: smithay::backend::input::InputBackend,
+ E: smithay::backend::input::GestureEndEvent<B>,
+{
+ let Some((fingers, total_dx)) = state.gesture_swipe.take() else { return };
+ if event.cancelled() || fingers < 3 || total_dx.abs() < SWIPE_THRESHOLD {
+ return;
+ }
+ switch_workspace_relative(state, total_dx < 0.0);
+}
diff --git a/crates/wayland/src/input/keyboard.rs b/crates/wayland/src/input/keyboard.rs
new file mode 100644
index 0000000..ae367f4
--- /dev/null
+++ b/crates/wayland/src/input/keyboard.rs
@@ -0,0 +1,174 @@
+//! Keyboard key events: precise keybinding matching against
+//! `srdwm_core::keysyms`, VT switching, and the locked-session/native-lock
+//! password-entry path.
+
+use smithay::backend::input::{KeyState as BackendKeyState, KeyboardKeyEvent};
+use smithay::backend::session::Session as _;
+use smithay::input::keyboard::FilterResult;
+use smithay::utils::SERIAL_COUNTER;
+
+use srdwm_core::{Event as CoreEvent, Modifiers};
+
+use crate::state::CompState;
+
+/// Shared between the winit (nested) and udev (bare-TTY) backends: both
+/// deliver keyboard events through smithay's generic `KeyboardKeyEvent`
+/// trait, so the precise-keybinding-matching logic (see the module docs)
+/// only needs to exist once.
+pub(crate) fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend, E: KeyboardKeyEvent<B>>(state: &mut CompState, event: &E) {
+ super::notify_idle_activity(state);
+ let keycode = event.key_code();
+ let key_state = event.state();
+ let time = event.time_msec();
+ let serial = SERIAL_COUNTER.next_serial();
+ let Some(keyboard) = state.seat.get_keyboard() else { return };
+
+ // While the session is locked, every key goes to the lock surface and
+ // *nothing* is treated as a WM keybinding. Skipping this would leave the
+ // lock trivially bypassable - the config binds spawn commands
+ // (`Mod4+Return` opens a terminal), so honouring bindings here would let
+ // anyone at a locked screen run arbitrary programs.
+ if state.lock.locked {
+ // A native lock (`crate::native_lock`) has no external client
+ // surface to forward to at all - srdwm is its own locker, so
+ // every keystroke feeds the password buffer directly instead.
+ // Only on press: a character is typed on key-down, matching
+ // ordinary text input, and password/BackSpace/Return/Escape
+ // handling only make sense once per physical keystroke, not once
+ // per press *and* release.
+ if state.lock.native.is_some() {
+ if key_state == BackendKeyState::Pressed {
+ keyboard.input::<(), _>(state, keycode, key_state, serial, time, |data, mods, handle| {
+ // `keysym_to_utf8` on the already-resolved keysym
+ // (rather than the state-aware `xkb_state_key_get_
+ // utf8` xkbcommon's own docs recommend) is a
+ // deliberate simplification: correct for plain
+ // ASCII/shifted-symbol passwords, which is the
+ // overwhelming common case; the gap is dead-key/
+ // compose sequences spanning more than one keypress,
+ // which would just make that one character not match
+ // rather than ever falsely succeed - a usability
+ // rough edge, not a security one. Computed before
+ // `keysym_name_for` below, which takes `handle` by
+ // value.
+ let utf8 = xkbcommon::xkb::keysym_to_utf8(handle.modified_sym());
+ let name = keysym_name_for(handle).unwrap_or_default();
+ data.native_lock_key(&name, &utf8, mods.caps_lock);
+ FilterResult::Intercept(())
+ });
+ } else {
+ keyboard.input::<(), _>(state, keycode, key_state, serial, time, |_, _, _| FilterResult::Intercept(()));
+ }
+ return;
+ }
+ keyboard.input::<(), _>(state, keycode, key_state, serial, time, |_, _, _| FilterResult::Forward);
+ return;
+ }
+
+ let bound_keys = state.bound_keys.clone();
+ let matched: Option<(String, Modifiers)> =
+ keyboard.input(state, keycode, key_state, serial, time, move |data, mods, handle| {
+ let modifiers = core_modifiers_from_xkb(mods);
+ // `Ctrl+Alt+F1`..`F12` (xkb emits these as the `XF86Switch_VT_1`..
+ // `_12` keysyms, not a plain function-key + modifier combo) --
+ // handled here, by raw keysym *value* rather than name, since
+ // matching a name string wrong fails silently and looks
+ // identical to this never having been implemented at all (it
+ // wasn't, until now: reported live, the user had to leave the
+ // graphical session entirely and log in on a different TTY to
+ // get a shell back after srdwm went down, because nothing ever
+ // told the session to switch away). Values are contiguous
+ // (0x1008FE01..=0x1008FE0C, xkbcommon's `keysyms.rs`), so `raw -
+ // KEY_XF86SWITCH_VT_1 + 1` is the target VT. Udev/bare-TTY
+ // backend only - `data.udev` is `None` under the nested winit
+ // backend, where VT switching is meaningless, so this is a
+ // no-op there rather than an error, same as every other
+ // udev-only feature in this module.
+ const KEY_XF86SWITCH_VT_1: u32 = 0x1008_FE01;
+ const KEY_XF86SWITCH_VT_12: u32 = 0x1008_FE0C;
+ let raw = handle.modified_sym().raw();
+ if (KEY_XF86SWITCH_VT_1..=KEY_XF86SWITCH_VT_12).contains(&raw) {
+ if key_state == BackendKeyState::Pressed {
+ if let Some(udev) = data.udev.as_mut() {
+ let vt = (raw - KEY_XF86SWITCH_VT_1 + 1) as i32;
+ if let Err(e) = udev.session.change_vt(vt) {
+ log::warn!("udev: change_vt({vt}) failed: {e}");
+ }
+ }
+ }
+ return FilterResult::Intercept((String::new(), modifiers));
+ }
+ match keysym_name_for(handle) {
+ Some(name) if bound_keys.contains(&srdwm_core::key_combo_string(modifiers, &name)) => {
+ FilterResult::Intercept((name, modifiers))
+ }
+ _ => FilterResult::Forward,
+ }
+ });
+
+ match key_state {
+ BackendKeyState::Pressed => {
+ // An empty `key_name` is the VT-switch case above, already
+ // fully handled inside the closure - it isn't a real
+ // keybinding and must not start a repeat timer or fire a
+ // `CoreEvent::KeyPress` (`Lua` config has nothing bound to `""`,
+ // so this would be harmless either way, but skipping it is both
+ // cheaper and clearer than relying on that).
+ if let Some((key_name, modifiers)) = matched {
+ if !key_name.is_empty() {
+ state.begin_repeat(keycode, &key_name, modifiers);
+ state.pending.borrow_mut().push(CoreEvent::KeyPress { key_name, modifiers });
+ }
+ }
+ }
+ // Any release ends a repeat of *that* key; releasing an unrelated
+ // key must not stop it.
+ BackendKeyState::Released => state.end_repeat(keycode),
+ }
+ // Unmatched keys were already forwarded to the focused client by
+ // `FilterResult::Forward` inside the closure above.
+}
+
+/// Translates the effective xkb keysym for this keypress into the same
+/// `"Return"`/`"a"`/`"F5"`-style name `srdwm_core::keysyms` uses, so a
+/// binding written once in Lua resolves identically on X11 and Wayland.
+pub(crate) fn keysym_name_for(handle: smithay::input::keyboard::KeysymHandle<'_>) -> Option<String> {
+ // `raw_syms()` - the keycode's level-0 (unshifted) symbol for the
+ // *current* layout, not `modified_sym()` (what Shift actually turns it
+ // into). For a keybinding like `Super+Shift+2`, matching against
+ // `modified_sym()` looked up whatever Shift+2 really produces on the
+ // active layout - `@` on US, and something else again on most other
+ // layouts - which never equals the literal name `"2"` the Lua config
+ // binds against. Every `Super+Shift+<number>` binding
+ // (`keybindings.lua`'s `workspace.move_window`) silently never matched
+ // anything, indistinguishable from not being bound at all. Shift is
+ // still fully honored as a *modifier* - `core_modifiers_from_xkb`
+ // reads it independently of which symbol this function returns - this
+ // only changes which symbol *name* represents "the 2 key", the same
+ // physical-key-plus-modifier-flags model every other keybinding system
+ // (Hyprland, i3, sway) uses. The other caller of this function (the
+ // native lock's password entry) only ever compares the result against
+ // non-shift-sensitive names (`BackSpace`/`Return`/`Escape`), so this
+ // doesn't change that path's behavior at all - real character input
+ // there already goes through `keysym_to_utf8(handle.modified_sym())`
+ // separately, untouched by this.
+ let sym = handle.raw_syms().first().copied().unwrap_or_else(|| handle.modified_sym());
+ srdwm_core::keysyms::keysym_to_name(sym.raw())
+}
+
+pub(crate) fn core_modifiers_from_xkb(mods: &smithay::input::keyboard::ModifiersState) -> Modifiers {
+ let mut m = Modifiers::empty();
+ if mods.shift {
+ m |= Modifiers::SHIFT;
+ }
+ if mods.ctrl {
+ m |= Modifiers::CTRL;
+ }
+ if mods.alt {
+ m |= Modifiers::ALT;
+ }
+ if mods.logo {
+ m |= Modifiers::SUPER;
+ }
+ m
+}
diff --git a/crates/wayland/src/input/layers.rs b/crates/wayland/src/input/layers.rs
new file mode 100644
index 0000000..718d83f
--- /dev/null
+++ b/crates/wayland/src/input/layers.rs
@@ -0,0 +1,169 @@
+//! `zwlr_layer_shell_v1` pointer hit-testing (bars, docks, launchers) and
+//! the layer-driven maximize-geometry computation both backends' `monitors()`
+//! need.
+
+use smithay::desktop::{layer_map_for_output, WindowSurfaceType};
+use smithay::output::Output;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::Resource as _;
+use smithay::utils::{Logical, Point};
+use smithay::wayland::compositor::with_states;
+use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone, Layer, LayerSurfaceCachedState};
+
+use crate::state::CompState;
+
+/// Topmost layer-shell surface (if any) under `pos`, checked in the same
+/// above-everything-else stacking order `space_render_elements` renders
+/// `Overlay`/`Top` layers in (bars, launchers, notifications, lock UIs).
+/// `Background`/`Bottom` layers (wallpapers) deliberately aren't checked
+/// here: nothing in scope for the daily-driver gate needs pointer input
+/// routed to them, and space windows should stay clickable over a
+/// wallpaper.
+/// `pos` is in the global space; layer geometry is relative to its own
+/// output, so the pointer is translated into output-local coordinates
+/// before hit-testing and the result translated back out.
+/// Only checked for `Overlay`/`Top` before a window hit-test, and again for
+/// `Bottom`/`Background` after one comes up empty - see the two call
+/// sites in `handle_pointer_button`/`handle_pointer_position` for why it's
+/// split rather than one four-layer loop here. A `Bottom`/`Background`
+/// surface (a desktop-icons layer, a wallpaper daemon that wants clicks) is
+/// meant to sit *behind* normal windows, so a window covering that point
+/// should still get the click; `Overlay`/`Top` (an on-screen keyboard, a
+/// bar, a dock) are meant to sit in front of everything, windows included.
+///
+/// Was `Overlay`/`Top` only, full stop - a `Bottom`-layer surface was
+/// silently unclickable no matter what, since nothing else in
+/// `handle_pointer_button` ever checked layers at all. Not the cause of
+/// the live "clicking the dock does nothing" report (confirmed: that dock
+/// uses `Layer::Top`, which was already checked), but a real, separate gap
+/// found while chasing it - worth closing regardless of whether anything
+/// currently deployed sits at `Bottom`/`Background` yet.
+pub(super) fn layer_surface_under_layers(state: &CompState, pos: Point<f64, Logical>, layers: [Layer; 2]) -> Option<(WlSurface, Point<i32, Logical>)> {
+ let entry = state.output_at(pos)?;
+ let origin = entry.location;
+ let local = pos - origin.to_f64();
+ let map = layer_map_for_output(&entry.output);
+ for layer_kind in layers {
+ // Not `map.layer_under(layer_kind, local)` - that hands back only
+ // the single topmost surface whose *bounding box* contains `local`,
+ // and if that one surface's own input region excludes the point
+ // (its `surface_under` below returns `None`), the old code gave up
+ // on this whole layer-kind rather than trying whatever real,
+ // clickable surface is stacked underneath it. A bbox-only pick is
+ // exactly wrong the moment two surfaces on the same layer-kind
+ // overlap - a transparent, mapped-but-mostly-empty surface (a
+ // backdrop-dismiss popup, concretely: `Overview`'s own bbox-wide
+ // fallback region was exactly this shape before it was fixed
+ // AGS-side) sitting in front of a real one in z-order would
+ // silently swallow every click and even every hover/motion event
+ // meant for the surface underneath, with no way to reach it at
+ // all. Walking every candidate on this layer-kind, topmost first
+ // (`.rev()`, matching `layer_under`'s own z-order convention), and
+ // falling through to the next when a candidate's real input region
+ // doesn't cover the point, is what `layer_under` alone can't do.
+ for layer in map.layers_on(layer_kind).rev() {
+ let Some(geo) = map.layer_geometry(layer) else { continue };
+ if !geo.to_f64().contains(local) {
+ continue;
+ }
+ // Temporary: verifying the `layer_surfaces_shown_once` fix
+ // (state/layers.rs) actually stops a reused `wl_surface`'s
+ // stale layer-shell entry from outliving its role destroy --
+ // live-reproduced this session as a full-monitor click-catcher
+ // popup whose hit-tested geometry came back wider than the
+ // real output after several open/close cycles. Remove once a
+ // restart confirms the geometry stays sane across repeated
+ // popup toggles.
+ let local_in_surface = local - geo.loc.to_f64();
+ // `None` here means "no region ever committed" - per-protocol
+ // that means the *whole* surface is input-sensitive, not that
+ // nothing is, so it is its own distinct, meaningful answer from
+ // `Some([])` (a region was committed and it is empty).
+ let region_dump = with_states(layer.wl_surface(), |states| {
+ states.cached_state.get::<smithay::wayland::compositor::SurfaceAttributes>().current().input_region.as_ref().map(|r| r.rects.clone())
+ });
+ log::info!(
+ "layer_hit_test: layer={:?} namespace={:?} surface={:?} geo={:?} local_in_surface={:?} input_region={:?}",
+ layer_kind,
+ layer.namespace(),
+ layer.wl_surface().id(),
+ geo,
+ local_in_surface,
+ region_dump
+ );
+ if let Some((surface, surface_loc)) = layer.surface_under(local - geo.loc.to_f64(), WindowSurfaceType::ALL) {
+ return Some((surface, origin + geo.loc + surface_loc));
+ }
+ }
+ }
+ None
+}
+
+pub(super) fn layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> {
+ layer_surface_under_layers(state, pos, [Layer::Overlay, Layer::Top])
+}
+
+/// The `Bottom`/`Background` half of the same lookup - see
+/// `layer_surface_under_layers`'s doc comment for the ordering rationale.
+pub(super) fn background_layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> {
+ layer_surface_under_layers(state, pos, [Layer::Bottom, Layer::Background])
+}
+
+/// `full` with only a top-anchored layer surface's exclusive zone (a menu
+/// bar) subtracted back out - see `Monitor::maximize_geometry`'s own doc
+/// comment for why maximize needs this third rect, distinct from both
+/// `geometry` (every zone subtracted) and `full_geometry` (none). Shared by
+/// both backends' `monitors()`, same as everything else in this module.
+/// Deliberately re-derived from the layer list rather than reusing
+/// `non_exclusive_zone()`: that smithay helper folds every anchor
+/// together with no way to ask it to skip one edge - see below for which
+/// edges this now shrinks for and why.
+///
+/// Shrinks for a reservation on *any* edge (top, bottom, left, or right),
+/// not top only - reported live as a maximized window's own bottom edge
+/// and border ending up underneath a bottom-anchored dock, indistinguishable
+/// from the dock not rendering at all. An earlier version of this
+/// function shrank only for a top-anchored bar, on the reasoning that
+/// maximize should be able to "go past" a dock while fullscreen (which
+/// already ignores every zone, via `full_geometry`) covers the case that
+/// wants the screen entirely to itself - but no other edge actually
+/// benefits from that distinction the way a top menu bar does, and
+/// respecting every edge here is what every mainstream desktop's own
+/// maximize convention already does. Fullscreen is unaffected - it never
+/// called this function, and still doesn't.
+pub(crate) fn maximize_geometry_for(output: &Output, full: srdwm_core::Rect) -> srdwm_core::Rect {
+ let mut rect = full;
+ // `exclusive_zone`/`margin` are logical (a layer-shell client reports
+ // its own reservation the same way every other layer-shell geometry
+ // is expressed), while `full` is physical pixels - same unit
+ // mismatch `Platform::monitors()` needed fixing for, and the same
+ // fix: scale the logical amount into physical pixels before touching
+ // a physical rect with it. Left unconverted, a scaled output's
+ // maximize target shrank by the wrong number of physical rows/columns
+ // for its own bar/dock (too few at scale < 1.0, too many above 1.0).
+ let scale = output.current_scale().fractional_scale();
+ for layer in layer_map_for_output(output).layers() {
+ let data = with_states(layer.wl_surface(), |states| *states.cached_state.get::<LayerSurfaceCachedState>().current());
+ let ExclusiveZone::Exclusive(amount) = data.exclusive_zone else { continue };
+ let scaled = |margin: i32| ((amount as f64 + margin as f64) * scale).round().max(0.0) as i32;
+ if data.anchor.contains(Anchor::TOP) && !data.anchor.contains(Anchor::BOTTOM) {
+ let shrink = scaled(data.margin.top);
+ rect.y += shrink;
+ rect.height = rect.height.saturating_sub(shrink as u32);
+ }
+ if data.anchor.contains(Anchor::BOTTOM) && !data.anchor.contains(Anchor::TOP) {
+ let shrink = scaled(data.margin.bottom);
+ rect.height = rect.height.saturating_sub(shrink as u32);
+ }
+ if data.anchor.contains(Anchor::LEFT) && !data.anchor.contains(Anchor::RIGHT) {
+ let shrink = scaled(data.margin.left);
+ rect.x += shrink;
+ rect.width = rect.width.saturating_sub(shrink as u32);
+ }
+ if data.anchor.contains(Anchor::RIGHT) && !data.anchor.contains(Anchor::LEFT) {
+ let shrink = scaled(data.margin.right);
+ rect.width = rect.width.saturating_sub(shrink as u32);
+ }
+ }
+ rect
+}
diff --git a/crates/wayland/src/input/pointer.rs b/crates/wayland/src/input/pointer.rs
new file mode 100644
index 0000000..7b3bbce
--- /dev/null
+++ b/crates/wayland/src/input/pointer.rs
@@ -0,0 +1,683 @@
+//! Pointer motion, button presses, and cursor-shape resolution.
+
+use smithay::backend::input::ButtonState as BackendButtonState;
+use smithay::desktop::{layer_map_for_output, WindowSurfaceType};
+use smithay::input::pointer::{ButtonEvent, MotionEvent};
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::utils::{Logical, Point, SERIAL_COUNTER};
+use smithay::wayland::shell::wlr_layer::KeyboardInteractivity;
+
+use srdwm_core::{TitlebarHit, WindowId};
+
+use crate::state::CompState;
+
+use super::focus::{close_dwindow, dwindow_is_visible, dwindow_wl_surface, focus_window};
+use super::keyboard::core_modifiers_from_xkb;
+use super::layers::{background_layer_surface_under, layer_surface_under};
+use super::{notify_idle_activity, DRAG_MODIFIER};
+
+/// `WindowManager::hit_test`, but substituting each window's currently
+/// *animated* rect (if it has one active in `state.window_anims`) for its
+/// final `geometry` - see `hit_test_with`'s own doc comment in
+/// `crates/core/src/manager/hittest.rs` for why plain `hit_test` alone gets
+/// this wrong during a maximize/fullscreen/snap toggle or a new window's
+/// open-slide. Every decoration hit-test call site in this module goes
+/// through this now instead of calling `hit_test` on the borrowed
+/// `WindowManager` directly.
+fn hit_test_animated(state: &CompState, x: i32, y: i32) -> Option<(WindowId, TitlebarHit)> {
+ state.wm.borrow().hit_test_with(x, y, |id, geometry| {
+ let animated = state.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(geometry);
+ // Also corrects for a client whose real committed size differs
+ // from what was requested (a terminal's cell-quantized size, most
+ // commonly) - see `effective_frame`'s own doc comment. Without
+ // this, the resize-margin/border hit-test zone stayed sized to the
+ // *requested* rect even after the border itself moved to match the
+ // real one, so the clickable edge and the visible edge disagreed
+ // again, just like the border and the desktop background used to.
+ state.effective_frame(id, animated)
+ })
+}
+
+/// Re-resolves and re-asserts real Wayland pointer focus at `pos` - i.e.
+/// re-runs the exact same layer-shell/decoration/content/background
+/// hit-testing `handle_pointer_position` always did, and calls
+/// `pointer.motion()` with whatever it finds, but *without* sending
+/// `wl_pointer.frame` (callers decide when their own batch of events is
+/// done) and without any of `handle_pointer_position`'s other side effects
+/// (cursor shape, focus-follows-mouse, drag/resize updates) - those only
+/// make sense on an actual motion event, not a button press.
+///
+/// Extracted so [`handle_pointer_button`] can call this immediately before
+/// delivering a click, rather than only ever trusting whatever the *last*
+/// real motion event happened to leave `PointerHandle`'s own focus at.
+/// Those can disagree: confirmed live via a temporary diagnostic (since
+/// removed) that `space.element_under(pos)` - srdwm's own, freshly
+/// computed on every click - and
+/// `PointerHandle::current_focus()` - Wayland's, last set by whichever
+/// motion event happened to run before this click - disagreed on a real
+/// user's real clicks, inconsistently, sometimes on the very same window.
+/// A click landing on stale/no Wayland focus reads exactly like "clicking
+/// doesn't work" or "the cursor isn't where clicking happens," even though
+/// srdwm's own idea of what's under the pointer was correct the whole
+/// time. Calling this right before every button event closes that gap
+/// regardless of why focus went stale, rather than chasing the exact
+/// staleness trigger (rapid clicks, a tap-to-click event with no
+/// intervening motion delta, etc.) one cause at a time.
+#[allow(clippy::type_complexity)]
+fn refresh_pointer_focus(
+ state: &mut CompState,
+ pos: Point<f64, Logical>,
+ time: u32,
+) -> (Option<(WindowId, TitlebarHit)>, bool, bool, Option<WindowId>, Option<(WlSurface, Point<f64, Logical>)>) {
+ // Checked before literally everything else, including layer-shell --
+ // see `elements::popup_surface_under`'s own doc comment for why: a
+ // popup (tooltip, dropdown, right-click menu) always renders on top of
+ // everything else, popups on their own parent's content and layer-shell
+ // bars/docks alike, and hit-testing has to match that same priority or
+ // a click/scroll over an open popup silently lands on whatever's
+ // underneath it instead.
+ let popup_hit = crate::elements::popup_surface_under(state, pos);
+ let layer_hit = layer_surface_under(state, pos);
+ // Broadened, not just layer-shell: both a layer surface and an open
+ // popup are transient client UI that should suppress WM-level
+ // decoration-cursor guessing and focus-follows-mouse the same way (see
+ // both call sites below) - hovering a dropdown menu must not refocus
+ // whatever window happens to sit underneath it.
+ let over_layer_surface = layer_hit.is_some() || popup_hit.is_some();
+ let hit = hit_test_animated(state, pos.x as i32, pos.y as i32);
+ let under = state
+ .space
+ .element_under(pos)
+ .filter(|(w, _)| dwindow_is_visible(state, w))
+ .map(|(w, loc)| (w.clone(), loc));
+ let over_content = under.is_some();
+ // Whichever core window the pointer is over right now, decoration or
+ // content - `None` while over a layer-shell surface or bare desktop.
+ // Only `handle_pointer_position` actually uses this (focus-follows-
+ // mouse), but it needs `under` before that's consumed by the match
+ // below, so it's computed here rather than recomputed by the caller.
+ let hovered_id = hit
+ .map(|(id, _)| id)
+ .or_else(|| under.as_ref().and_then(|(window, _)| dwindow_wl_surface(window)).and_then(|s| state.surface_to_id.get(&s).copied()));
+
+ let Some(pointer) = state.seat.get_pointer() else { return (hit, over_layer_surface, over_content, hovered_id, None) };
+ // Freshly resolved target from ordinary hit-testing - overridden below
+ // by `pointer_button_grab` when a button is held, per its own doc
+ // comment (the Wayland implicit-grab rule).
+ let resolved: Option<(WlSurface, Point<f64, Logical>)> = if let Some((surface, loc)) = popup_hit {
+ Some((surface, loc.to_f64()))
+ } else if let Some((surface, loc)) = layer_hit {
+ Some((surface, loc.to_f64()))
+ } else if hit.is_some() {
+ None // Over our own decoration - no client focus.
+ } else if let Some((window, loc)) = &under {
+ // `window.toplevel()` is only ever `Some` for a native xdg-shell
+ // surface - it's `None` for every XWayland window, and even for a
+ // plain xdg-shell one it's always the *root* surface regardless of
+ // which subsurface the pointer is actually over (video/GL overlays,
+ // some GTK/Electron popups). Either way that meant pointer focus
+ // landed on the wrong surface - or no surface at all, for X11
+ // clients - and the click coordinates were relative to the window
+ // root rather than whatever was actually under the cursor.
+ // `Window::surface_under` is smithay's own hit-test for this: it
+ // walks the real surface tree (subsurfaces and popups included) and
+ // unifies the xdg-shell/X11 cases the way `dwindow_wl_surface` does
+ // elsewhere in this module.
+ //
+ // `loc` (from `Space`) is the window's raw *buffer*-origin in screen
+ // space, NOT its visible top-left - see `sync_geometry`'s own doc
+ // comment (`state/geometry.rs`), which positions every window via
+ // `map_element(w, (geom.x - content_offset.x, ...))` *specifically*
+ // so that `pos - loc` alone already lands in the buffer-local
+ // coordinates `Window::surface_under` expects (confirmed against
+ // smithay 0.7.0's own source: the ordinary toplevel branch hands
+ // `point` straight through with a hardcoded `(0, 0)` offset, unlike
+ // its sibling popup branch a few lines above, which does add
+ // `self.geometry().loc` - so a toplevel's `point` has to already
+ // be buffer-local, and `map_element`'s own placement is what makes
+ // `pos - loc` be exactly that with no further adjustment needed).
+ //
+ // A previous version of this line added `content_offset` back a
+ // *second* time (`pos - loc + content_offset`), reasoning that
+ // `loc` was the visible position and needed shifting back to
+ // buffer-local - but `sync_geometry` had already done that
+ // shifting into `loc` itself, so this double-applied it: every
+ // click on a CSD window with a nonzero shadow margin (GTK4 clients
+ // - Firefox concretely, on its own titlebar/tab-strip buttons
+ // specifically, since that's real content on an undecorated
+ // window, not srdwm's own decoration) landed `content_offset`
+ // *past* whatever was actually clicked, in the opposite direction
+ // from the original (pre-any-fix) bug. Both versions were wrong in
+ // opposite directions; plain `pos - loc` is what `sync_geometry`'s
+ // own contract actually calls for.
+ let win_relative = pos - loc.to_f64();
+ window.surface_under(win_relative, WindowSurfaceType::ALL).map(|(surface, offset)| (surface, (*loc + offset).to_f64()))
+ } else {
+ // Bare desktop, no window there either - last chance for a
+ // `Bottom`/`Background` layer surface (see
+ // `layer_surface_under_layers`'s doc comment) before giving up.
+ background_layer_surface_under(state, pos).map(|(surface, loc)| (surface, loc.to_f64()))
+ };
+ // `pointer_button_grab`'s own lock is deliberately skipped whenever a
+ // real Wayland-level grab is active (`pointer.is_grabbed()`) - most
+ // concretely, a client-initiated `wl_data_device` drag-and-drop
+ // (`DnDGrab`, installed the moment a client calls `start_drag`, e.g. a
+ // browser tab being torn out into another window). smithay's own
+ // `DnDGrab::motion` ignores this call's `focus` argument for the
+ // client-facing side (it explicitly calls `handle.motion(data, None,
+ // event)`, since no client gets ordinary pointer focus mid-drag) but
+ // *does* feed the same `focus` straight into `update_focus`, which is
+ // what actually decides the current drop target as the cursor moves.
+ // Keeping the origin-surface lock active here as well meant that value
+ // stayed pinned to whichever window the drag *started* over for the
+ // entire gesture, so `update_focus` could never see a second window as
+ // the drop target no matter where the cursor actually went - reported
+ // live as not being able to drag a tab from one window onto another.
+ // The lock's own reason for existing (a GTK drag recognizer treating a
+ // mid-gesture `leave` as "abort", see this field's own doc comment)
+ // only applies to *ordinary* pointer motion, which is exactly the case
+ // `is_grabbed()` being false identifies - once a real grab has taken
+ // over, that grab's own implementation is already responsible for
+ // routing enter/leave correctly, and needs the true, freshly-resolved
+ // surface to do it, not a stale one.
+ let delivery = if pointer.is_grabbed() { resolved.clone() } else { state.pointer_button_grab.clone().or_else(|| resolved.clone()) };
+ if let Some((surface, origin)) = delivery {
+ // `MotionEvent.location` is documented on `smithay::input::pointer::
+ // MotionEvent` itself as "Location of the pointer in compositor
+ // space" - i.e. global, the same space `pos` is already in.
+ // `PointerHandle::motion`'s own `focus` parameter carries `origin`
+ // specifically so smithay can compute the surface-relative
+ // coordinate *itself* (`event.location - loc`, see `PointerInternal
+ // ::motion` in smithay's `input/pointer/mod.rs`) before handing it
+ // to the client and storing the *global* value in its own internal
+ // `self.location` (what `PointerHandle::current_location()` later
+ // returns). Subtracting `origin` here as well, before this call,
+ // fed the client `pos - origin - origin` - doubly-offset, and
+ // wrong in a way that grows with a window's distance from the
+ // screen origin - while also corrupting smithay's own idea of
+ // "where is the pointer" for anything else that reads
+ // `current_location()`. `pos` unmodified, letting smithay subtract
+ // `origin` exactly once, is what every other call site in this
+ // file (and this same function's own `None`/lock-surface branches)
+ // already does correctly.
+ pointer.motion(state, Some((surface, origin)), &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
+ } else {
+ pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
+ }
+ (hit, over_layer_surface, over_content, hovered_id, resolved)
+}
+
+pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Logical>, time: u32) {
+ notify_idle_activity(state);
+ // Locked: pointer motion goes to the lock surface only. No hit-testing
+ // against windows/decorations, so no hover, no drag, no resize.
+ if state.lock.locked {
+ let surface = state.any_lock_surface().cloned();
+ if let Some(pointer) = state.seat.get_pointer() {
+ let focus = surface.map(|s| (s, Point::from((0, 0)).to_f64()));
+ pointer.motion(state, focus, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time });
+ pointer.frame(state);
+ }
+ return;
+ }
+
+ // Tells core which monitor the pointer is physically over right now --
+ // core has no pointer of its own to know this (see `pointer_monitor`'s
+ // own doc comment), and `add_window`'s target-monitor fallback needs
+ // it for the one case the *focused* window's monitor can't answer:
+ // nothing focused on whichever monitor the user is actually at when
+ // launching something new. `full_geometry`, not the bar-shrunk
+ // `geometry` - this is "which physical screen is this pixel on", not
+ // a work-area question. `None` if the pointer is somehow outside every
+ // known monitor (shouldn't happen given `UdevState::bounds()` already
+ // clamps to their union, but a real `None` here is honest rather than
+ // guessing).
+ {
+ let mut wm = state.wm.borrow_mut();
+ let current = wm.monitors().iter().find(|m| m.full_geometry.contains_point(pos.x as i32, pos.y as i32)).map(|m| m.id);
+ wm.set_pointer_monitor(current);
+ }
+ let (hit, over_layer_surface, over_content, hovered_id, _) = refresh_pointer_focus(state, pos, time);
+ // The only pointer-position telemetry this compositor exposes to
+ // anything outside itself - kept at `trace` (off by default, `RUST_LOG`
+ // enables it same as any other target here) rather than removed
+ // outright: a peer session building against this compositor over IPC
+ // pointed out that without *some* "where is the pointer right now" oracle,
+ // a synthetic-input tool has no way to tell whether it moved the pointer
+ // at all versus landed somewhere unexpected, short of corner-clamping (4
+ // fixed points) or hover feedback (binary, only over a reactive widget).
+ // Every earlier version of this line ran at `warn`, unconditionally on --
+ // see `docs/TODO.md`'s matching cleanup entry for why that was too loud
+ // for daily use, not for why the telemetry itself was ever the problem.
+ log::trace!("pointer motion pos={:?} hit={hit:?}", (pos.x, pos.y));
+ // Titlebar button hover highlighting (explicitly requested, see
+ // docs/TODO.md) - `Drag`/`Resize` aren't buttons, so only the three
+ // real ones count. Compared against the previous value rather than
+ // set unconditionally so an unchanged hover (the overwhelmingly common
+ // case: most motion events land on the same button, or on none at all)
+ // doesn't force a redraw every single pointer-motion event.
+ let new_hover = hit.and_then(|(id, h)| matches!(h, srdwm_core::TitlebarHit::Close | srdwm_core::TitlebarHit::Minimize | srdwm_core::TitlebarHit::Maximize).then_some((id, h)));
+ // Compared as just `(id, hit)`, ignoring the `Instant` already stored
+ // - the field itself carries a timestamp, but "is this the same hover
+ // as before" must not depend on it, or every motion event within the
+ // same button would read as a *new* hover and keep resetting the
+ // glyph-reveal animation's own start time back to zero.
+ let currently_hovering = state.hovered_titlebar_button.map(|(id, h, _)| (id, h));
+ if new_hover != currently_hovering {
+ let old = state.hovered_titlebar_button.take();
+ state.hovered_titlebar_button = new_hover.map(|(id, h)| (id, h, std::time::Instant::now()));
+ // Both windows need a fresh signature check: the newly-hovered one
+ // (to actually draw the highlight) and the previously-hovered one,
+ // if it's a *different* window, to clear its own highlight again.
+ if let Some((id, _, _)) = old {
+ state.redraw_decoration_buffer(id);
+ }
+ if let Some((id, _)) = new_hover {
+ state.redraw_decoration_buffer(id);
+ }
+ }
+ let Some(pointer) = state.seat.get_pointer() else { return };
+ // `PointerHandle::motion`/`button`/`axis` only queue the event with the
+ // active grab - nothing sends `wl_pointer.frame` on its own (confirmed
+ // reading smithay's `DefaultGrab`: its `motion`/`button` impls call
+ // straight through to the handle and never call `frame`). `frame` is
+ // what tells a client "the events since the last frame are one atomic
+ // update, process them now" - required by the protocol since
+ // `wl_pointer` version 5, and this compositor advertises v9. Without
+ // it, any client that correctly waits for `frame` before acting on
+ // motion/button state (most modern toolkits, confirmed live: neither
+ // Firefox nor wezterm registered a click or a drag-selection, in both
+ // cases with the cursor sitting squarely on the target) never actually
+ // processes what it was sent, even though every event up to this point
+ // was individually correct. This is likely the real root cause behind
+ // this whole session's "clicking/scrolling doesn't work" reports --
+ // every fix so far (subsurface routing, decoration geometry, app_id)
+ // was real and necessary, but none of them could have mattered if the
+ // client was never told to look at what it received.
+ pointer.frame(state);
+
+ update_cursor_shape(state, hit, over_layer_surface, over_content);
+
+ let mut wm = state.wm.borrow_mut();
+ let dragging_or_resizing = wm.is_dragging() || wm.is_resizing();
+ if wm.is_dragging() {
+ wm.update_drag(pos.x as i32, pos.y as i32);
+ } else if wm.is_resizing() {
+ wm.update_resize(pos.x as i32, pos.y as i32);
+ }
+ let focused = wm.focused_id();
+ // `general.focus_follows_mouse`: hovering a *different* window focuses
+ // it, no click needed - classic X11 sloppy focus. Gated on `hit`/
+ // `under` actually landing on a window (not a layer surface or bare
+ // desktop) and on not already being mid-drag/resize, where the pointer
+ // sweeps over unrelated windows constantly and none of that should
+ // steal focus from whatever's actually being dragged. `hovered_id !=
+ // focused` both skips redundant work on every one of the many motion
+ // events a stationary pointer over an already-focused window still
+ // generates, and is what makes `auto_raise` (below) only fire on an
+ // actual focus change rather than every motion tick too.
+ let focus_follow_target =
+ (wm.focus_follows_mouse && !dragging_or_resizing && !over_layer_surface).then_some(hovered_id).flatten().filter(|id| Some(*id) != focused);
+ if let Some(id) = focus_follow_target {
+ if wm.auto_raise {
+ // `raise_window` alone here, not `focus_window` - the actual
+ // core + real Wayland/X11 keyboard focus change happens once,
+ // below, through the same `focus_window` free function every
+ // click-driven focus change already goes through (sets real
+ // keyboard focus too, which `WindowManager::focus_window`
+ // alone does not).
+ wm.raise_window(id);
+ }
+ }
+ drop(wm);
+ if let Some(id) = focus_follow_target {
+ focus_window(state, id);
+ }
+ if dragging_or_resizing {
+ if let Some(id) = focused {
+ state.sync_geometry(id);
+ }
+ }
+}
+
+/// Sets the pointer to a resize-direction shape while hovering (or
+/// actively dragging) one of our own decoration's resize edges, and back
+/// to the default arrow when leaving our decoration for anything else.
+///
+/// Only ever *forces* `cursor_status` for our own decoration - never while
+/// `layer_hit`/client content has focus, since a client surface drives its
+/// own cursor via `wl_pointer.set_cursor` once it starts receiving
+/// `pointer.motion()`/`enter` (already sent above, by the time this runs),
+/// and stomping on that here would fight the client for control of its own
+/// cursor rather than just leaving it alone.
+///
+/// Without this, `cursor_status` was only ever set by client requests --
+/// nothing on the compositor's own side ever asked for a resize cursor at
+/// all, so hovering or dragging one of our own decoration's edges never
+/// looked any different from hovering plain content, regardless of what
+/// shapes `cursor.rs` can actually render.
+///
+/// `over_content` distinguishes "over a client surface that will drive its
+/// own cursor" from "over the bare desktop, where nothing ever will" --
+/// without it, dragging off one of our decoration's resize edges straight
+/// onto empty desktop left `cursor_status` stuck on that resize icon
+/// forever: there is no client there to ever call `set_cursor` and reset
+/// it, and this function's own early-return (for the "let the client drive
+/// it" case) doesn't distinguish an *absent* client from a slow one.
+///
+/// `state.decoration_cursor_active` is what makes the "leave it alone"
+/// branch below safe rather than sticky: reported live as "the resize icon
+/// stays on screen long after the pointer is nowhere near an edge." Moving
+/// from a decoration edge onto plain content sets no new `wl_pointer` focus
+/// (an undecorated/CSD window's edge and its content are the same surface,
+/// just different bands of it - see `hit_test`'s `UNDECORATED_TOP_RESIZE_
+/// MARGIN`), so the client never gets an `enter` event to react to, and most
+/// toolkits only re-call `set_cursor` when *their own* idea of which widget
+/// is hovered changes - which it hasn't, from their point of view, since
+/// they were never told the pointer was ever over a resize edge to begin
+/// with. The resize icon we forced while hovering that edge was therefore
+/// never going to be overwritten by anything, ever, without this: the very
+/// first content tick after leaving a decoration/resize hover resets to the
+/// plain arrow *once*, and only if we're the one who last set it - a
+/// client that has since claimed the cursor itself (tracked by `cursor_
+/// image` in `protocols.rs` clearing this same flag) is left alone on every
+/// following tick, so this can't fight a legitimate client cursor that
+/// isn't changing simply because the pointer kept moving.
+fn update_cursor_shape(state: &mut CompState, hit: Option<(WindowId, TitlebarHit)>, over_layer_surface: bool, over_content: bool) {
+ use smithay::input::pointer::{CursorIcon, CursorImageStatus};
+
+ if over_layer_surface {
+ return;
+ }
+ let edge = match hit {
+ Some((_, TitlebarHit::Resize(edge))) => Some(edge),
+ _ => state.wm.borrow().resize_edge(),
+ };
+ // A live "forcing the cursor here has no visible effect" report earlier
+ // this session turned out to be a real *design* gap, not a rendering
+ // bug: hovering a titlebar button used to fall into the same
+ // `CursorIcon::Default` branch as the plain drag area below it --
+ // indistinguishable from "not hovering anything special" (the desktop's
+ // own baseline cursor is also `Default`), so there was never any visible
+ // change to notice in the first place. `Pointer` (a real hand/finger
+ // cursor - see `cursor.rs`'s own `pointer_bitmap`) is what every
+ // mainstream desktop shows over a clickable titlebar button instead.
+ let icon = match edge {
+ Some(edge) => resize_cursor_icon(edge),
+ // A real button (Close/Maximize/Minimize), not the plain drag
+ // area - a hand cursor, matching every mainstream desktop's own
+ // convention for a clickable titlebar control.
+ None if matches!(hit, Some((_, TitlebarHit::Close | TitlebarHit::Maximize | TitlebarHit::Minimize))) => CursorIcon::Pointer,
+ // Hovering our own decoration but not an edge or a button (the
+ // drag area) and not actively resizing: back to the plain arrow.
+ None if hit.is_some() => CursorIcon::Default,
+ // Over a client's own content: leave `cursor_status` alone once the
+ // client has claimed it - but if we're still showing whatever we
+ // last forced (a resize icon from the edge just left), reset it
+ // back to the plain arrow this one time rather than leaving it
+ // stuck, since nothing else is ever going to.
+ None if over_content => {
+ if state.decoration_cursor_active {
+ state.cursor_status = CursorImageStatus::Named(CursorIcon::Default);
+ state.decoration_cursor_active = false;
+ }
+ return;
+ }
+ // Bare desktop: nothing else will ever reset this, so we have to.
+ None => CursorIcon::Default,
+ };
+ state.cursor_status = CursorImageStatus::Named(icon);
+ state.decoration_cursor_active = true;
+}
+
+fn resize_cursor_icon(edge: srdwm_core::ResizeEdge) -> smithay::input::pointer::CursorIcon {
+ use smithay::input::pointer::CursorIcon;
+ use srdwm_core::ResizeEdge;
+ match edge {
+ ResizeEdge::Left | ResizeEdge::Right => CursorIcon::EwResize,
+ ResizeEdge::Top | ResizeEdge::Bottom => CursorIcon::NsResize,
+ ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorIcon::NwseResize,
+ ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorIcon::NeswResize,
+ }
+}
+
+pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logical>, button: u32, pressed: bool, time: u32) {
+ notify_idle_activity(state);
+ const BTN_LEFT: u32 = 0x110;
+ const BTN_RIGHT: u32 = 0x111;
+ const BTN_MIDDLE: u32 = 0x112;
+ let serial = SERIAL_COUNTER.next_serial();
+
+ // Locked: forward the click to the lock surface (it may have a button or
+ // a text field) but never let it focus, raise, drag, or close a window.
+ if state.lock.locked {
+ if let Some(pointer) = state.seat.get_pointer() {
+ let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released };
+ pointer.button(state, &ButtonEvent { serial, time, button, state: button_state });
+ pointer.frame(state);
+ }
+ return;
+ }
+
+ // The context menu, if open, captures every press: a click inside
+ // resolves whichever row it landed on, a click anywhere else just
+ // dismisses it. Neither case falls through to the normal handling
+ // below - opening the menu and then clicking a window underneath it
+ // should not *also* focus/raise/drag that window on the same click,
+ // the same "one click, one action" rule every native window menu
+ // follows.
+ if pressed {
+ if let Some(menu) = state.context_menu.take() {
+ if let Some(row) = menu.row_at(pos.x as i32, pos.y as i32) {
+ let (_, action) = menu.items[row];
+ state.close_context_menu();
+ state.run_context_menu_action(menu.window, action);
+ } else {
+ state.close_context_menu();
+ }
+ return;
+ }
+ // Same "one click, one action" rule as the context menu above --
+ // a click inside the Snap-Layouts flyout applies that zone, a click
+ // anywhere else just dismisses it.
+ if let Some(flyout) = state.snap_flyout.take() {
+ if let Some(zone) = flyout.zone_at(pos.x as i32, pos.y as i32) {
+ state.close_snap_flyout();
+ state.run_snap_flyout_action(flyout.window, zone);
+ } else {
+ state.close_snap_flyout();
+ }
+ return;
+ }
+ }
+
+ // Modifier+drag: with the modifier held, dragging *anywhere* in a window
+ // moves it (left button) or resizes it from the nearest corner (right
+ // button) - the `bindm SUPER, mouse:272/273` gesture. Without this a
+ // window can only be moved by its titlebar, which is useless for
+ // windows that have none (fullscreen, CSD apps, layer surfaces).
+ //
+ // Checked before the titlebar hit-test so the modifier wins over the
+ // decoration: holding the modifier and grabbing the titlebar should
+ // still move, not press a titlebar button.
+ if pressed && (button == BTN_LEFT || button == BTN_RIGHT) {
+ let mods = state.seat.get_keyboard().map(|k| core_modifiers_from_xkb(&k.modifier_state()));
+ if mods.is_some_and(|m| m.contains(DRAG_MODIFIER)) {
+ let target = state.wm.borrow().window_at(pos.x as i32, pos.y as i32);
+ if let Some(id) = target {
+ focus_window(state, id);
+ let mut wm = state.wm.borrow_mut();
+ if button == BTN_LEFT {
+ wm.start_drag(id, pos.x as i32, pos.y as i32);
+ } else {
+ let edge = wm.nearest_corner(id, pos.x as i32, pos.y as i32);
+ wm.start_resize(id, edge, pos.x as i32, pos.y as i32);
+ }
+ return;
+ }
+ }
+ }
+
+ if pressed && button == BTN_LEFT {
+ let layer_hit = layer_surface_under(state, pos);
+ if let Some((surface, _)) = &layer_hit {
+ // Look the surface up on whichever output actually holds it.
+ let on_demand = state
+ .outputs()
+ .find_map(|output| {
+ layer_map_for_output(output)
+ .layer_for_surface(surface, WindowSurfaceType::ALL)
+ .map(|l| {
+ l.can_receive_keyboard_focus()
+ && l.cached_state().keyboard_interactivity != KeyboardInteractivity::Exclusive
+ })
+ })
+ .unwrap_or(false);
+ // `Exclusive` layers (lock screens, exclusive launchers) already
+ // hold focus from `ensure_layer_initial_configure` and keep it
+ // regardless of where else is clicked; only `OnDemand` layers
+ // (e.g. a bar's search field) claim it on click.
+ if on_demand {
+ state.set_keyboard_focus(Some(surface.clone()));
+ }
+ }
+ let hit = if layer_hit.is_some() { None } else { hit_test_animated(state, pos.x as i32, pos.y as i32) };
+ if let Some((id, hit)) = hit {
+ focus_window(state, id);
+ match hit {
+ TitlebarHit::Drag => {
+ // Double-click the titlebar to maximise, as every other
+ // desktop does - one of the few window operations that
+ // otherwise needs the keyboard or a precise button hit.
+ if state.is_double_click(id, time) {
+ state.wm.borrow_mut().toggle_maximize(id);
+ state.sync_geometry(id);
+ crate::foreign_toplevel::send_state(state, id);
+ } else {
+ state.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32)
+ }
+ }
+ TitlebarHit::Close => {
+ if let Some(w) = state.id_to_window.get(&id) {
+ close_dwindow(w);
+ }
+ }
+ TitlebarHit::Maximize => {
+ state.wm.borrow_mut().toggle_maximize(id);
+ state.sync_geometry(id);
+ crate::foreign_toplevel::send_state(state, id);
+ }
+ TitlebarHit::Minimize => {
+ state.wm.borrow_mut().minimize_window(id);
+ crate::foreign_toplevel::send_state(state, id);
+ }
+ TitlebarHit::Resize(edge) => state.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32),
+ }
+ } else if layer_hit.is_none() {
+ if let Some((window, _loc)) = state.space.element_under(pos).filter(|(w, _)| dwindow_is_visible(state, w)) {
+ let window = window.clone();
+ // `focus_window` itself raises both `Space` and pinned
+ // windows now - see its own doc comment. No longer done
+ // manually here first.
+ if let Some(&id) = dwindow_wl_surface(&window).and_then(|s| state.surface_to_id.get(&s)) {
+ focus_window(state, id);
+ }
+ }
+ }
+ } else if pressed && (button == BTN_RIGHT || button == BTN_MIDDLE) {
+ // Right-click a titlebar: open the window menu (minimize/maximize/
+ // pin/close) - previously nothing at all, since the only
+ // right-button behaviour anywhere was the SUPER+right-drag resize
+ // gesture above, which needs the modifier held. Middle-click:
+ // lower the window instead, the convention several X11 WMs
+ // (twm, fvwm, IceWM) have always had. Both only fire on the
+ // titlebar's plain drag area - a resize edge or one of the three
+ // buttons keeps its own single meaning regardless of which button
+ // was pressed, so a right-click on the close button, say, doesn't
+ // do something else entirely.
+ let hit = hit_test_animated(state, pos.x as i32, pos.y as i32);
+ match (button, hit) {
+ (BTN_RIGHT, Some((id, TitlebarHit::Drag))) => state.open_context_menu(id, (pos.x as i32, pos.y as i32)),
+ (BTN_MIDDLE, Some((id, TitlebarHit::Drag))) => state.wm.borrow_mut().lower_window(id),
+ // Right-click the maximize button itself: the Snap-Layouts
+ // flyout (pick a half/quarter position for this window)
+ // instead of the window menu - a plain left-click there still
+ // just toggles maximize, unchanged.
+ (BTN_RIGHT, Some((id, TitlebarHit::Maximize))) => state.open_snap_flyout(id, (pos.x as i32, pos.y as i32)),
+ _ => {}
+ }
+ } else if !pressed {
+ let mut wm = state.wm.borrow_mut();
+ let was_dragging = wm.is_dragging();
+ let was_resizing = wm.is_resizing();
+ // `start_drag`/`start_resize` both focus the window they grab, and
+ // nothing else can change focus while a grab is active (the pointer
+ // is captured by the drag, not routed elsewhere) - so `focused_id`
+ // is reliably the window `end_drag`/`end_resize` are about to
+ // finish, without `WindowManager` needing to hand the id back
+ // itself.
+ let id = wm.focused_id();
+ if was_dragging {
+ wm.end_drag();
+ } else if was_resizing {
+ wm.end_resize();
+ }
+ drop(wm);
+ // `end_drag` can snap the geometry one more time (edge/top-of-
+ // screen snapping, `SmartPlacement::snap_zone`) *after* the last
+ // `update_drag` already moved the window - without this, that
+ // final snap only ever reached `Window.geometry`. The border and
+ // titlebar redraw fresh from live geometry every frame, so they'd
+ // jump to the snapped rect immediately, while the client's actual
+ // mapped surface (driven only by `sync_geometry`'s
+ // `space.map_element`/`xdg_toplevel.configure`) stayed wherever the
+ // drag physically stopped - decoration visibly detached from its
+ // own window's content. Click routing desynced the same way:
+ // `hit_test`/`window_at` read the now-snapped `Window.geometry`
+ // while `space.element_under` still read the stale pre-snap
+ // position, so clicks in the visually-snapped zone resolved
+ // against the wrong rect. The X11 backend already gets this right
+ // (`crates/x11/src/lib.rs`'s `ButtonRelease` handler); this was the
+ // one call site in the module doc'd as "shared by both backends"
+ // that never got the same fix.
+ if was_dragging || was_resizing {
+ if let Some(id) = id {
+ state.sync_geometry(id);
+ }
+ }
+ }
+
+ // Re-assert real Wayland pointer focus at `pos` immediately before the
+ // actual click - see `refresh_pointer_focus`'s own doc comment for why
+ // this can't just trust whatever the last motion event left focus at.
+ // A no-op from the client's perspective when focus was already correct
+ // (an idempotent motion event at the same surface-local coordinates it
+ // already has), so this costs nothing in the common case.
+ //
+ // Also where `pointer_button_grab` starts and ends - see its own doc
+ // comment. Only the 0->1 transition captures a new grab target (a
+ // second button going down mid-gesture keeps whatever the first press
+ // already locked in); only the ->0 transition releases it, and not
+ // before this press/release's own `pointer.button()` below still goes
+ // out under the (still-active) grab.
+ let (.., resolved) = refresh_pointer_focus(state, pos, time);
+ if pressed {
+ if state.pointer_buttons_held == 0 {
+ state.pointer_button_grab = resolved;
+ }
+ state.pointer_buttons_held += 1;
+ } else {
+ state.pointer_buttons_held = state.pointer_buttons_held.saturating_sub(1);
+ }
+ if let Some(pointer) = state.seat.get_pointer() {
+ let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released };
+ pointer.button(state, &ButtonEvent { serial, time, button, state: button_state });
+ // See the matching comment in `handle_pointer_position`: `button`
+ // alone never tells the client the event is ready to act on, only
+ // `frame` does.
+ pointer.frame(state);
+ }
+ if !pressed && state.pointer_buttons_held == 0 {
+ state.pointer_button_grab = None;
+ }
+}