refactor: split every monolithic module into focused submodules

Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves.
image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
2026-05-15 23:46:56 +02:00
parent 3d237039c6
commit 4aa3480b64
155 changed files with 13832 additions and 13035 deletions

View File

@@ -0,0 +1,28 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
/// Top-left of `focus` in main-surface (global) coordinates. Used to
/// translate per-surface pointer coords into a single coordinate
/// space the app can reason about during drag-and-drop across
/// surfaces.
pub( crate ) fn surface_offset_for( &self, focus: SurfaceFocus ) -> ( f32, f32 )
{
let SurfaceFocus::Overlay( id ) = focus else { return ( 0.0, 0.0 ); };
let Some( ss ) = self.overlays.get( &id ) else { return ( 0.0, 0.0 ); };
let Some( anchor ) = ss.layer_anchor else { return ( 0.0, 0.0 ); };
let ( sw, sh ) = ( self.main.width as f32, self.main.height as f32 );
let ( w, h ) = ( ss.width as f32, ss.height as f32 );
let x = if anchor.left { 0.0 }
else if anchor.right { sw - w }
else { ( sw - w ) / 2.0 };
let y = if anchor.top { 0.0 }
else if anchor.bottom { sh - h }
else { ( sh - h ) / 2.0 };
( x, y )
}
}

20
src/input/dispatch/mod.rs Normal file
View File

@@ -0,0 +1,20 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Dispatch helpers shared by the pointer and touch handlers.
//!
//! The gesture state machine returns abstract outcomes; this module is
//! where those outcomes turn into concrete side-effects on `AppData`:
//! pending-message pushes, app-level callbacks (`on_swipe_*`,
//! `on_drag_move`, `on_drop`, `on_tap`, `overlay_dismiss_msg`), and
//! the redraw / cache invalidation flags that keep the loop moving
//! after a non-Message-producing gesture.
//!
//! Splitting these into a dedicated module means pointer.rs and touch.rs
//! only carry the Wayland-event translation. A future input source
//! (stylus, gamepad) can reuse the same dispatcher by feeding the
//! state machine the same way.
pub( super ) mod outcomes;
pub( super ) mod password_toggle;
pub( super ) mod coords;

View File

