srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/input.rs
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.rs
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.rs')
-rw-r--r--crates/wayland/src/input.rs1012
1 files changed, 29 insertions, 983 deletions
diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs
index 5cce9a4..9d31872 100644
--- a/crates/wayland/src/input.rs
+++ b/crates/wayland/src/input.rs
@@ -9,153 +9,46 @@
//! Every function that routes an event checks the session lock first: while
//! locked, input goes to the lock surface and nowhere else. See
//! [`crate::lock`].
+//!
+//! Split by concern, one file per input-event kind, matching niri's own
+//! per-grab-kind module boundaries: [`layers`] (layer-shell hit-testing and
+//! the layer-driven maximize geometry), [`focus`] (focusing/raising/closing
+//! a window - needed by every other kind below regardless of what
+//! triggered the change), [`pointer`] (motion, button, cursor shape),
+//! [`keyboard`] (key events and keysym/modifier translation), [`gestures`]
+//! (workspace scroll and touchpad swipe). `notify_idle_activity` and
+//! [`DRAG_MODIFIER`] stay here, at the root, since every one of those
+//! modules needs at least one of them.
+
+mod focus;
+mod gestures;
+mod keyboard;
+mod layers;
+mod pointer;
-use smithay::backend::input::{ButtonState as BackendButtonState, KeyState as BackendKeyState, KeyboardKeyEvent};
-use smithay::backend::session::Session as _;
-use smithay::desktop::{layer_map_for_output, Window as DWindow, WindowSurfaceType};
-use smithay::input::keyboard::FilterResult;
-use smithay::input::pointer::{ButtonEvent, MotionEvent};
-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, SERIAL_COUNTER};
-use smithay::wayland::compositor::with_states;
-use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone, KeyboardInteractivity, Layer, LayerSurfaceCachedState};
use std::time::{Duration, Instant};
-use srdwm_core::{Event as CoreEvent, Modifiers, TitlebarHit, WindowId};
+use smithay::utils::{Logical, Point};
+
+use srdwm_core::Modifiers;
+
+use crate::state::CompState;
+
+pub(crate) use focus::{close_dwindow, dwindow_wl_surface, focus_window, raise_in_space, sync_keyboard_focus};
+pub(crate) use gestures::{handle_gesture_swipe_begin, handle_gesture_swipe_end, handle_gesture_swipe_update, handle_workspace_scroll};
+pub(crate) use keyboard::handle_keyboard_key_event;
+pub(crate) use layers::maximize_geometry_for;
+pub(crate) use pointer::{handle_pointer_button, handle_pointer_position};
/// Modifier that turns a drag anywhere in a window into move/resize.
/// Matches the `SUPER` the shipped and ported configs use for
/// `bindm ... movewindow` / `resizewindow`.
-const DRAG_MODIFIER: Modifiers = Modifiers::SUPER;
-
-use crate::state::CompState;
+pub(super) const DRAG_MODIFIER: Modifiers = Modifiers::SUPER;
pub(crate) fn last_pointer_pos(state: &CompState) -> Point<f64, Logical> {
state.seat.get_pointer().map(|p| p.current_location()).unwrap_or_default()
}
-/// 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(crate) 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(crate) 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(crate) 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 a bottom-anchored dock while
-/// still respecting a top-anchored bar.
-pub(crate) fn maximize_geometry_for(output: &Output, full: srdwm_core::Rect) -> srdwm_core::Rect {
- let mut rect = full;
- 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 };
- if data.anchor.contains(Anchor::TOP) && !data.anchor.contains(Anchor::BOTTOM) {
- let shrink = (amount as i32 + data.margin.top).max(0);
- rect.y += shrink;
- rect.height = rect.height.saturating_sub(shrink as u32);
- }
- }
- rect
-}
-
/// `ext_idle_notify_v1`'s whole job is answering "has the user touched an
/// input device recently" - `IdleNotifierState` does the actual timer
/// bookkeeping (see its own doc comment), this just has to be called from
@@ -176,7 +69,7 @@ pub(crate) fn maximize_geometry_for(output: &Output, full: srdwm_core::Rect) ->
/// (see `docs/IMPLEMENTATION_STATUS.md`), just cheap enough here (in-memory
/// bookkeeping, not synchronous I/O) that throttling rather than removing
/// it outright is the right amount of caution.
-fn notify_idle_activity(state: &mut CompState) {
+pub(super) fn notify_idle_activity(state: &mut CompState) {
const THROTTLE: Duration = Duration::from_millis(250);
let now = Instant::now();
if state.last_idle_notify.is_some_and(|last| now.duration_since(last) < THROTTLE) {
@@ -186,850 +79,3 @@ fn notify_idle_activity(state: &mut CompState) {
let seat = state.seat.clone();
state.idle_notifier_state.notify_activity(&seat);
}
-
-/// 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 = state.wm.borrow().hit_test(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.
- 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()))
- };
- let delivery = state.pointer_button_grab.clone().or_else(|| resolved.clone());
- if let Some((surface, origin)) = delivery {
- let surface_loc = pos - origin;
- pointer.motion(state, Some((surface, origin)), &MotionEvent { location: surface_loc, 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;
- }
-
- let (hit, over_layer_surface, over_content, hovered_id, _) = refresh_pointer_focus(state, pos, time);
- 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 touches `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.
-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(),
- };
- let icon = match edge {
- Some(edge) => resize_cursor_icon(edge),
- // Hovering our own decoration but not an edge (the drag area, a
- // button) 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, per the
- // doc comment above - the client drives it.
- None if over_content => return,
- // Bare desktop: nothing else will ever reset this, so we have to.
- None => CursorIcon::Default,
- };
- state.cursor_status = CursorImageStatus::Named(icon);
-}
-
-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,
- }
-}
-
-/// 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.
-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.
- if let Some(w) = state.id_to_window.get(&id).cloned() {
- state.space.raise_element(&w, true);
- state.raise_pinned();
- }
- 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);
-}
-
-/// 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);
-}
-
-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 { state.wm.borrow().hit_test(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 = state.wm.borrow().hit_test(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;
- }
-}
-
-/// 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) {
- 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> {
- srdwm_core::keysyms::keysym_to_name(handle.modified_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
-}
-
-/// 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);
-}