// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! 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 PointerHandler for AppData { fn pointer_frame( &mut self, _conn: &Connection, qh: &QueueHandle, _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( widget_rects: &[crate::widget::LaidOutWidget], idx: Option, ) -> 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 AppData { /// 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(), } } }