@@ -1,93 +1,16 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Dispatch helpers shared by the pointer and touch handlers.
//!
//! The gesture state machine returns abstract outcomes; this file is
//! where those outcomes turn into concrete side-effects on `AppData`:
//! pending-message pushes, app-level callbacks (`on_swipe_*`,
//! `on_drag_move`, `on_drop`, `on_tap`, `overlay_dismiss_msg`), and
//! the redraw / cache invalidation flags that keep the loop moving
//! after a non-Message-producing gesture.
//!
//! Splitting these into a dedicated file means pointer.rs and touch.rs
//! only carry the Wayland-event translation. A future input source
//! (stylus, gamepad) can reuse the same dispatcher by feeding the
//! state machine the same way.
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::types::Point;
use crate::widget::WidgetHandlers;
use super::gesture::{ MoveOutcome, ReleaseEvent };
use crate::input::gesture::{ MoveOutcome, ReleaseEvent };
impl<A: App> AppData<A>
{
/// If the press at `pos` lands on a password-toggle icon zone of
/// the widget at `idx`, push the toggle message and return
/// `true` so the caller can skip the rest of the cursor /
/// selection placement that would otherwise consume the press.
/// Returns `false` when there is no toggle on that widget or
/// the press fell outside the icon's hit area, leaving the
/// caller to dispatch the press normally.
pub( super ) fn handle_password_toggle_press
(
&mut self,
focus: SurfaceFocus,
idx: usize,
pos: Point,
) -> bool
{
let toggle_msg = self.surface( focus ).widget_rects.iter()
.find( |w| w.flat_idx == idx )
.and_then( |w| match &w.handlers
{
WidgetHandlers::TextEdit { password_toggle_msg: Some( msg ), .. } =>
{
let zone = crate::widget::text_edit::password_toggle_hit_zone( w.rect );
if zone.contains( pos ) { Some( msg.clone() ) } else { None }
}
_ => None,
} );
if let Some( msg ) = toggle_msg
{
self.pending_msgs.push( msg );
self.surface_mut( focus ).request_redraw();
true
}
else
{
false
}
}
}
impl<A: App> AppData<A>
{
/// Top-left of `focus` in main-surface (global) coordinates. Used to
/// translate per-surface pointer coords into a single coordinate
/// space the app can reason about during drag-and-drop across
/// surfaces.
pub( crate ) fn surface_offset_for( &self, focus: SurfaceFocus ) -> ( f32, f32 )
{
let SurfaceFocus::Overlay( id ) = focus else { return ( 0.0, 0.0 ); };
let Some( ss ) = self.overlays.get( &id ) else { return ( 0.0, 0.0 ); };
let Some( anchor ) = ss.layer_anchor else { return ( 0.0, 0.0 ); };
let ( sw, sh ) = ( self.main.width as f32, self.main.height as f32 );
let ( w, h ) = ( ss.width as f32, ss.height as f32 );
let x = if anchor.left { 0.0 }
else if anchor.right { sw - w }
else { ( sw - w ) / 2.0 };
let y = if anchor.top { 0.0 }
else if anchor.bottom { sh - h }
else { ( sh - h ) / 2.0 };
( x, y )
}
/// Apply the outcome of a motion event. Non-blocking side-effects
/// only: push messages, call app callbacks, set redraw flags.
pub( super ) fn apply_move_outcome
pub( crate ) fn apply_move_outcome
(
&mut self,
focus: SurfaceFocus,
@@ -142,7 +65,7 @@ impl<A: App> AppData<A>
/// release can emit more than one event (e.g. horizontal
/// fall-through followed by a vertical commit or fall-through), so
/// we walk the list and run each event's side-effects in order.
pub( super ) fn apply_release_events
pub( crate ) fn apply_release_events
(
&mut self,
focus: SurfaceFocus,

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::types::Point;
use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
/// If the press at `pos` lands on a password-toggle icon zone of
/// the widget at `idx`, push the toggle message and return
/// `true` so the caller can skip the rest of the cursor /
/// selection placement that would otherwise consume the press.
/// Returns `false` when there is no toggle on that widget or
/// the press fell outside the icon's hit area, leaving the
/// caller to dispatch the press normally.
pub( crate ) fn handle_password_toggle_press
(
&mut self,
focus: SurfaceFocus,
idx: usize,
pos: Point,
) -> bool
{
let toggle_msg = self.surface( focus ).widget_rects.iter()
.find( |w| w.flat_idx == idx )
.and_then( |w| match &w.handlers
{
WidgetHandlers::TextEdit { password_toggle_msg: Some( msg ), .. } =>
{
let zone = crate::widget::text_edit::password_toggle_hit_zone( w.rect );
if zone.contains( pos ) { Some( msg.clone() ) } else { None }
}
_ => None,
} );
if let Some( msg ) = toggle_msg
{
self.pending_msgs.push( msg );
self.surface_mut( focus ).request_redraw();
true
}
else
{
false
}
}
}

File diff suppressed because it is too large Load Diff

524
src/input/gesture/mod.rs Normal file
View File

@@ -0,0 +1,524 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Per-surface gesture state machine. Source-agnostic — both
//! `PointerHandler` (Wayland `wl_pointer`) and `TouchHandler`
//! (`wl_touch`) feed into the same machine and apply the decisions it
//! emits.
//!
//! ## Lifecycle
//!
//! 1. [`GestureState::on_press`] — records the gesture origin, snapshots
//! the hit widget's long-press AND drag-start handlers, identifies
//! slider / scroll targets. Returns a [`PressOutcome`] with the hit
//! index and any initial slider message.
//! 2. [`GestureState::on_move`] — cancels the long-press candidate if
//! the finger strays past 6 px, updates slider values, mutates
//! scroll offsets, computes swipe progress. Returns a
//! [`MoveOutcome`] telling the handler which app callbacks to fire.
//! 3. [`GestureState::on_release`] — decides between drop (long-press
//! was already fired), slider / scroll done, horizontal / vertical
//! swipe commit or fall-through, tap or button press. Returns an
//! ordered [`Vec<ReleaseEvent>`] — a single release can emit more
//! than one event when a horizontal swipe falls through and the
//! handler still has to check the vertical branch.
//! 4. [`GestureState::on_cancel`] — resets everything (touch-cancel
//! only; the compositor is stealing the gesture).
//!
//! The long-press DEADLINE itself is not polled here. `AppData`
//! walks every surface's `gesture.long_press_start` against the app's
//! `long_press_duration()` and flips `gesture.long_press_fired` when
//! the deadline elapses — this module only records the start instant
//! and reacts to the flip.
use std::collections::HashMap;
use std::time::Instant;
use crate::tree::{ find_handlers, find_widget, find_widget_at };
use crate::types::{ Point, Rect };
use crate::widget::LaidOutWidget;
mod outcomes;
pub( crate ) use outcomes::*;
#[ cfg( test ) ]
mod tests;
// ─── State ───────────────────────────────────────────────────────────────────
/// Per-surface gesture tracking. Lives on `SurfaceState::gesture`.
///
/// Every field is `pub` because `AppData`'s long-press deadline
/// checker in `event_loop/app_data.rs` needs to read the start instant
/// and set `long_press_fired` from outside the state machine. The
/// lifecycle methods ([`Self::on_press`], [`Self::on_move`],
/// [`Self::on_release`], [`Self::on_cancel`]) are the supported way
/// for input handlers to drive the machine; direct mutation is only
/// for the deadline path.
#[ derive( Debug, Clone ) ]
pub struct GestureState<Msg: Clone>
{
/// Position where the current press / touch-down landed. `None`
/// between gestures.
pub start: Option<Point>,
/// Widget index under the press, if any. Set on press; taken on
/// release.
pub pressed_idx: Option<usize>,
/// Index of the Scroll viewport that owns the current gesture, if
/// the press landed inside one.
pub scrolling_widget: Option<usize>,
/// Scroll-viewport drag exceeded the 8 px start tolerance — the
/// release will be consumed as a scroll instead of a tap.
pub scroll_drag_started: bool,
/// Horizontal drag exceeded the 8 px threshold. Release runs the
/// horizontal-swipe commit check instead of the button path.
pub horizontal_drag_started: bool,
/// Vertical drag exceeded the 8 px threshold. Release runs the
/// swipe-up / swipe-down commit check instead of the button path.
pub vertical_drag_started: bool,
/// Slider widget being dragged for a live-value update.
pub dragging_slider: Option<usize>,
/// Instant when the long-press window opened. `None` if the hit
/// widget has no long-press handler (or the candidate was
/// cancelled by movement).
pub long_press_start: Option<Instant>,
/// Original press position for the long-press candidate — used by
/// [`Self::on_move`] to cancel if the finger strays more than 6 px.
pub long_press_origin: Option<Point>,
/// Message to fire when the deadline elapses (touch hold) or when
/// the user right-clicks. Drained by the deadline checker on touch
/// and by the right-click handler on mouse. Firing this on its own
/// does NOT put the gesture into drag mode — that is governed by
/// [`Self::drag_start_msg`].
pub long_press_msg: Option<Msg>,
/// Drag-arm message captured from the hit widget on press. Fired
/// by the touch deadline alongside `long_press_msg` and by the
/// mouse-motion drag-promotion path on its own. When fired, the
/// gesture transitions into drag mode (`long_press_fired = true`)
/// and subsequent motion / release run through `on_drag_move` /
/// `on_drop`.
pub drag_start_msg: Option<Msg>,
/// `widget_rects` index of a TextEdit the press landed on, when
/// no user `on_long_press` is configured. Lets the runtime show
/// the built-in Copy / Cut / Paste menu after the same delay
/// without forcing every text field to opt in.
pub long_press_text_idx: Option<usize>,
/// `true` once the gesture has transitioned into drag mode —
/// subsequent motion fires `app.on_drag_move`, release fires
/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
/// is present, or by the mouse-motion promotion path.
pub long_press_fired: bool,
/// `true` when the press came from a mouse (`wl_pointer`) rather
/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
/// on this: touch needs the cancel so a hold that turns into a
/// swipe / scroll doesn't keep the long-press candidate armed,
/// but mouse motion is always intentional and never competes with
/// a swipe gesture, so the candidate stays alive until the
/// pointer-side promotion threshold or the deadline fires. Set by
/// the pointer Press handler right after [`Self::on_press`]; left
/// `false` for touch presses.
pub mouse_press: bool,
}
impl<Msg: Clone> Default for GestureState<Msg>
{
fn default() -> Self { Self::new() }
}
impl<Msg: Clone> GestureState<Msg>
{
/// Fresh state — no press in flight.
pub fn new() -> Self
{
Self
{
start: None,
pressed_idx: None,
scrolling_widget: None,
scroll_drag_started: false,
horizontal_drag_started: false,
vertical_drag_started: false,
dragging_slider: None,
long_press_start: None,
long_press_origin: None,
long_press_msg: None,
drag_start_msg: None,
long_press_text_idx: None,
long_press_fired: false,
mouse_press: false,
}
}
/// Press / touch-down: record the gesture origin, capture long-press
/// handler if the hit widget has one, identify slider / scroll
/// targets. Returns the hit index (for keyboard focus update) and
/// any initial slider message (the slider applies its value
/// immediately on press so the thumb tracks the finger from pixel
/// one).
pub fn on_press
(
&mut self,
pos: Point,
widget_rects: &[LaidOutWidget<Msg>],
scroll_rects: &[( Rect, usize )],
) -> PressOutcome<Msg>
{
let hit = find_widget_at( widget_rects, pos );
let ( lp_msg, ds_msg ) = hit.map( |idx|
{
let h = find_handlers( widget_rects, idx );
(
h.as_ref().and_then( |h| h.long_press_msg() ),
h.as_ref().and_then( |h| h.drag_start_msg() ),
)
}).unwrap_or( ( None, None ) );
self.start = Some( pos );
self.scrolling_widget = scroll_rects.iter().rev()
.find( |( r, _ )| r.contains( pos ) )
.map( |( _, idx )| *idx );
self.scroll_drag_started = false;
self.horizontal_drag_started = false;
self.vertical_drag_started = false;
self.pressed_idx = hit;
// A fresh press resets any stale long-press / source state
// from a prior gesture that never released cleanly. The
// pointer Press handler flips `mouse_press` back to true
// right after this call when the press came from a mouse;
// touch leaves it `false`.
self.long_press_fired = false;
self.mouse_press = false;
// Built-in long-press for text inputs: arms the same timer
// even when the widget has no user-set `on_long_press`, so
// the runtime can show the Copy / Cut / Paste menu after the
// usual delay.
let text_idx = hit.and_then( |idx|
{
find_handlers( widget_rects, idx )
.and_then( |h| if h.is_text_input() { Some( idx ) } else { None } )
} );
// Arm the long-press timer if anything is pending: the menu
// message, the drag-start message, or the built-in text menu.
// A widget that only carries `on_drag_start` (no menu) still
// needs the timer so a touch hold can promote into a drag.
let arm_long_press = lp_msg.is_some() || ds_msg.is_some() || text_idx.is_some();
self.long_press_start = arm_long_press.then( Instant::now );
self.long_press_origin = arm_long_press.then_some( pos );
self.long_press_msg = lp_msg;
self.drag_start_msg = ds_msg;
self.long_press_text_idx = text_idx;
// Slider drag: if the hit widget is a slider, arm the drag and
// apply the press-point value immediately.
let mut initial_slider_msg = None;
if let Some( idx ) = hit
{
if let Some( w ) = find_widget( widget_rects, idx )
{
if w.handlers.is_slider()
{
self.dragging_slider = Some( idx );
let value = w.handlers.slider_value_from_pos( w.rect, pos );
initial_slider_msg = w.handlers.slider_change_msg( value );
}
}
}
PressOutcome { hit_idx: hit, initial_slider_msg }
}
/// Move / touch-motion: classify the motion and mutate scroll
/// offsets in place. `global_drag` is `true` when another surface
/// has an active long-press drag (cross-surface drags route every
/// motion through `on_drag_move` even on surfaces that didn't
/// start the gesture).
pub fn on_move
(
&mut self,
pos: Point,
widget_rects: &[LaidOutWidget<Msg>],
scroll_offsets: &mut HashMap<usize, f32>,
swipe: &SwipeConfig,
global_drag: bool,
) -> MoveOutcome<Msg>
{
// Drag phase: once the long-press has fired, every motion
// goes to the app's drag handler and bypasses slider / scroll /
// swipe logic.
if self.long_press_fired || global_drag
{
return MoveOutcome::Drag { pos };
}
// Cancel the long-press candidate if the finger strayed > 6 px
// — but ONLY for touch. Touch needs this so a hold that turns
// into a swipe / scroll doesn't keep the menu candidate
// armed and fire mid-swipe; mouse motion is always
// intentional and never competes with a swipe, so the
// candidate stays alive until the pointer-side promotion at
// 24 px (or the hold-timer deadline) consumes it. Without
// this gate, mouse motion in the 624 px band would clear
// `drag_start_msg` before promotion could fire and the user
// would have to wait out the full 500 ms hold timer to drag.
if !self.mouse_press
{
if let Some( origin ) = self.long_press_origin
{
let d = ( pos.x - origin.x ).hypot( pos.y - origin.y );
if d > 6.0
{
self.long_press_start = None;
self.long_press_origin = None;
self.long_press_msg = None;
self.drag_start_msg = None;
self.long_press_text_idx = None;
}
}
}
// Slider drag: continuously update value.
if let Some( slider_idx ) = self.dragging_slider
{
if let Some( w ) = find_widget( widget_rects, slider_idx )
{
let value = w.handlers.slider_value_from_pos( w.rect, pos );
let msg = w.handlers.slider_change_msg( value );
return MoveOutcome::Slider { msg };
}
return MoveOutcome::Idle;
}
// Scroll viewport drag: mutate offset in place and advance the
// gesture origin so the next delta is frame-to-frame, not
// press-to-now (otherwise the first 8 px trip the threshold
// and the entire scroll gets absorbed into one delta).
if let Some( scroll_idx ) = self.scrolling_widget
{
if let Some( start ) = self.start
{
let dy = pos.y - start.y;
let entry = scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
*entry = ( *entry - dy ).max( 0.0 );
if dy.abs() > 8.0 { self.scroll_drag_started = true; }
self.start = Some( pos );
return MoveOutcome::Scroll;
}
return MoveOutcome::Idle;
}
// Swipe progress — independent on both axes so a vertical-
// dominant gesture that picks up horizontal drift still drives
// the pager.
if let Some( start ) = self.start
{
let dy = start.y - pos.y;
let dx = pos.x - start.x;
let ( up, down ) = if swipe.surface_height > 0
{
let h = swipe.surface_height as f32;
if dy > 0.0
{
let threshold = h * swipe.up_thresh;
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
( Some( ( dy / threshold ).clamp( 0.0, 1.0 ) ), None )
}
else if start.y <= h * swipe.down_edge
{
let threshold = h * swipe.down_thresh;
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
// Unclamped on purpose — lets follow-the-finger
// panels track past the commit threshold.
( None, Some( ( -dy / threshold ).max( 0.0 ) ) )
}
else { ( None, None ) }
}
else { ( None, None ) };
let horizontal = if swipe.surface_width > 0
{
let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh;
let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };
if dx.abs() > 8.0 { self.horizontal_drag_started = true; }
Some( h_progress )
}
else { None };
if up.is_some() || down.is_some() || horizontal.is_some()
{
return MoveOutcome::Swipe { up, down, horizontal };
}
}
MoveOutcome::Idle
}
/// Release / touch-up: decide between drop / slider done / scroll
/// done / swipe commit / swipe fall-through / button press / tap.
///
/// A release can emit multiple events: a horizontal swipe that
/// didn't commit still fires `on_swipe_horizontal_progress(0.0)`
/// before falling through to the vertical branch, and a below-
/// threshold vertical swipe pulses the vertical progress
/// callbacks. The handler dispatches the events in order.
///
/// `global_drag` mirrors the parameter of [`Self::on_move`].
pub fn on_release
(
&mut self,
pos: Point,
widget_rects: &[LaidOutWidget<Msg>],
swipe: &SwipeConfig,
global_drag: bool,
) -> Vec<ReleaseEvent<Msg>>
{
let pressed = self.pressed_idx.take();
let was_dragging_slider = self.dragging_slider.is_some();
let long_press_fired = self.long_press_fired;
let horizontal_drag_started = self.horizontal_drag_started;
let vertical_drag_started = self.vertical_drag_started;
// Drain all the drag / long-press slots. A release ALWAYS
// closes the long-press window: if the deadline fired we've
// already consumed the msg; if not, the candidate is cancelled.
self.dragging_slider = None;
self.long_press_start = None;
self.long_press_origin = None;
self.long_press_msg = None;
self.drag_start_msg = None;
self.long_press_text_idx = None;
self.long_press_fired = false;
self.mouse_press = false;
self.horizontal_drag_started = false;
self.vertical_drag_started = false;
let mut events = Vec::new();
// Drop — long-press had fired earlier in the gesture.
if long_press_fired || global_drag
{
self.start = None;
events.push( ReleaseEvent::Drop { pos } );
return events;
}
// Slider drag complete — not a swipe, not a tap.
if was_dragging_slider
{
self.scroll_drag_started = false;
self.start = None;
return events;
}
// Scroll gesture consumed.
let scroll_consumed = self.scrolling_widget.take().is_some()
&& self.scroll_drag_started;
self.scroll_drag_started = false;
if scroll_consumed
{
self.start = None;
return events;
}
// Horizontal swipe commit / fall-through. On commit we return
// immediately; on fall-through we send the 0.0 pulse, keep
// `self.start` for the vertical branch, and continue.
if horizontal_drag_started
{
if let Some( start ) = self.start
{
let dx = pos.x - start.x;
let width = swipe.surface_width;
let committed = width > 0
&& dx.abs() >= width as f32 * swipe.horizontal_thresh;
if committed
{
self.start = None;
events.push( if dx < 0.0 { ReleaseEvent::SwipeLeft } else { ReleaseEvent::SwipeRight } );
return events;
}
events.push( ReleaseEvent::HorizontalFellThrough );
}
}
// Vertical swipe commit / fall-through. Consumes `self.start`.
if vertical_drag_started || pressed.is_none()
{
if let Some( start ) = self.start.take()
{
let dy = start.y - pos.y;
let height = swipe.surface_height as f32;
let up_thr = height * swipe.up_thresh;
let down_thr = height * swipe.down_thresh;
let down_edge = height * swipe.down_edge;
if dy >= up_thr
{
events.push( ReleaseEvent::SwipeUp );
return events;
}
else if -dy >= down_thr && start.y <= down_edge
{
events.push( ReleaseEvent::SwipeDown );
return events;
}
else
{
events.push( ReleaseEvent::VerticalFellThrough );
}
}
}
else
{
self.start = None;
}
// Any drag started — skip the button path so a gesture that
// fell through without committing does not also trigger a tap.
if horizontal_drag_started || vertical_drag_started
{
return events;
}
// Button / slider press on release: the release must land on
// the same widget the press did.
let release_hit = find_widget_at( widget_rects, pos );
if let Some( idx ) = pressed
{
if release_hit == Some( idx )
{
if let Some( w ) = find_widget( widget_rects, idx )
{
let value = w.handlers.slider_value_from_pos( w.rect, pos );
if let Some( msg ) = w.handlers.slider_change_msg( value )
{
events.push( ReleaseEvent::PushMsg( msg ) );
return events;
}
// Repeating buttons fire `press_msg` on *press* (and
// keep firing through the calloop repeat timer
// while held) — suppressing the release-time fire
// avoids a double tap on a quick click.
if !w.handlers.is_repeating()
{
if let Some( msg ) = w.handlers.press_msg()
{
events.push( ReleaseEvent::PushMsg( msg ) );
}
}
}
}
}
else if release_hit.is_none()
{
// Release on empty area — handler decides Tap vs
// overlay-dismiss based on focus.
events.push( ReleaseEvent::EmptyRelease );
}
events
}
/// Touch-cancel: drop all in-flight gesture state.
pub fn on_cancel( &mut self ) { *self = Self::new(); }
}

View File

@@ -0,0 +1,84 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Point;
/// Result of [`GestureState::on_press`]. Handler uses `hit_idx` to set
/// keyboard focus; pushes `initial_slider_msg` to the pending queue
/// when present.
pub struct PressOutcome<Msg>
{
pub hit_idx: Option<usize>,
pub initial_slider_msg: Option<Msg>,
}
/// Result of [`GestureState::on_move`]. Handler turns this into app
/// callbacks + redraw flags.
pub enum MoveOutcome<Msg>
{
/// Nothing gesture-level fired. Hovered index may still have
/// changed — pointer handler updates it separately before calling
/// on_move.
Idle,
/// Long-press drag in flight — fire `app.on_drag_move(pos)`.
Drag { pos: Point },
/// Slider value changed. `None` when the slider has no change
/// handler for this value (typically unreachable — sliders always
/// carry a change msg — but the gesture machine stays general).
Slider { msg: Option<Msg> },
/// Scroll viewport offset updated. Handler just requests a redraw.
Scroll,
/// Swipe progress. Each axis is `Some(value)` when it has a valid
/// progress reading for this move (not every motion updates all
/// axes). Handler fires the matching app callback.
Swipe { up: Option<f32>, down: Option<f32>, horizontal: Option<f32> },
}
/// Events emitted by [`GestureState::on_release`], in the order the
/// handler should dispatch them. Multiple events per release are
/// possible (see the horizontal fall-through case).
pub enum ReleaseEvent<Msg>
{
/// Long-press drop — `app.on_drop(pos)`, kick main redraw, clear
/// long-press drag.
Drop { pos: Point },
/// Horizontal swipe committed left.
SwipeLeft,
/// Horizontal swipe committed right.
SwipeRight,
/// Vertical swipe committed up.
SwipeUp,
/// Vertical swipe committed down.
SwipeDown,
/// Horizontal swipe did not commit — fire
/// `app.on_swipe_horizontal_progress(0.0)` + main redraw.
HorizontalFellThrough,
/// Vertical swipe did not commit — fire
/// `app.on_swipe_progress(0.0)` + `app.on_swipe_down_progress(0.0)`.
VerticalFellThrough,
/// Push a widget-level message (button press or final slider
/// value on release).
PushMsg( Msg ),
/// Release on empty area — handler calls `app.on_tap()` on Main
/// focus or `overlay_dismiss_msg(id)` on Overlay focus.
EmptyRelease,
}
/// Swipe thresholds + surface dimensions snapshotted from the app +
/// current surface, passed into gesture methods so the machine stays
/// decoupled from `App` and `SurfaceState`.
pub struct SwipeConfig
{
/// Up-swipe commit fraction of surface height (e.g. 0.3 = 30 %).
pub up_thresh: f32,
/// Down-swipe commit fraction of surface height.
pub down_thresh: f32,
/// Down-swipe start zone fraction — the press must land within
/// `surface_height * down_edge` of the top for a down-swipe to
/// arm.
pub down_edge: f32,
/// Horizontal commit fraction of surface width.
pub horizontal_thresh: f32,
pub surface_width: u32,
pub surface_height: u32,
}

470
src/input/gesture/tests.rs Normal file
View File

@@ -0,0 +1,470 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::*;
use crate::widget::{ LaidOutWidget, WidgetHandlers };
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Pressed,
LongPressed,
}
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
Rect { x, y, width: w, height: h }
}
fn pt( x: f32, y: f32 ) -> Point
{
Point { x, y }
}
fn button( idx: usize, r: Rect, on_press: Option<Msg>, on_long_press: Option<Msg> )
-> LaidOutWidget<Msg>
{
button_full( idx, r, on_press, on_long_press, None )
}
fn button_full(
idx: usize, r: Rect,
on_press: Option<Msg>, on_long_press: Option<Msg>, on_drag_start: Option<Msg>,
) -> LaidOutWidget<Msg>
{
LaidOutWidget
{
rect: r,
flat_idx: idx,
id: None,
paint_rect: r,
handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false },
keyboard_focusable: true,
cursor: crate::types::CursorShape::Default,
tooltip: None,
}
}
fn cfg_full( w: u32, h: u32 ) -> SwipeConfig
{
SwipeConfig
{
up_thresh: 0.30,
down_thresh: 0.30,
down_edge: 0.10,
horizontal_thresh: 0.30,
surface_width: w,
surface_height: h,
}
}
// ── on_press ──────────────────────────────────────────────────────────────
#[ test ]
fn press_records_origin_and_hit_idx()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let out = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
assert_eq!( out.hit_idx, Some( 1 ) );
assert!( out.initial_slider_msg.is_none() );
assert_eq!( g.start, Some( pt( 50.0, 50.0 ) ) );
assert_eq!( g.pressed_idx, Some( 1 ) );
}
#[ test ]
fn press_outside_any_widget_records_origin_with_no_hit()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let out = g.on_press( pt( 200.0, 200.0 ), &widgets, &[] );
assert_eq!( out.hit_idx, None );
assert_eq!( g.start, Some( pt( 200.0, 200.0 ) ) );
assert!( g.long_press_start.is_none() );
}
#[ test ]
fn press_arms_long_press_when_handler_is_present()
{
let widgets = vec![ button( 7, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
assert!( g.long_press_start.is_some() );
assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) );
assert_eq!( g.long_press_origin, Some( pt( 10.0, 10.0 ) ) );
assert!( !g.long_press_fired );
}
#[ test ]
fn press_does_not_arm_long_press_without_handler()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
assert!( g.long_press_start.is_none() );
assert!( g.long_press_msg.is_none() );
assert!( g.drag_start_msg.is_none() );
}
#[ test ]
fn press_arms_long_press_when_only_drag_start_is_set()
{
// A draggable-but-menu-less widget (e.g. launcher icons) still
// needs the timer so a touch hold can promote into a drag.
let widgets = vec![ button_full( 3, rect( 0.0, 0.0, 100.0, 100.0 ), None, None, Some( Msg::Pressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
assert!( g.long_press_start.is_some() );
assert!( g.long_press_msg.is_none() );
assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) );
}
#[ test ]
fn move_past_six_pixels_cancels_drag_start_msg_too()
{
let widgets = vec![ button_full( 4, rect( 0.0, 0.0, 200.0, 200.0 ),
None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.long_press_msg.is_none() );
assert!( g.drag_start_msg.is_none() );
}
#[ test ]
fn mouse_press_skips_six_pixel_cancel_so_promotion_can_fire()
{
// With `mouse_press = true` the gesture state must keep the
// drag-start / long-press slots alive past the 6 px touch
// tolerance — otherwise the pointer-side promotion at 24 px
// would never see them and a mouse drag past the cancel
// distance would do nothing until the hold-timer deadline.
let widgets = vec![ button_full( 5, rect( 0.0, 0.0, 200.0, 200.0 ),
None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
g.mouse_press = true;
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) );
assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) );
assert!( g.long_press_origin.is_some() );
}
#[ test ]
fn press_identifies_scroll_target()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42 ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &scrolls );
assert_eq!( g.scrolling_widget, Some( 42 ) );
}
#[ test ]
fn press_resets_stale_long_press_fired_flag()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
g.long_press_fired = true; // stale from a prior gesture
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
assert!( !g.long_press_fired );
}
// ── on_move: long-press cancellation ──────────────────────────────────────
#[ test ]
fn move_within_six_pixels_keeps_long_press_armed()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 14.0, 12.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.long_press_start.is_some() );
assert!( g.long_press_msg.is_some() );
}
#[ test ]
fn move_past_six_pixels_cancels_long_press()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 200.0, 200.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.long_press_start.is_none() );
assert!( g.long_press_msg.is_none() );
assert!( g.long_press_origin.is_none() );
}
// ── on_move: drag fork ────────────────────────────────────────────────────
#[ test ]
fn move_after_long_press_fired_returns_drag()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
g.long_press_fired = true;
let mut offsets = HashMap::new();
let out = g.on_move( pt( 50.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( matches!( out, MoveOutcome::Drag { .. } ) );
}
#[ test ]
fn move_with_global_drag_returns_drag()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 0.0, 0.0 ) );
let mut offsets = HashMap::new();
let out = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), true );
assert!( matches!( out, MoveOutcome::Drag { .. } ) );
}
// ── on_move: scroll ───────────────────────────────────────────────────────
#[ test ]
fn move_inside_scroll_widget_mutates_offset_in_place()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7 ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
// Drag finger upward → content scrolls down → offset increases.
let out = g.on_move( pt( 100.0, 60.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( matches!( out, MoveOutcome::Scroll ) );
assert_eq!( offsets.get( &7 ).copied(), Some( 40.0 ) );
assert!( g.scroll_drag_started, "drag past 8 px must arm scroll_drag_started" );
}
#[ test ]
fn move_inside_scroll_widget_clamps_offset_at_zero()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9 ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
// Drag finger downward → content tries to scroll up past origin → clamp at 0.
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( offsets.get( &9 ).copied(), Some( 0.0 ) );
}
#[ test ]
fn move_below_eight_pixels_does_not_arm_scroll_drag()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1 ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 100.0, 95.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( !g.scroll_drag_started );
}
// ── on_move: swipe progress ───────────────────────────────────────────────
#[ test ]
fn move_below_eight_pixels_emits_swipe_progress_without_arming_drag()
{
// The 8 px threshold only gates `*_drag_started`; the Swipe outcome
// itself fires on every motion that has a non-empty axis reading.
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 100.0 ) );
let mut offsets = HashMap::new();
let out = g.on_move( pt( 102.0, 99.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( matches!( out, MoveOutcome::Swipe { .. } ) );
assert!( !g.horizontal_drag_started );
assert!( !g.vertical_drag_started );
}
#[ test ]
fn move_up_eleven_pixels_arms_vertical_drag()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 600.0 ) );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 100.0, 589.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.vertical_drag_started );
}
#[ test ]
fn move_horizontal_eleven_pixels_arms_horizontal_drag()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 600.0 ) );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 111.5, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.horizontal_drag_started );
}
#[ test ]
fn move_up_progress_clamps_to_unit_interval()
{
// Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag
// must clamp progress to 1.0 instead of overshooting to 2.0.
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 800.0 ) );
let mut offsets = HashMap::new();
let out = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false );
match out
{
MoveOutcome::Swipe { up: Some( v ), .. } =>
assert!( ( v - 1.0 ).abs() < 1e-6, "expected clamp to 1.0, got {v}" ),
_ => panic!( "expected Swipe with up=Some" ),
}
}
// ── on_release ────────────────────────────────────────────────────────────
#[ test ]
fn release_on_button_emits_press_msg()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert_eq!( events.len(), 1 );
assert!( matches!( events[ 0 ], ReleaseEvent::PushMsg( Msg::Pressed ) ) );
}
#[ test ]
fn release_on_different_widget_does_not_emit_press_msg()
{
let widgets = vec![
button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ),
button( 2, rect( 100.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ),
];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 25.0, 25.0 ), &widgets, &[] );
let events = g.on_release( pt( 125.0, 25.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!(
events.iter().all( |e| !matches!( e, ReleaseEvent::PushMsg( _ ) ) ),
"press_msg must not fire when release lands on a different widget"
);
}
#[ test ]
fn release_on_empty_area_emits_empty_release()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::EmptyRelease ) ) );
}
#[ test ]
fn release_after_long_press_fired_emits_drop()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
g.long_press_fired = true;
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) );
}
#[ test ]
fn release_with_global_drag_emits_drop()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), true );
assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) );
}
#[ test ]
fn release_committing_swipe_up_emits_swipe_up()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
// No press_idx (release on empty area) so the vertical-swipe branch runs.
let _ = g.on_press( pt( 400.0, 900.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
// Drag well past 30 % of 1000 px to trip vertical_drag_started.
let _ = g.on_move( pt( 400.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false );
let events = g.on_release( pt( 400.0, 200.0 ), &widgets, &cfg_full( 800, 1000 ), false );
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::SwipeUp ) ) );
}
#[ test ]
fn release_horizontal_below_threshold_falls_through()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 600.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
// 50 px horizontal — below 30 % of 800 = 240 — but past 8 px, so
// horizontal_drag_started flips to true.
let _ = g.on_move( pt( 150.0, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false );
let events = g.on_release( pt( 150.0, 600.0 ), &widgets, &cfg_full( 800, 1000 ), false );
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::HorizontalFellThrough ) ) );
assert!( events.iter().all( |e| !matches!( e, ReleaseEvent::SwipeLeft | ReleaseEvent::SwipeRight ) ) );
}
#[ test ]
fn release_after_slider_drag_emits_no_events()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 50.0, 50.0 ) );
g.dragging_slider = Some( 1 );
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.is_empty() );
assert!( g.dragging_slider.is_none() );
}
#[ test ]
fn release_after_consumed_scroll_emits_no_events()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 50.0, 50.0 ) );
g.scrolling_widget = Some( 1 );
g.scroll_drag_started = true;
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.is_empty() );
assert!( g.scrolling_widget.is_none() );
assert!( !g.scroll_drag_started );
}
// ── on_cancel ─────────────────────────────────────────────────────────────
#[ test ]
fn cancel_resets_every_field()
{
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 1.0, 2.0 ) );
g.pressed_idx = Some( 99 );
g.scrolling_widget = Some( 99 );
g.scroll_drag_started = true;
g.horizontal_drag_started = true;
g.vertical_drag_started = true;
g.dragging_slider = Some( 99 );
g.long_press_fired = true;
g.long_press_msg = Some( Msg::LongPressed );
g.drag_start_msg = Some( Msg::Pressed );
g.on_cancel();
assert!( g.start.is_none() );
assert!( g.pressed_idx.is_none() );
assert!( g.scrolling_widget.is_none() );
assert!( !g.scroll_drag_started );
assert!( !g.horizontal_drag_started );
assert!( !g.vertical_drag_started );
assert!( g.dragging_slider.is_none() );
assert!( !g.long_press_fired );
assert!( g.long_press_msg.is_none() );
assert!( g.drag_start_msg.is_none() );
}

