Files
ltk/src/input/pointer.rs

496 lines
18 KiB
Rust

// 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; }
}
// 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 );
self.app.on_drag_move( origin.x, origin.y );
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;
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(),
}
}
}