First commit. Version 0.1.0
This commit is contained in:
495
src/input/pointer.rs
Normal file
495
src/input/pointer.rs
Normal file
@@ -0,0 +1,495 @@
|
||||
// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user