View File

@@ -1,645 +0,0 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland keyboard → ltk dispatch.
//!
//! Translates `wl_keyboard` events into focus / text-insertion /
//! widget-submit actions. Does not share state with the gesture state
//! machine — keyboard tracks its own focus (`AppData::keyboard_focus`)
//! and modifier flags (`shift_pressed`, `ctrl_pressed`).
//!
//! The only cross-cutting state it reads is `SurfaceState::focused_idx`
//! (set by pointer / touch focus updates) so keyboard navigation can
//! branch on "is a widget focused?" — Return submits / Space presses /
//! typing inserts all funnel through that check.
use std::time::Duration;
use smithay_client_toolkit::seat::keyboard::
{
KeyboardHandler, KeyEvent, Keysym, Modifiers, RawModifiers, RepeatInfo,
};
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_keyboard::WlKeyboard, wl_surface::WlSurface },
Connection, QueueHandle,
};
use calloop::timer::{ Timer, TimeoutAction };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::event_loop::app_data::KeyRepeatState;
use crate::tree::{ find_handlers, next_focusable_index };
/// Built-in fallback for the initial key-repeat delay when the
/// compositor does not advertise one and the app does not override
/// [`crate::App::key_repeat_delay`].
const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis( 500 );
/// Built-in fallback for the inter-repeat interval when the compositor
/// does not advertise a rate. ~30 Hz, matching the GNOME / KDE default
/// for "fast" without straying into uncomfortably-twitchy territory.
const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis( 33 );
impl<A: App> KeyboardHandler for AppData<A>
{
fn enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
surface: &WlSurface,
_serial: u32,
_raw: &[u32],
_keysyms: &[Keysym],
)
{
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
self.keyboard_focus = focus;
self.surface_mut( focus ).request_redraw();
}
fn leave(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_surface: &WlSurface,
_serial: u32,
)
{
self.stop_key_repeat();
self.keyboard_focus = SurfaceFocus::Main;
}
fn press_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
let focus = self.keyboard_focus;
// Raw observer hook (e.g. forwarding to an embedded WPE view).
// Fires before the focus-aware dispatch.
self.app.on_raw_key( event.keysym, event.raw_code, true, self.ctrl_pressed, self.shift_pressed );
// A new press always cancels any in-flight repeat — the user
// has either released the previous key or is pressing a
// different one. Either way, the prior timer's keysym should
// not keep firing.
self.stop_key_repeat();
self.dispatch_key( focus, event.clone() );
self.start_key_repeat( focus, event );
}
fn release_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
self.app.on_raw_key( event.keysym, event.raw_code, false, self.ctrl_pressed, self.shift_pressed );
// Only stop if the released key matches the one currently
// repeating; releasing a non-repeating key (e.g. a shift that
// snuck through, or any key we never armed) leaves the timer
// untouched.
if let Some( ref state ) = self.key_repeat
{
if state.event.keysym == event.keysym
{
self.stop_key_repeat();
}
}
}
fn update_modifiers(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
modifiers: Modifiers,
_raw_modifiers: RawModifiers,
_layout: u32,
)
{
self.shift_pressed = modifiers.shift;
self.ctrl_pressed = modifiers.ctrl;
}
fn update_repeat_info(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
info: RepeatInfo,
)
{
match info
{
RepeatInfo::Repeat { rate, delay } =>
{
self.compositor_repeat_rate = rate.get();
self.compositor_repeat_delay = delay;
}
RepeatInfo::Disable =>
{
self.compositor_repeat_rate = 0;
self.compositor_repeat_delay = 0;
self.stop_key_repeat();
}
}
}
fn repeat_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
// Compositor-driven repeat (wl_keyboard v10). When the
// compositor takes the repeat job we just re-dispatch — and
// suppress our internal timer to avoid double-firing.
self.stop_key_repeat();
let focus = self.keyboard_focus;
self.dispatch_key( focus, event );
}
}
impl<A: App> AppData<A>
{
/// Run the same dispatch logic that the Wayland press-key handler
/// runs, but without the trait-callback signature — used both by
/// the trait method and by the key-repeat timer.
fn dispatch_key( &mut self, focus: SurfaceFocus, event: KeyEvent )
{
// `set_focus` (Tab handling) needs a `QueueHandle`. Cloning is
// cheap (an `Arc`-equivalent under the hood) and avoids
// threading the qh through every helper.
let qh = self.qh.clone();
let qh = &qh;
let focused = self.surface( focus ).focused_idx;
match event.keysym
{
Keysym::BackSpace =>
{
if focused.is_some() { self.handle_backspace( focus ); }
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
Keysym::Delete =>
{
// Supr / Forward-Delete: same shape as Backspace but
// removes the char *after* the cursor. When no widget
// is focused, the keysym bubbles to the app.
if focused.is_some() { self.handle_delete_forward( focus ); }
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
Keysym::Return | Keysym::KP_Enter =>
{
// Enter targets, in order: (a) the focused widget's submit
// message (TextEdit), (b) its press message (Button etc),
// (c) the press message of the keyboard-hovered list item
// in the topmost scroll. The hovered fallback lets users
// confirm a combo / list choice with the keyboard after
// arrow-navigating to it without ever taking widget focus.
//
// Multiline text-input override: when the focused widget
// is a `text_edit().multiline( true )`, Enter inserts a
// literal `\n` into the buffer instead of submitting, so
// the user can compose paragraphs.
let is_multi = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_multiline_text_input() ) ).unwrap_or( false );
if is_multi
{
self.handle_text_insert( focus, "\n" );
} else {
let target = focused.or( self.surface( focus ).hovered_idx );
if let Some( idx ) = target
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.submit_msg().or_else( || h.press_msg() ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
Keysym::Down | Keysym::Up =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let reverse = event.keysym == Keysym::Up;
let extend = self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else
{
let consumed = if is_text
{
if reverse { self.handle_cursor_up( focus, extend ) }
else { self.handle_cursor_down( focus, extend ) }
} else { false };
if !consumed && !self.move_keyboard_hover( focus, reverse )
{
// already None, no extra fire
}
}
}
Keysym::Left | Keysym::Right =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let extend = self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else if is_text
{
if event.keysym == Keysym::Left
{
self.handle_cursor_left( focus, extend );
} else {
self.handle_cursor_right( focus, extend );
}
}
}
Keysym::Home =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_home( focus, self.shift_pressed ); }
}
Keysym::End =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_end( focus, self.shift_pressed ); }
}
Keysym::a | Keysym::A if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_select_all( focus ); }
}
Keysym::c | Keysym::C if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_copy( focus ); }
}
Keysym::x | Keysym::X if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cut( focus ); }
}
Keysym::v | Keysym::V if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_paste( focus ); }
}
Keysym::Tab | Keysym::ISO_Left_Tab =>
{
let reverse = event.keysym == Keysym::ISO_Left_Tab || self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else
{
let ss = self.surface( focus );
let next_idx = next_focusable_index( &ss.widget_rects, ss.focused_idx, reverse );
if let Some( next_idx ) = next_idx
{
self.set_focus( focus, Some( next_idx ), qh );
}
}
}
Keysym::Escape =>
{
// Esc peels off transient UI one layer at a time.
// Order: (1) open xdg-popup overlays → dismiss them;
// (2) context menu → close it; (3) active selection in
// a focused text input → collapse to cursor; (4) any
// laid-out pressable carrying an `on_escape` message
// (typically the topmost `dialog`'s cancel) → fire
// that; (5) otherwise → drop focus and let the app see
// Esc.
if !self.overlays.is_empty() && self.app.overlays().iter().any( | s | s.anchor_widget_id.is_some() )
{
self.dismiss_all_popups();
} else if self.surface( focus ).context_menu.is_some()
{
self.hide_context_menu( focus );
} else if self.collapse_selection_if_any( focus )
{
// Selection collapsed — keep focus, no app msg.
} else if let Some( msg ) = self.surface( focus ).widget_rects.iter()
.rev()
.find_map( |w| w.handlers.escape_msg() )
{
self.pending_msgs.push( msg );
} else {
self.set_focus( focus, None, qh );
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
Keysym::space =>
{
if let Some( idx ) = focused
{
let press = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.press_msg() );
if let Some( msg ) = press
{
self.pending_msgs.push( msg );
} else {
// Focused widget is a TextEdit (or has no on_press) — insert space as text
self.handle_text_insert( focus, " " );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
_ =>
{
if self.ctrl_pressed
{
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, true, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
} else if focused.is_some()
{
if let Some( txt ) = event.utf8
{
if !txt.is_empty() && txt.chars().all( |c| !c.is_control() )
{
self.handle_text_insert( focus, &txt );
}
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, false, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
self.surface_mut( focus ).request_redraw();
}
/// Compute the effective initial repeat delay — app override wins,
/// then compositor info, then a built-in default.
pub( crate ) fn effective_repeat_delay( &self ) -> Duration
{
if let Some( d ) = self.app.key_repeat_delay() { return d; }
if self.compositor_repeat_delay > 0
{
Duration::from_millis( self.compositor_repeat_delay as u64 )
} else {
DEFAULT_REPEAT_DELAY
}
}
/// Compute the effective inter-repeat interval. Same precedence as
/// [`Self::effective_repeat_delay`]: app override → compositor →
/// built-in default. Returns `None` when repeat is disabled by the
/// active source (compositor `RepeatInfo::Disable` with no app
/// override, or an app override of `Some(Duration::ZERO)`).
pub( crate ) fn effective_repeat_interval( &self ) -> Option<Duration>
{
if let Some( d ) = self.app.key_repeat_interval()
{
if d.is_zero() { return None; }
return Some( d );
}
if self.compositor_repeat_rate == 0
{
// Compositor explicitly disabled repeat, app did not
// override — fall back to a built-in default rather than
// disabling, because most environments where the
// compositor reports 0 also fail to send the event in
// the first place. The default keeps the feel close to
// what people expect from a desktop toolkit.
return Some( DEFAULT_REPEAT_INTERVAL );
}
let ms = ( 1000 / self.compositor_repeat_rate ).max( 1 );
Some( Duration::from_millis( ms as u64 ) )
}
/// Schedule a key-repeat timer for the given event. No-op when the
/// app's [`crate::App::key_repeats`] gate returns `false` for this
/// keysym, when repeat is disabled, or when the calloop timer
/// insertion fails (in which case held keys simply do not repeat).
pub( crate ) fn start_key_repeat( &mut self, focus: SurfaceFocus, event: KeyEvent )
{
if !self.app.key_repeats( event.keysym ) { return; }
let interval = match self.effective_repeat_interval()
{
Some( i ) => i,
None => return,
};
let delay = self.effective_repeat_delay();
let event_for_timer = event.clone();
let timer = Timer::from_duration( delay );
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
{
data.dispatch_key( focus, event_for_timer.clone() );
TimeoutAction::ToDuration( interval )
} );
match token
{
Ok( token ) => { self.key_repeat = Some( KeyRepeatState { event, token } ); }
Err( _ ) => {}
}
}
/// Cancel any active key-repeat timer.
pub( crate ) fn stop_key_repeat( &mut self )
{
if let Some( state ) = self.key_repeat.take()
{
self.loop_handle.remove( state.token );
}
}
/// Schedule a button-press repeat timer. The runtime fires the
/// `on_press` message *immediately* on press too — this fn only
/// arms the held-down repeat path; the first fire happens at
/// the call site so a quick tap still registers as a single
/// press.
///
/// Each timer tick re-reads the live `on_press` from the
/// current widget tree (via the snapshotted `flat_idx`) rather
/// than replaying a captured message. This is what makes
/// stepper-style buttons work: a stepper builds an `on_press`
/// like `"go to value + 5"` at view-build time, so replaying
/// the press-time snapshot after the first fire would re-issue
/// the same target and the value would freeze. By reading
/// `on_press` afresh each tick we pick up the new target the
/// view rebuilt with the updated value.
///
/// No-op when [`Self::effective_repeat_interval`] reports
/// repeat disabled (zero rate, app override of
/// `Duration::ZERO`). Self-cancels on the first tick where the
/// widget at `idx` no longer exists or no longer carries a
/// press message.
pub( crate ) fn start_button_repeat( &mut self, focus: SurfaceFocus, idx: usize )
{
// Cancel any pre-existing repeat first — only one button can
// be in repeat mode at a time, and a fresh press should
// supersede a previous one.
self.stop_button_repeat();
// Button repeat ticks at a fixed 120 ms (~8 Hz). The keyboard
// repeat interval is ~33 ms (30 Hz), which suits cursor /
// character entry but is too aggressive for pointer steppers
// — a date / time picker would walk a full minute in under
// two seconds. 120 ms is fast enough to ramp through values,
// slow enough that the user can still release on the value
// they want.
if matches!( self.effective_repeat_interval(), None ) { return; }
let interval = std::time::Duration::from_millis( 120 );
let delay = self.effective_repeat_delay();
let timer = Timer::from_duration( delay );
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
{
let live_msg = crate::tree::find_handlers(
&data.surface( focus ).widget_rects,
idx,
)
.and_then( |h| h.press_msg() );
match live_msg
{
Some( m ) =>
{
data.pending_msgs.push( m );
TimeoutAction::ToDuration( interval )
}
None =>
{
// Widget gone (view restructured) or no longer
// has an on_press → drop ourselves so the timer
// does not fire forever against a stale slot.
data.button_repeat = None;
TimeoutAction::Drop
}
}
} );
if let Ok( token ) = token
{
self.button_repeat = Some( crate::event_loop::app_data::ButtonRepeatState { token } );
}
}
/// Cancel any active button-press repeat timer.
pub( crate ) fn stop_button_repeat( &mut self )
{
if let Some( state ) = self.button_repeat.take()
{
self.loop_handle.remove( state.token );
}
}
/// Step the topmost scroll's `hovered_idx` one item up or down with
/// the keyboard. Returns `true` when the move was applied (a scroll
/// with at least one navigable item was on screen and the new item
/// is different from the current hover) so the caller can fall
/// through to the application's own `on_key` only on a no-op.
///
/// The "topmost scroll" is the last entry in `scroll_rects`, which
/// matches Stack-overlay layout order: a popup pushed after the
/// main content sits above it. Auto-scrolls the new item into view
/// by adjusting `scroll_offsets[scroll_idx]`.
pub( crate ) fn move_keyboard_hover( &mut self, focus: SurfaceFocus, reverse: bool ) -> bool
{
// Find the topmost scroll that has a navigable item list.
let scroll_meta = {
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find_map( |( rect, idx )|
{
ss.scroll_navigable_items.get( idx )
.filter( |list| !list.is_empty() )
.map( |list| ( *rect, *idx, list.clone() ) )
} )
};
let Some( ( scroll_rect, scroll_idx, items ) ) = scroll_meta else { return false; };
let current = self.surface( focus ).hovered_idx;
let pos = current.and_then( |h| items.iter().position( |( i, _, _ )| *i == h ) );
let next_pos = match ( reverse, pos )
{
( false, None ) => 0,
( false, Some( p ) ) => ( p + 1 ).min( items.len() - 1 ),
( true, None ) => items.len() - 1,
( true, Some( p ) ) => p.saturating_sub( 1 ),
};
let ( new_idx, content_y, content_h ) = items[ next_pos ];
// Auto-scroll to bring the new item fully into view. Item Y is
// in pre-offset content coordinates, so the offset that places
// the item flush with the top of the viewport is `content_y`,
// and flush with the bottom is `content_y + content_h - viewport_h`.
let viewport_h = scroll_rect.height;
let current_offset = self.surface( focus )
.scroll_offsets.get( &scroll_idx )
.copied().unwrap_or( 0.0 );
let new_offset = if content_y < current_offset
{
content_y
}
else if content_y + content_h > current_offset + viewport_h
{
( content_y + content_h - viewport_h ).max( 0.0 )
}
else
{
current_offset
};
let ss = self.surface_mut( focus );
ss.hovered_idx = Some( new_idx );
ss.scroll_offsets.insert( scroll_idx, new_offset );
ss.request_redraw();
true
}
}

View File

@@ -0,0 +1,41 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
/// Run the same dispatch logic that the Wayland press-key handler
/// runs, but without the trait-callback signature — used both by
/// the trait method and by the key-repeat timer.
pub( crate ) fn dispatch_key( &mut self, focus: SurfaceFocus, event: KeyEvent )
{
// `set_focus` (Tab handling) needs a `QueueHandle`. Cloning is
// cheap (an `Arc`-equivalent under the hood) and avoids
// threading the qh through every helper.
let qh = self.qh.clone();
let qh = &qh;
match event.keysym
{
Keysym::BackSpace => self.handle_key_backspace( focus, &event ),
Keysym::Delete => self.handle_key_delete( focus, &event ),
Keysym::Return | Keysym::KP_Enter => self.handle_key_return( focus, &event ),
Keysym::Down | Keysym::Up => self.handle_key_arrow_vertical( focus, &event ),
Keysym::Left | Keysym::Right => self.handle_key_arrow_horizontal( focus, &event ),
Keysym::Home => self.handle_key_home( focus ),
Keysym::End => self.handle_key_end( focus ),
Keysym::a | Keysym::A if self.ctrl_pressed => self.handle_key_ctrl_a( focus ),
Keysym::c | Keysym::C if self.ctrl_pressed => self.handle_key_ctrl_c( focus ),
Keysym::x | Keysym::X if self.ctrl_pressed => self.handle_key_ctrl_x( focus ),
Keysym::v | Keysym::V if self.ctrl_pressed => self.handle_key_ctrl_v( focus ),
Keysym::Tab | Keysym::ISO_Left_Tab => self.handle_key_tab( focus, &event, qh ),
Keysym::Escape => self.handle_key_escape( focus, &event, qh ),
Keysym::space => self.handle_key_space( focus, &event ),
_ => self.handle_key_default( focus, &event ),
}
self.surface_mut( focus ).request_redraw();
}
}

165
src/input/keyboard/mod.rs Normal file
View File

@@ -0,0 +1,165 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland keyboard → ltk dispatch.
//!
//! Translates `wl_keyboard` events into focus / text-insertion /
//! widget-submit actions. Does not share state with the gesture state
//! machine — keyboard tracks its own focus (`AppData::keyboard_focus`)
//! and modifier flags (`shift_pressed`, `ctrl_pressed`).
//!
//! The only cross-cutting state it reads is `SurfaceState::focused_idx`
//! (set by pointer / touch focus updates) so keyboard navigation can
//! branch on "is a widget focused?" — Return submits / Space presses /
//! typing inserts all funnel through that check.
use smithay_client_toolkit::seat::keyboard::
{
KeyboardHandler, KeyEvent, Keysym, Modifiers, RawModifiers, RepeatInfo,
};
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_keyboard::WlKeyboard, wl_surface::WlSurface },
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
pub( super ) mod dispatch;
pub( super ) mod text_keys;
pub( super ) mod shortcuts;
pub( super ) mod nav;
impl<A: App> KeyboardHandler for AppData<A>
{
fn enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
surface: &WlSurface,
_serial: u32,
_raw: &[u32],
_keysyms: &[Keysym],
)
{
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
self.keyboard_focus = focus;
self.surface_mut( focus ).request_redraw();
}
fn leave(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_surface: &WlSurface,
_serial: u32,
)
{
self.stop_key_repeat();
self.keyboard_focus = SurfaceFocus::Main;
}
fn press_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
let focus = self.keyboard_focus;
// Raw observer hook (e.g. forwarding to an embedded WPE view).
// Fires before the focus-aware dispatch.
self.app.on_raw_key( event.keysym, event.raw_code, true, self.ctrl_pressed, self.shift_pressed );
// A new press always cancels any in-flight repeat — the user
// has either released the previous key or is pressing a
// different one. Either way, the prior timer's keysym should
// not keep firing.
self.stop_key_repeat();
self.dispatch_key( focus, event.clone() );
self.start_key_repeat( focus, event );
}
fn release_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
self.app.on_raw_key( event.keysym, event.raw_code, false, self.ctrl_pressed, self.shift_pressed );
// Only stop if the released key matches the one currently
// repeating; releasing a non-repeating key (e.g. a shift that
// snuck through, or any key we never armed) leaves the timer
// untouched.
if let Some( ref state ) = self.key_repeat
{
if state.event.keysym == event.keysym
{
self.stop_key_repeat();
}
}
}
fn update_modifiers(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
modifiers: Modifiers,
_raw_modifiers: RawModifiers,
_layout: u32,
)
{
self.shift_pressed = modifiers.shift;
self.ctrl_pressed = modifiers.ctrl;
}
fn update_repeat_info(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
info: RepeatInfo,
)
{
match info
{
RepeatInfo::Repeat { rate, delay } =>
{
self.compositor_repeat_rate = rate.get();
self.compositor_repeat_delay = delay;
}
RepeatInfo::Disable =>
{
self.compositor_repeat_rate = 0;
self.compositor_repeat_delay = 0;
self.stop_key_repeat();
}
}
}
fn repeat_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
// Compositor-driven repeat (wl_keyboard v10). When the
// compositor takes the repeat job we just re-dispatch — and
// suppress our internal timer to avoid double-firing.
self.stop_key_repeat();
let focus = self.keyboard_focus;
self.dispatch_key( focus, event );
}
}

72
src/input/keyboard/nav.rs Normal file
View File

@@ -0,0 +1,72 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
/// Step the topmost scroll's `hovered_idx` one item up or down with
/// the keyboard. Returns `true` when the move was applied (a scroll
/// with at least one navigable item was on screen and the new item
/// is different from the current hover) so the caller can fall
/// through to the application's own `on_key` only on a no-op.
///
/// The "topmost scroll" is the last entry in `scroll_rects`, which
/// matches Stack-overlay layout order: a popup pushed after the
/// main content sits above it. Auto-scrolls the new item into view
/// by adjusting `scroll_offsets[scroll_idx]`.
pub( crate ) fn move_keyboard_hover( &mut self, focus: SurfaceFocus, reverse: bool ) -> bool
{
// Find the topmost scroll that has a navigable item list.
let scroll_meta = {
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find_map( |( rect, idx )|
{
ss.scroll_navigable_items.get( idx )
.filter( |list| !list.is_empty() )
.map( |list| ( *rect, *idx, list.clone() ) )
} )
};
let Some( ( scroll_rect, scroll_idx, items ) ) = scroll_meta else { return false; };
let current = self.surface( focus ).hovered_idx;
let pos = current.and_then( |h| items.iter().position( |( i, _, _ )| *i == h ) );
let next_pos = match ( reverse, pos )
{
( false, None ) => 0,
( false, Some( p ) ) => ( p + 1 ).min( items.len() - 1 ),
( true, None ) => items.len() - 1,
( true, Some( p ) ) => p.saturating_sub( 1 ),
};
let ( new_idx, content_y, content_h ) = items[ next_pos ];
// Auto-scroll to bring the new item fully into view. Item Y is
// in pre-offset content coordinates, so the offset that places
// the item flush with the top of the viewport is `content_y`,
// and flush with the bottom is `content_y + content_h - viewport_h`.
let viewport_h = scroll_rect.height;
let current_offset = self.surface( focus )
.scroll_offsets.get( &scroll_idx )
.copied().unwrap_or( 0.0 );
let new_offset = if content_y < current_offset
{
content_y
}
else if content_y + content_h > current_offset + viewport_h
{
( content_y + content_h - viewport_h ).max( 0.0 )
}
else
{
current_offset
};
let ss = self.surface_mut( focus );
ss.hovered_idx = Some( new_idx );
ss.scroll_offsets.insert( scroll_idx, new_offset );
ss.request_redraw();
true
}
}

View File

@@ -0,0 +1,100 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym };
use smithay_client_toolkit::reexports::client::QueueHandle;
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::{ find_handlers, next_focusable_index };
impl<A: App> AppData<A>
{
pub( super ) fn handle_key_ctrl_a( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_select_all( focus ); }
}
pub( super ) fn handle_key_ctrl_c( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_copy( focus ); }
}
pub( super ) fn handle_key_ctrl_x( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cut( focus ); }
}
pub( super ) fn handle_key_ctrl_v( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_paste( focus ); }
}
pub( super ) fn handle_key_tab( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle<Self> )
{
let reverse = event.keysym == Keysym::ISO_Left_Tab || self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else
{
let ss = self.surface( focus );
let next_idx = next_focusable_index( &ss.widget_rects, ss.focused_idx, reverse );
if let Some( next_idx ) = next_idx
{
self.set_focus( focus, Some( next_idx ), qh );
}
}
}
pub( super ) fn handle_key_escape( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle<Self> )
{
// Esc peels off transient UI one layer at a time.
// Order: (1) open xdg-popup overlays → dismiss them;
// (2) context menu → close it; (3) active selection in
// a focused text input → collapse to cursor; (4) any
// laid-out pressable carrying an `on_escape` message
// (typically the topmost `dialog`'s cancel) → fire
// that; (5) otherwise → drop focus and let the app see
// Esc.
if !self.overlays.is_empty() && self.app.overlays().iter().any( | s | s.anchor_widget_id.is_some() )
{
self.dismiss_all_popups();
} else if self.surface( focus ).context_menu.is_some()
{
self.hide_context_menu( focus );
} else if self.collapse_selection_if_any( focus )
{
// Selection collapsed — keep focus, no app msg.
} else if let Some( msg ) = self.surface( focus ).widget_rects.iter()
.rev()
.find_map( |w| w.handlers.escape_msg() )
{
self.pending_msgs.push( msg );
} else {
self.set_focus( focus, None, qh );
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
}

View File

@@ -0,0 +1,183 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::find_handlers;
impl<A: App> AppData<A>
{
pub( super ) fn handle_key_backspace( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
if focused.is_some() { self.handle_backspace( focus ); }
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
pub( super ) fn handle_key_delete( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
// Supr / Forward-Delete: same shape as Backspace but
// removes the char *after* the cursor. When no widget
// is focused, the keysym bubbles to the app.
if focused.is_some() { self.handle_delete_forward( focus ); }
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
pub( super ) fn handle_key_return( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
// Enter targets, in order: (a) the focused widget's submit
// message (TextEdit), (b) its press message (Button etc),
// (c) the press message of the keyboard-hovered list item
// in the topmost scroll. The hovered fallback lets users
// confirm a combo / list choice with the keyboard after
// arrow-navigating to it without ever taking widget focus.
//
// Multiline text-input override: when the focused widget
// is a `text_edit().multiline( true )`, Enter inserts a
// literal `\n` into the buffer instead of submitting, so
// the user can compose paragraphs.
let is_multi = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_multiline_text_input() ) ).unwrap_or( false );
if is_multi
{
self.handle_text_insert( focus, "\n" );
} else {
let target = focused.or( self.surface( focus ).hovered_idx );
if let Some( idx ) = target
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.submit_msg().or_else( || h.press_msg() ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
pub( super ) fn handle_key_arrow_vertical( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let reverse = event.keysym == Keysym::Up;
let extend = self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else
{
let consumed = if is_text
{
if reverse { self.handle_cursor_up( focus, extend ) }
else { self.handle_cursor_down( focus, extend ) }
} else { false };
if !consumed && !self.move_keyboard_hover( focus, reverse )
{
// already None, no extra fire
}
}
}
pub( super ) fn handle_key_arrow_horizontal( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let extend = self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else if is_text
{
if event.keysym == Keysym::Left
{
self.handle_cursor_left( focus, extend );
} else {
self.handle_cursor_right( focus, extend );
}
}
}
pub( super ) fn handle_key_home( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_home( focus, self.shift_pressed ); }
}
pub( super ) fn handle_key_end( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_end( focus, self.shift_pressed ); }
}
pub( super ) fn handle_key_space( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
if let Some( idx ) = focused
{
let press = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.press_msg() );
if let Some( msg ) = press
{
self.pending_msgs.push( msg );
} else {
// Focused widget is a TextEdit (or has no on_press) — insert space as text
self.handle_text_insert( focus, " " );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
pub( super ) fn handle_key_default( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
if self.ctrl_pressed
{
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, true, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
} else if focused.is_some()
{
if let Some( txt ) = event.utf8.clone()
{
if !txt.is_empty() && txt.chars().all( |c| !c.is_control() )
{
self.handle_text_insert( focus, &txt );
}
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, false, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}

View File

@@ -27,6 +27,7 @@ pub( crate ) mod keyboard;
pub( crate ) mod pointer;
pub( crate ) mod touch;
pub( crate ) mod dispatch;
pub( crate ) mod repeat;
// `GestureState` is the only type consumers outside `input/` need —
// `AppData` stores one per `SurfaceState` and the long-press deadline

View File

@@ -1,504 +0,0 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland pointer → ltk dispatch.
//!
//! Each `wl_pointer` event (motion / press / release / axis) translates
//! into a call on the surface's [`GestureState`](super::gesture::GestureState)
//! plus the follow-up side-effects that can only live at the
//! [`AppData`] level (pending-message push, surface redraw, dirty-cache
//! invalidation, window move). The pointer handler adds three things
//! touch does not have:
//!
//! * **Hover tracking** — motion updates `SurfaceState::hovered_idx`
//! before delegating the motion to the gesture machine, since the
//! hovered widget drives visual state independent of whether a press
//! is active.
//! * **Title-bar interaction** — a press inside the client-side title
//! bar either closes the window (close button hit) or initiates a
//! compositor-driven window move. Neither path goes through the
//! gesture machine.
//! * **Wheel / axis scroll** — routed directly into the per-viewport
//! `scroll_offsets` map; axis events do not have a press/release
//! lifecycle so they bypass the state machine entirely.
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind, PointerHandler };
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_pointer, wl_pointer::WlPointer },
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::{ find_handlers, find_widget_at };
use crate::widget::WidgetHandlers;
use super::gesture::SwipeConfig;
impl<A: App> PointerHandler for AppData<A>
{
fn pointer_frame(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_pointer: &WlPointer,
events: &[PointerEvent],
)
{
for event in events
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
self.pointer_focus = focus;
match event.kind
{
PointerEventKind::Enter { serial } =>
{
// Pointer just entered our surface — capture the
// serial that wp_cursor_shape_device_v1::set_shape
// requires, then push the initial cursor shape
// *unconditionally*. Resetting `current_cursor_shape`
// to `None` is what forces the dispatch: the
// compositor was showing whatever the previous
// client asked for (e.g. a text I-beam from a
// terminal under our window), and we must claim
// the cursor for our surface even when the target
// equals what we last pushed.
self.last_pointer_enter_serial = serial;
self.current_cursor_shape = None;
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Motion { .. } =>
{
let pp = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pp;
self.app.on_pointer_move( pp.x, pp.y );
// Hover tracking — pointer-only (touch has no hover).
// Runs before the gesture motion so the cache-dirty
// below picks up any hover-dependent redraw request.
let new_hover = find_widget_at( &self.surface( focus ).widget_rects, pp );
let old_hover = self.surface( focus ).hovered_idx;
if new_hover != old_hover
{
let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover )
|| hover_affects_paint( &self.surface( focus ).widget_rects, new_hover );
{
let ss = self.surface_mut( focus );
ss.hovered_idx = new_hover;
if redraw { ss.needs_redraw = true; }
}
match new_hover
{
Some( idx ) => self.arm_tooltip( focus, idx ),
None => self.cancel_tooltip(),
}
}
// Mouse drag-promotion: a left-button press whose hit
// widget carries `on_drag_start` should arm a drag as
// soon as the cursor moves past the threshold, without
// waiting for the touch hold timer. Touch keeps its
// hold-then-drag path because scroll / swipe gestures
// need the in-between motion budget; mouse has a
// dedicated right-click for the menu so left-button
// can be drag-only.
//
// Threshold of 24 px (logical) sits comfortably above
// the gesture machine's 6 px long-press cancel
// tolerance and above crustace's 16 px drag-commit
// threshold, so by the time we promote the next
// motion sample is already past the app's commit
// distance and drag mode latches without flashing
// any half-state.
//
// Promotion is synchronous (`self.app.update(...)`
// directly) so the app's drag state is armed BEFORE
// the `on_drag_move` call below runs — otherwise the
// seed coords land on a `dragging_item = None`
// shell and get lost. We pay the cost of bypassing
// `invalidate_after` for this one msg, but the next
// frame will repaint everything anyway because the
// drag is in flight.
let promote = {
let ss = self.surface( focus );
match ( ss.gesture.long_press_origin, ss.gesture.drag_start_msg.is_some() )
{
( Some( o ), true ) => ( pp.x - o.x ).hypot( pp.y - o.y ) > 24.0,
_ => false,
}
};
if promote
{
let ( ds_msg, origin ) = {
let ss = self.surface_mut( focus );
let m = ss.gesture.drag_start_msg.take().expect( "promote checked is_some" );
let o = ss.gesture.long_press_origin.expect( "promote checked Some(origin)" );
ss.gesture.long_press_start = None;
ss.gesture.long_press_origin = None;
ss.gesture.long_press_msg = None;
ss.gesture.long_press_text_idx = None;
ss.gesture.long_press_fired = true;
ss.request_redraw();
( m, o )
};
self.app.update( ds_msg );
let ( ox, oy ) = self.surface_offset_for( focus );
self.app.on_drag_move( origin.x + ox, origin.y + oy );
self.dirty_caches();
self.stop_button_repeat();
}
// `global_drag` must be sampled AFTER the promotion
// above — promotion flips `long_press_fired` and we
// want the gesture machine to take the drag branch
// for the same motion event that triggered the
// promotion.
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let outcome =
{
let ss = self.surface_mut( focus );
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
};
self.apply_move_outcome( focus, outcome );
// Drag-to-select inside a TextEdit. Runs after the
// gesture machine so the gesture's "did we leave
// the press's hit rect?" reasoning still applies
// to the press itself; for text fields the answer
// is "fine, keep selecting" because we only widen
// the selection while the pointer is still inside
// the same widget rect.
let pressed_text = self.surface( focus ).gesture.pressed_idx
.and_then( |idx|
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text { Some( idx ) } else { None }
} );
if let Some( idx ) = pressed_text
{
self.handle_text_pointer_drag( focus, idx, pp );
}
// Cursor shape: hover may have changed → push the
// new shape to the compositor (no-op when
// unchanged).
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Press { button: 0x111, .. } =>
{
// Right-click: the desktop equivalent of a touch
// long-press. Three cases on the press target:
//
// * Widget with `on_long_press` (Button or
// Pressable that opted in) — fire the message.
// The drag-arm slot (`on_drag_start`) is NOT
// consumed: right-click never enters drag mode,
// so an icon's context menu opens but no drag
// is armed.
// * TextEdit with no user `on_long_press` — open
// the built-in Copy / Cut / Paste menu near the
// click. Selection is preserved (we deliberately
// skip `handle_text_pointer_down`) so the common
// "select text → right-click → Copy" flow works.
// * Anywhere else — dismiss any already-open menu.
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
let hit_idx = find_widget_at( &self.surface( focus ).widget_rects, pos );
let lp_msg = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.long_press_msg() ) );
if let Some( msg ) = lp_msg
{
self.pending_msgs.push( msg );
self.surface_mut( focus ).request_redraw();
} else {
let is_text = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text
{
let idx = hit_idx.unwrap();
if self.surface( focus ).focused_idx != Some( idx )
{
self.set_focus( focus, hit_idx, qh );
}
self.show_context_menu( focus, idx, pos );
} else {
self.hide_context_menu( focus );
}
}
}
PointerEventKind::Press { button: 0x110, serial, .. } =>
{
self.last_pointer_serial = serial;
self.last_input_serial = serial;
self.cancel_tooltip();
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
{
self.dismiss_main_outside_popups( pos );
}
// Built-in context menu intercepts the press
// before the regular gesture machine. Either an
// item activates (Copy / Cut / Paste) or the
// click is outside the menu and dismisses it —
// in both cases we consume the event.
if self.surface( focus ).context_menu.is_some()
{
if self.handle_context_menu_press( focus, pos )
{
continue;
}
}
// Client-side title bar interaction — pointer-only
// (touch never hits a titlebar; layer-shell surfaces
// have titlebar_height == 0).
let sf = self.surface( focus ).scale_factor.max( 1 ) as f32;
let tb_h = self.surface( focus ).titlebar_height * sf;
if tb_h > 0.0 && pos.y < tb_h
{
let close_rect = self.surface( focus ).titlebar_close_rect;
if close_rect.contains( pos )
{
if self.app.on_close_requested()
{
self.exit_requested = true;
}
return;
}
}
// Resize-edge interception. Wins over the titlebar
// drag-move (top-left / top-right corners overlap
// the titlebar) and over the gesture machine.
if let Some( edge ) = self.resize_edge_under_pointer( focus )
{
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
window.resize( &seat, serial, edge );
}
}
continue;
}
if tb_h > 0.0 && pos.y < tb_h
{
// Drag to move the window.
if matches!( focus, SurfaceFocus::Main )
{
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
window.move_( &seat, serial );
}
}
}
continue;
}
// Past every chrome interception — surface the
// press to the app so embeddings (e.g. an
// embedded WPE WebView) see real button-down
// events that were not consumed by the window
// frame.
self.app.on_pointer_button( pos.x, pos.y, true );
let outcome =
{
let ss = self.surface_mut( focus );
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
// Mark this gesture as mouse-driven so the
// gesture machine's 6 px stray-cancel skips
// the drag-start / long-press slots — mouse
// motion is intentional and should never
// drop the candidate before the pointer-side
// promotion at 24 px gets to fire.
ss.gesture.mouse_press = true;
ss.needs_redraw = true;
result
};
self.set_focus( focus, outcome.hit_idx, qh );
if let Some( msg ) = outcome.initial_slider_msg
{
self.pending_msgs.push( msg );
}
// Press-and-hold repeat: when the hit is a button
// that opted into `.repeating( true )`, fire the
// `on_press` message immediately and arm the
// repeat timer. The release handler in
// `gesture.rs` knows to suppress the regular tap-
// on-release fire for repeating buttons so a
// quick click still counts as exactly one press.
// The timer re-reads `on_press` from the live
// widget tree on every tick — see
// `start_button_repeat` for why.
if let Some( idx ) = outcome.hit_idx
{
let immediate = {
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
if matches!( handlers, Some( WidgetHandlers::Button { repeating: true, .. } ) )
{
handlers.and_then( |h| h.press_msg() )
} else { None }
};
if let Some( msg ) = immediate
{
self.pending_msgs.push( msg );
self.start_button_repeat( focus, idx );
}
}
// Click-to-position the text cursor when the press
// landed on a TextEdit. `set_focus` above moves the
// cursor to the end of the value; this overrides
// it with the byte offset under the pointer and
// collapses the selection there so a subsequent
// drag widens the selection from the click point.
//
// Double-click on a TextEdit selects the word
// under the cursor instead of just positioning.
if let Some( idx ) = outcome.hit_idx
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text
{
// Eye icon hit on a password field
// short-circuits the text-edit
// dispatch — fire the toggle msg and
// skip cursor placement.
if self.handle_password_toggle_press( focus, idx, pos )
{
let _ = self.note_press_for_double_click( pos );
}
else
{
let is_double = self.note_press_for_double_click( pos );
if is_double
{
self.handle_text_select_word( focus, idx, pos );
} else {
self.handle_text_pointer_down( focus, idx, pos );
}
}
} else {
let _ = self.note_press_for_double_click( pos );
}
} else {
let _ = self.note_press_for_double_click( pos );
}
// Slider press → drag may have started; refresh
// the cursor shape so it switches to `Grabbing`.
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Release { button: 0x110, .. } =>
{
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
self.app.on_pointer_button( pos.x, pos.y, false );
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
// Cancel any held-button repeat — the press is
// over, so the timer no longer has anything to
// fire against.
self.stop_button_repeat();
// Slider drag (if any) just ended — cursor reverts
// from `Grabbing` to whatever the hovered widget
// asks for.
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Axis { horizontal, vertical, source, .. } =>
{
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
let scroll_idx_opt =
{
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find( |( r, _ )| r.contains( pos ) )
.map( |( _, idx )| *idx )
};
if let Some( scroll_idx ) = scroll_idx_opt
{
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
_ => 1.0,
};
let step = vertical.absolute as f32 * multiplier;
let ss = self.surface_mut( focus );
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
*entry = ( *entry + step ).max( 0.0 );
ss.request_redraw();
} else {
// No LTK scroll viewport under the cursor —
// surface the raw axis to the app so embedded
// content (e.g. a WPE view) can handle scrolling
// itself.
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
_ => 1.0,
};
let dx = horizontal.absolute as f32 * multiplier;
let dy = vertical.absolute as f32 * multiplier;
self.app.on_pointer_axis( pos.x, pos.y, dx, dy );
}
}
_ => {}
}
}
}
}
fn hover_affects_paint<Msg: Clone>(
widget_rects: &[crate::widget::LaidOutWidget<Msg>],
idx: Option<usize>,
) -> bool
{
idx.and_then( |i| find_handlers( widget_rects, i ) )
.map( |h| !h.is_slider() )
.unwrap_or( false )
}
/// Pointer-side helpers on `AppData`. Split out so touch can call the
/// same swipe-config factory; `apply_*` helpers live in `dispatch.rs`
/// because they are shared.
impl<A: App> AppData<A>
{
/// Snapshot the swipe thresholds + surface dimensions into a
/// [`SwipeConfig`] for the gesture machine. Called once per
/// motion / release event.
pub( super ) fn swipe_config( &self, focus: SurfaceFocus ) -> SwipeConfig
{
let ss = self.surface( focus );
SwipeConfig
{
up_thresh: self.app.swipe_threshold(),
down_thresh: self.app.swipe_down_threshold(),
down_edge: self.app.swipe_down_edge(),
horizontal_thresh: self.app.swipe_horizontal_threshold(),
surface_width: ss.physical_width(),
surface_height: ss.physical_height(),
}
}
}

View File

@@ -0,0 +1,43 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind };
use smithay_client_toolkit::reexports::client::
{
protocol::wl_pointer::WlPointer,
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
if let PointerEventKind::Enter { serial } = event.kind
{
// Pointer just entered our surface — capture the
// serial that wp_cursor_shape_device_v1::set_shape
// requires, then push the initial cursor shape
// *unconditionally*. Resetting `current_cursor_shape`
// to `None` is what forces the dispatch: the
// compositor was showing whatever the previous
// client asked for (e.g. a text I-beam from a
// terminal under our window), and we must claim
// the cursor for our surface even when the target
// equals what we last pushed.
self.last_pointer_enter_serial = serial;
self.current_cursor_shape = None;
self.dispatch_cursor_shape( focus );
}
}
}

View File

@@ -0,0 +1,41 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::find_handlers;
use super::super::gesture::SwipeConfig;
/// Pointer-side helpers on `AppData`. Split out so touch can call the
/// same swipe-config factory; `apply_*` helpers live in `dispatch.rs`
/// because they are shared.
impl<A: App> AppData<A>
{
/// Snapshot the swipe thresholds + surface dimensions into a
/// [`SwipeConfig`] for the gesture machine. Called once per
/// motion / release event.
pub( crate ) fn swipe_config( &self, focus: SurfaceFocus ) -> SwipeConfig
{
let ss = self.surface( focus );
SwipeConfig
{
up_thresh: self.app.swipe_threshold(),
down_thresh: self.app.swipe_down_threshold(),
down_edge: self.app.swipe_down_edge(),
horizontal_thresh: self.app.swipe_horizontal_threshold(),
surface_width: ss.physical_width(),
surface_height: ss.physical_height(),
}
}
}
pub( crate ) fn hover_affects_paint<Msg: Clone>(
widget_rects: &[crate::widget::LaidOutWidget<Msg>],
idx: Option<usize>,
) -> bool
{
idx.and_then( |i| find_handlers( widget_rects, i ) )
.map( |h| !h.is_slider() )
.unwrap_or( false )
}

69
src/input/pointer/mod.rs Normal file
View File

@@ -0,0 +1,69 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland pointer → ltk dispatch.
//!
//! Each `wl_pointer` event (motion / press / release / axis) translates
//! into a call on the surface's [`GestureState`](super::gesture::GestureState)
//! plus the follow-up side-effects that can only live at the
//! [`AppData`] level (pending-message push, surface redraw, dirty-cache
//! invalidation, window move). The pointer handler adds three things
//! touch does not have:
//!
//! * **Hover tracking** — motion updates `SurfaceState::hovered_idx`
//! before delegating the motion to the gesture machine, since the
//! hovered widget drives visual state independent of whether a press
//! is active.
//! * **Title-bar interaction** — a press inside the client-side title
//! bar either closes the window (close button hit) or initiates a
//! compositor-driven window move. Neither path goes through the
//! gesture machine.
//! * **Wheel / axis scroll** — routed directly into the per-viewport
//! `scroll_offsets` map; axis events do not have a press/release
//! lifecycle so they bypass the state machine entirely.
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind, PointerHandler };
use smithay_client_toolkit::reexports::client::
{
protocol::wl_pointer::WlPointer,
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
pub( crate ) mod enter;
pub( crate ) mod motion;
pub( crate ) mod press;
pub( crate ) mod release;
pub( crate ) mod scroll;
pub( crate ) mod helpers;
impl<A: App> PointerHandler for AppData<A>
{
fn pointer_frame(
&mut self,
conn: &Connection,
qh: &QueueHandle<Self>,
pointer: &WlPointer,
events: &[PointerEvent],
)
{
for event in events
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
self.pointer_focus = focus;
match event.kind
{
PointerEventKind::Enter { .. } => self.on_pointer_enter( conn, qh, pointer, event ),
PointerEventKind::Motion { .. } => self.on_pointer_motion( conn, qh, pointer, event ),
PointerEventKind::Press { button: 0x110, .. } => self.on_pointer_left_press( conn, qh, pointer, event ),
PointerEventKind::Press { button: 0x111, .. } => self.on_pointer_right_click( conn, qh, pointer, event ),
PointerEventKind::Release { button: 0x110, .. } => self.on_pointer_release( conn, qh, pointer, event ),
PointerEventKind::Axis { .. } => self.on_pointer_axis( conn, qh, pointer, event ),
_ => {}
}
}
}
}

145
src/input/pointer/motion.rs Normal file
View File

@@ -0,0 +1,145 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::PointerEvent;
use smithay_client_toolkit::reexports::client::
{
protocol::wl_pointer::WlPointer,
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::{ find_handlers, find_widget_at };
use super::helpers::hover_affects_paint;
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_motion(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
let pp = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pp;
self.app.on_pointer_move( pp.x, pp.y );
// Hover tracking — pointer-only (touch has no hover).
// Runs before the gesture motion so the cache-dirty
// below picks up any hover-dependent redraw request.
let new_hover = find_widget_at( &self.surface( focus ).widget_rects, pp );
let old_hover = self.surface( focus ).hovered_idx;
if new_hover != old_hover
{
let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover )
|| hover_affects_paint( &self.surface( focus ).widget_rects, new_hover );
{
let ss = self.surface_mut( focus );
ss.hovered_idx = new_hover;
if redraw { ss.needs_redraw = true; }
}
match new_hover
{
Some( idx ) => self.arm_tooltip( focus, idx ),
None => self.cancel_tooltip(),
}
}
// Mouse drag-promotion: a left-button press whose hit
// widget carries `on_drag_start` should arm a drag as
// soon as the cursor moves past the threshold, without
// waiting for the touch hold timer. Touch keeps its
// hold-then-drag path because scroll / swipe gestures
// need the in-between motion budget; mouse has a
// dedicated right-click for the menu so left-button
// can be drag-only.
//
// Threshold of 24 px (logical) sits comfortably above
// the gesture machine's 6 px long-press cancel
// tolerance and above crustace's 16 px drag-commit
// threshold, so by the time we promote the next
// motion sample is already past the app's commit
// distance and drag mode latches without flashing
// any half-state.
//
// Promotion is synchronous (`self.app.update(...)`
// directly) so the app's drag state is armed BEFORE
// the `on_drag_move` call below runs — otherwise the
// seed coords land on a `dragging_item = None`
// shell and get lost. We pay the cost of bypassing
// `invalidate_after` for this one msg, but the next
// frame will repaint everything anyway because the
// drag is in flight.
let promote = {
let ss = self.surface( focus );
match ( ss.gesture.long_press_origin, ss.gesture.drag_start_msg.is_some() )
{
( Some( o ), true ) => ( pp.x - o.x ).hypot( pp.y - o.y ) > 24.0,
_ => false,
}
};
if promote
{
let ( ds_msg, origin ) = {
let ss = self.surface_mut( focus );
let m = ss.gesture.drag_start_msg.take().expect( "promote checked is_some" );
let o = ss.gesture.long_press_origin.expect( "promote checked Some(origin)" );
ss.gesture.long_press_start = None;
ss.gesture.long_press_origin = None;
ss.gesture.long_press_msg = None;
ss.gesture.long_press_text_idx = None;
ss.gesture.long_press_fired = true;
ss.request_redraw();
( m, o )
};
self.app.update( ds_msg );
let ( ox, oy ) = self.surface_offset_for( focus );
self.app.on_drag_move( origin.x + ox, origin.y + oy );
self.dirty_caches();
self.stop_button_repeat();
}
// `global_drag` must be sampled AFTER the promotion
// above — promotion flips `long_press_fired` and we
// want the gesture machine to take the drag branch
// for the same motion event that triggered the
// promotion.
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let outcome =
{
let ss = self.surface_mut( focus );
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
};
self.apply_move_outcome( focus, outcome );
// Drag-to-select inside a TextEdit. Runs after the
// gesture machine so the gesture's "did we leave
// the press's hit rect?" reasoning still applies
// to the press itself; for text fields the answer
// is "fine, keep selecting" because we only widen
// the selection while the pointer is still inside
// the same widget rect.
let pressed_text = self.surface( focus ).gesture.pressed_idx
.and_then( |idx|
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text { Some( idx ) } else { None }
} );
if let Some( idx ) = pressed_text
{
self.handle_text_pointer_drag( focus, idx, pp );
}
// Cursor shape: hover may have changed → push the
// new shape to the compositor (no-op when
// unchanged).
self.dispatch_cursor_shape( focus );
}
}

257
src/input/pointer/press.rs Normal file
View File

@@ -0,0 +1,257 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind };
use smithay_client_toolkit::reexports::client::
{
protocol::wl_pointer::WlPointer,
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::{ find_handlers, find_widget_at };
use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_right_click(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
// Right-click: the desktop equivalent of a touch
// long-press. Three cases on the press target:
//
// * Widget with `on_long_press` (Button or
// Pressable that opted in) — fire the message.
// The drag-arm slot (`on_drag_start`) is NOT
// consumed: right-click never enters drag mode,
// so an icon's context menu opens but no drag
// is armed.
// * TextEdit with no user `on_long_press` — open
// the built-in Copy / Cut / Paste menu near the
// click. Selection is preserved (we deliberately
// skip `handle_text_pointer_down`) so the common
// "select text → right-click → Copy" flow works.
// * Anywhere else — dismiss any already-open menu.
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
let hit_idx = find_widget_at( &self.surface( focus ).widget_rects, pos );
let lp_msg = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.long_press_msg() ) );
if let Some( msg ) = lp_msg
{
self.pending_msgs.push( msg );
self.surface_mut( focus ).request_redraw();
} else {
let is_text = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text
{
let idx = hit_idx.unwrap();
if self.surface( focus ).focused_idx != Some( idx )
{
self.set_focus( focus, hit_idx, qh );
}
self.show_context_menu( focus, idx, pos );
} else {
self.hide_context_menu( focus );
}
}
}
pub( super ) fn on_pointer_left_press(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
let serial = if let PointerEventKind::Press { serial, .. } = event.kind
{
serial
} else {
return;
};
self.last_pointer_serial = serial;
self.last_input_serial = serial;
self.cancel_tooltip();
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
{
self.dismiss_main_outside_popups( pos );
}
// Built-in context menu intercepts the press
// before the regular gesture machine. Either an
// item activates (Copy / Cut / Paste) or the
// click is outside the menu and dismisses it —
// in both cases we consume the event.
if self.surface( focus ).context_menu.is_some()
{
if self.handle_context_menu_press( focus, pos )
{
return;
}
}
// Client-side title bar interaction — pointer-only
// (touch never hits a titlebar; layer-shell surfaces
// have titlebar_height == 0).
let sf = self.surface( focus ).scale_factor.max( 1 ) as f32;
let tb_h = self.surface( focus ).titlebar_height * sf;
if tb_h > 0.0 && pos.y < tb_h
{
let close_rect = self.surface( focus ).titlebar_close_rect;
if close_rect.contains( pos )
{
if self.app.on_close_requested()
{
self.exit_requested = true;
}
return;
}
}
// Resize-edge interception. Wins over the titlebar
// drag-move (top-left / top-right corners overlap
// the titlebar) and over the gesture machine.
if let Some( edge ) = self.resize_edge_under_pointer( focus )
{
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
window.resize( &seat, serial, edge );
}
}
return;
}
if tb_h > 0.0 && pos.y < tb_h
{
// Drag to move the window.
if matches!( focus, SurfaceFocus::Main )
{
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
window.move_( &seat, serial );
}
}
}
return;
}
// Past every chrome interception — surface the
// press to the app so embeddings (e.g. an
// embedded WPE WebView) see real button-down
// events that were not consumed by the window
// frame.
self.app.on_pointer_button( pos.x, pos.y, true );
let outcome =
{
let ss = self.surface_mut( focus );
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
// Mark this gesture as mouse-driven so the
// gesture machine's 6 px stray-cancel skips
// the drag-start / long-press slots — mouse
// motion is intentional and should never
// drop the candidate before the pointer-side
// promotion at 24 px gets to fire.
ss.gesture.mouse_press = true;
ss.needs_redraw = true;
result
};
self.set_focus( focus, outcome.hit_idx, qh );
if let Some( msg ) = outcome.initial_slider_msg
{
self.pending_msgs.push( msg );
}
// Press-and-hold repeat: when the hit is a button
// that opted into `.repeating( true )`, fire the
// `on_press` message immediately and arm the
// repeat timer. The release handler in
// `gesture.rs` knows to suppress the regular tap-
// on-release fire for repeating buttons so a
// quick click still counts as exactly one press.
// The timer re-reads `on_press` from the live
// widget tree on every tick — see
// `start_button_repeat` for why.
if let Some( idx ) = outcome.hit_idx
{
let immediate = {
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
if matches!( handlers, Some( WidgetHandlers::Button { repeating: true, .. } ) )
{
handlers.and_then( |h| h.press_msg() )
} else { None }
};
if let Some( msg ) = immediate
{
self.pending_msgs.push( msg );
self.start_button_repeat( focus, idx );
}
}
// Click-to-position the text cursor when the press
// landed on a TextEdit. `set_focus` above moves the
// cursor to the end of the value; this overrides
// it with the byte offset under the pointer and
// collapses the selection there so a subsequent
// drag widens the selection from the click point.
//
// Double-click on a TextEdit selects the word
// under the cursor instead of just positioning.
if let Some( idx ) = outcome.hit_idx
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text
{
// Eye icon hit on a password field
// short-circuits the text-edit
// dispatch — fire the toggle msg and
// skip cursor placement.
if self.handle_password_toggle_press( focus, idx, pos )
{
let _ = self.note_press_for_double_click( pos );
}
else
{
let is_double = self.note_press_for_double_click( pos );
if is_double
{
self.handle_text_select_word( focus, idx, pos );
} else {
self.handle_text_pointer_down( focus, idx, pos );
}
}
} else {
let _ = self.note_press_for_double_click( pos );
}
} else {
let _ = self.note_press_for_double_click( pos );
}
// Slider press → drag may have started; refresh
// the cursor shape so it switches to `Grabbing`.
self.dispatch_cursor_shape( focus );
}
}

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::PointerEvent;
use smithay_client_toolkit::reexports::client::
{
protocol::wl_pointer::WlPointer,
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_release(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
self.app.on_pointer_button( pos.x, pos.y, false );
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
// Cancel any held-button repeat — the press is
// over, so the timer no longer has anything to
// fire against.
self.stop_button_repeat();
// Slider drag (if any) just ended — cursor reverts
// from `Grabbing` to whatever the hovered widget
// asks for.
self.dispatch_cursor_shape( focus );
}
}

View File

@@ -0,0 +1,67 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind };
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_pointer, wl_pointer::WlPointer },
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_axis(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
let ( horizontal, vertical, source ) = if let PointerEventKind::Axis { horizontal, vertical, source, .. } = event.kind
{
( horizontal, vertical, source )
} else {
return;
};
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
let scroll_idx_opt =
{
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find( |( r, _ )| r.contains( pos ) )
.map( |( _, idx )| *idx )
};
if let Some( scroll_idx ) = scroll_idx_opt
{
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
_ => 1.0,
};
let step = vertical.absolute as f32 * multiplier;
let ss = self.surface_mut( focus );
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
*entry = ( *entry + step ).max( 0.0 );
ss.request_redraw();
} else {
// No LTK scroll viewport under the cursor —
// surface the raw axis to the app so embedded
// content (e.g. a WPE view) can handle scrolling
// itself.
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
_ => 1.0,
};
let dx = horizontal.absolute as f32 * multiplier;
let dy = vertical.absolute as f32 * multiplier;
self.app.on_pointer_axis( pos.x, pos.y, dx, dy );
}
}
}

View File

@@ -0,0 +1,89 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use calloop::timer::{ Timer, TimeoutAction };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::event_loop::repeat::ButtonRepeatState;
impl<A: App> AppData<A>
{
/// Schedule a button-press repeat timer. The runtime fires the
/// `on_press` message *immediately* on press too — this fn only
/// arms the held-down repeat path; the first fire happens at
/// the call site so a quick tap still registers as a single
/// press.
///
/// Each timer tick re-reads the live `on_press` from the
/// current widget tree (via the snapshotted `flat_idx`) rather
/// than replaying a captured message. This is what makes
/// stepper-style buttons work: a stepper builds an `on_press`
/// like `"go to value + 5"` at view-build time, so replaying
/// the press-time snapshot after the first fire would re-issue
/// the same target and the value would freeze. By reading
/// `on_press` afresh each tick we pick up the new target the
/// view rebuilt with the updated value.
///
/// No-op when [`Self::effective_repeat_interval`] reports
/// repeat disabled (zero rate, app override of
/// `Duration::ZERO`). Self-cancels on the first tick where the
/// widget at `idx` no longer exists or no longer carries a
/// press message.
pub( crate ) fn start_button_repeat( &mut self, focus: SurfaceFocus, idx: usize )
{
// Cancel any pre-existing repeat first — only one button can
// be in repeat mode at a time, and a fresh press should
// supersede a previous one.
self.stop_button_repeat();
// Button repeat ticks at a fixed 120 ms (~8 Hz). The keyboard
// repeat interval is ~33 ms (30 Hz), which suits cursor /
// character entry but is too aggressive for pointer steppers
// — a date / time picker would walk a full minute in under
// two seconds. 120 ms is fast enough to ramp through values,
// slow enough that the user can still release on the value
// they want.
if matches!( self.effective_repeat_interval(), None ) { return; }
let interval = std::time::Duration::from_millis( 120 );
let delay = self.effective_repeat_delay();
let timer = Timer::from_duration( delay );
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
{
let live_msg = crate::tree::find_handlers(
&data.surface( focus ).widget_rects,
idx,
)
.and_then( |h| h.press_msg() );
match live_msg
{
Some( m ) =>
{
data.pending_msgs.push( m );
TimeoutAction::ToDuration( interval )
}
None =>
{
// Widget gone (view restructured) or no longer
// has an on_press → drop ourselves so the timer
// does not fire forever against a stale slot.
data.button_repeat = None;
TimeoutAction::Drop
}
}
} );
if let Ok( token ) = token
{
self.button_repeat = Some( ButtonRepeatState { token } );
}
}
/// Cancel any active button-press repeat timer.
pub( crate ) fn stop_button_repeat( &mut self )
{
if let Some( state ) = self.button_repeat.take()
{
self.loop_handle.remove( state.token );
}
}
}

100
src/input/repeat/key.rs Normal file
View File

@@ -0,0 +1,100 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::time::Duration;
use smithay_client_toolkit::seat::keyboard::KeyEvent;
use calloop::timer::{ Timer, TimeoutAction };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::event_loop::repeat::KeyRepeatState;
/// Built-in fallback for the initial key-repeat delay when the
/// compositor does not advertise one and the app does not override
/// [`crate::App::key_repeat_delay`].
const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis( 500 );
/// Built-in fallback for the inter-repeat interval when the compositor
/// does not advertise a rate. ~30 Hz, matching the GNOME / KDE default
/// for "fast" without straying into uncomfortably-twitchy territory.
const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis( 33 );
impl<A: App> AppData<A>
{
/// Compute the effective initial repeat delay — app override wins,
/// then compositor info, then a built-in default.
pub( crate ) fn effective_repeat_delay( &self ) -> Duration
{
if let Some( d ) = self.app.key_repeat_delay() { return d; }
if self.compositor_repeat_delay > 0
{
Duration::from_millis( self.compositor_repeat_delay as u64 )
} else {
DEFAULT_REPEAT_DELAY
}
}
/// Compute the effective inter-repeat interval. Same precedence as
/// [`Self::effective_repeat_delay`]: app override → compositor →
/// built-in default. Returns `None` when repeat is disabled by the
/// active source (compositor `RepeatInfo::Disable` with no app
/// override, or an app override of `Some(Duration::ZERO)`).
pub( crate ) fn effective_repeat_interval( &self ) -> Option<Duration>
{
if let Some( d ) = self.app.key_repeat_interval()
{
if d.is_zero() { return None; }
return Some( d );
}
if self.compositor_repeat_rate == 0
{
// Compositor explicitly disabled repeat, app did not
// override — fall back to a built-in default rather than
// disabling, because most environments where the
// compositor reports 0 also fail to send the event in
// the first place. The default keeps the feel close to
// what people expect from a desktop toolkit.
return Some( DEFAULT_REPEAT_INTERVAL );
}
let ms = ( 1000 / self.compositor_repeat_rate ).max( 1 );
Some( Duration::from_millis( ms as u64 ) )
}
/// Schedule a key-repeat timer for the given event. No-op when the
/// app's [`crate::App::key_repeats`] gate returns `false` for this
/// keysym, when repeat is disabled, or when the calloop timer
/// insertion fails (in which case held keys simply do not repeat).
pub( crate ) fn start_key_repeat( &mut self, focus: SurfaceFocus, event: KeyEvent )
{
if !self.app.key_repeats( event.keysym ) { return; }
let interval = match self.effective_repeat_interval()
{
Some( i ) => i,
None => return,
};
let delay = self.effective_repeat_delay();
let event_for_timer = event.clone();
let timer = Timer::from_duration( delay );
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
{
data.dispatch_key( focus, event_for_timer.clone() );
TimeoutAction::ToDuration( interval )
} );
match token
{
Ok( token ) => { self.key_repeat = Some( KeyRepeatState { event, token } ); }
Err( _ ) => {}
}
}
/// Cancel any active key-repeat timer.
pub( crate ) fn stop_key_repeat( &mut self )
{
if let Some( state ) = self.key_repeat.take()
{
self.loop_handle.remove( state.token );
}
}
}

5
src/input/repeat/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub( crate ) mod key;
pub( crate ) mod button;