First commit. Version 0.1.0
This commit is contained in:
251
src/input/dispatch.rs
Normal file
251
src/input/dispatch.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
// 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 };
|
||||
|
||||
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>
|
||||
{
|
||||
/// 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
|
||||
(
|
||||
&mut self,
|
||||
focus: SurfaceFocus,
|
||||
outcome: MoveOutcome<A::Message>,
|
||||
)
|
||||
{
|
||||
match outcome
|
||||
{
|
||||
MoveOutcome::Idle => {}
|
||||
MoveOutcome::Drag { pos } =>
|
||||
{
|
||||
self.app.on_drag_move( pos.x, pos.y );
|
||||
self.dirty_caches();
|
||||
self.surface_mut( focus ).request_redraw();
|
||||
self.main.request_redraw();
|
||||
}
|
||||
MoveOutcome::Slider { msg } =>
|
||||
{
|
||||
if let Some( m ) = msg
|
||||
{
|
||||
self.pending_msgs.push( m );
|
||||
}
|
||||
self.surface_mut( focus ).request_redraw();
|
||||
}
|
||||
MoveOutcome::Scroll =>
|
||||
{
|
||||
self.surface_mut( focus ).request_redraw();
|
||||
}
|
||||
MoveOutcome::Swipe { up, down, horizontal } =>
|
||||
{
|
||||
if let Some( v ) = up { self.app.on_swipe_progress( v ); }
|
||||
if let Some( v ) = down { self.app.on_swipe_down_progress( v ); }
|
||||
if let Some( v ) = horizontal { self.app.on_swipe_horizontal_progress( v ); }
|
||||
// Swipe-progress callbacks mutate app state outside
|
||||
// `update`, so the cached view tree is stale.
|
||||
self.dirty_caches();
|
||||
self.surface_mut( focus ).request_redraw();
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
ss.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the ordered list of events from a release. A single
|
||||
/// 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
|
||||
(
|
||||
&mut self,
|
||||
focus: SurfaceFocus,
|
||||
events: Vec<ReleaseEvent<A::Message>>,
|
||||
)
|
||||
{
|
||||
for event in events
|
||||
{
|
||||
self.apply_release_event( focus, event );
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_release_event
|
||||
(
|
||||
&mut self,
|
||||
focus: SurfaceFocus,
|
||||
event: ReleaseEvent<A::Message>,
|
||||
)
|
||||
{
|
||||
match event
|
||||
{
|
||||
ReleaseEvent::Drop { pos } =>
|
||||
{
|
||||
if let Some( msg ) = self.app.on_drop( pos.x, pos.y )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
self.clear_long_press_drag();
|
||||
self.dirty_caches();
|
||||
self.main.request_redraw();
|
||||
self.main.frame_pending = false;
|
||||
}
|
||||
ReleaseEvent::SwipeLeft =>
|
||||
{
|
||||
if let Some( msg ) = self.app.on_swipe_left()
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
// When the app handles the release internally (settle
|
||||
// animation, state-only mutation) it returns no
|
||||
// Message, so the update-driven redraw never fires.
|
||||
// Kick the main surface here so `is_animating()` has a
|
||||
// frame to latch onto.
|
||||
self.dirty_caches();
|
||||
self.main.request_redraw();
|
||||
self.main.frame_pending = false;
|
||||
}
|
||||
ReleaseEvent::SwipeRight =>
|
||||
{
|
||||
if let Some( msg ) = self.app.on_swipe_right()
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
self.dirty_caches();
|
||||
self.main.request_redraw();
|
||||
self.main.frame_pending = false;
|
||||
}
|
||||
ReleaseEvent::SwipeUp =>
|
||||
{
|
||||
if let Some( msg ) = self.app.on_swipe_up()
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
self.dirty_caches();
|
||||
self.main.request_redraw();
|
||||
self.main.frame_pending = false;
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
ss.request_redraw();
|
||||
ss.frame_pending = false;
|
||||
}
|
||||
}
|
||||
ReleaseEvent::SwipeDown =>
|
||||
{
|
||||
if let Some( msg ) = self.app.on_swipe_down()
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
self.dirty_caches();
|
||||
self.main.request_redraw();
|
||||
self.main.frame_pending = false;
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
ss.request_redraw();
|
||||
ss.frame_pending = false;
|
||||
}
|
||||
}
|
||||
ReleaseEvent::HorizontalFellThrough =>
|
||||
{
|
||||
// Below threshold: pulse horizontal 0 so a
|
||||
// vertical-dominant gesture that drifted laterally
|
||||
// does not leave the app stuck on a stale horizontal
|
||||
// progress.
|
||||
self.app.on_swipe_horizontal_progress( 0.0 );
|
||||
self.dirty_caches();
|
||||
self.main.request_redraw();
|
||||
self.main.frame_pending = false;
|
||||
}
|
||||
ReleaseEvent::VerticalFellThrough =>
|
||||
{
|
||||
self.app.on_swipe_progress( 0.0 );
|
||||
self.app.on_swipe_down_progress( 0.0 );
|
||||
self.dirty_caches();
|
||||
}
|
||||
ReleaseEvent::PushMsg( msg ) =>
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
ReleaseEvent::EmptyRelease =>
|
||||
{
|
||||
match focus
|
||||
{
|
||||
SurfaceFocus::Main =>
|
||||
{
|
||||
if let Some( msg ) = self.app.on_tap()
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
SurfaceFocus::Overlay( id ) =>
|
||||
{
|
||||
if let Some( msg ) = self.overlay_dismiss_msg( id )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1070
src/input/gesture.rs
Normal file
1070
src/input/gesture.rs
Normal file
File diff suppressed because it is too large
Load Diff
637
src/input/keyboard.rs
Normal file
637
src/input/keyboard.rs
Normal file
@@ -0,0 +1,637 @@
|
||||
// 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 =>
|
||||
{
|
||||
// When a text input is focused, Up/Down move the cursor
|
||||
// between lines first. They only fall through to list
|
||||
// hover-navigation (combo / scrollable list) when the
|
||||
// cursor was already on the topmost / bottommost line —
|
||||
// that lets a user keyboard-step out of a multiline
|
||||
// `text_edit` into surrounding navigable items without
|
||||
// changing focus first.
|
||||
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 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 )
|
||||
{
|
||||
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
if is_text
|
||||
{
|
||||
if event.keysym == Keysym::Left
|
||||
{
|
||||
self.handle_cursor_left( focus, extend );
|
||||
} else {
|
||||
self.handle_cursor_right( focus, extend );
|
||||
}
|
||||
} 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::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 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
|
||||
}
|
||||
}
|
||||
36
src/input/mod.rs
Normal file
36
src/input/mod.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Input-event layer.
|
||||
//!
|
||||
//! Three Wayland input sources — `wl_keyboard`, `wl_pointer`,
|
||||
//! `wl_touch` — land in their respective submodules and translate
|
||||
//! into ltk-level actions. Keyboard is standalone (it drives focus
|
||||
//! and text insertion, no gesture lifecycle); pointer and touch share
|
||||
//! the [`gesture::GestureState`] state machine so press → move →
|
||||
//! release logic (long-press, slider drag, scroll, swipe commit, tap,
|
||||
//! drop) is written once and fed by both sources.
|
||||
//!
|
||||
//! The [`dispatch`] submodule turns gesture outcomes into concrete
|
||||
//! side-effects on [`AppData`](crate::event_loop::AppData): pending
|
||||
//! messages, app callbacks, surface redraws, cache invalidation. It
|
||||
//! is `pub( super )`-scoped so pointer.rs and touch.rs can call it
|
||||
//! without exposing the helpers at the crate root.
|
||||
//!
|
||||
//! With [`GestureState`] owning the press / move / release lifecycle
|
||||
//! and [`dispatch`] owning the side-effects, the Wayland-facing
|
||||
//! handlers stay small and a hypothetical new input source (stylus,
|
||||
//! gamepad) can plug in by feeding the state machine the same way.
|
||||
|
||||
pub( crate ) mod gesture;
|
||||
pub( crate ) mod keyboard;
|
||||
pub( crate ) mod pointer;
|
||||
pub( crate ) mod touch;
|
||||
pub( crate ) mod dispatch;
|
||||
|
||||
// `GestureState` is the only type consumers outside `input/` need —
|
||||
// `AppData` stores one per `SurfaceState` and the long-press deadline
|
||||
// poller reaches through it. The rest (`MoveOutcome`, `PressOutcome`,
|
||||
// `ReleaseEvent`, `SwipeConfig`) stay at `super::gesture::*` because
|
||||
// only the sibling modules in `input/` touch them.
|
||||
pub use gesture::GestureState;
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/input/touch.rs
Normal file
223
src/input/touch.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Wayland touch → ltk dispatch.
|
||||
//!
|
||||
//! `wl_touch` down / up / motion map directly onto the three
|
||||
//! lifecycle methods of [`GestureState`](super::gesture::GestureState),
|
||||
//! same as pointer press / release / motion. Touch has no hover and
|
||||
//! no scroll-axis event, so this file is shorter than `pointer.rs`;
|
||||
//! the two handlers share `apply_move_outcome` /
|
||||
//! `apply_release_events` in `dispatch.rs`.
|
||||
//!
|
||||
//! The only touch-specific state is `AppData::touch_focus`, a
|
||||
//! `HashMap<touch_id, SurfaceFocus>` so a finger that landed on an
|
||||
//! overlay continues to route to that overlay even if it drifts over
|
||||
//! the main surface. Multi-finger tracking is not yet modelled — the
|
||||
//! gesture machine is single-gesture; a second finger arriving while
|
||||
//! the first is pressed overwrites the same slot. Good enough for
|
||||
//! sliders, swipes and taps; a proper multi-touch rewrite is a
|
||||
//! separate refactor.
|
||||
|
||||
use smithay_client_toolkit::seat::touch::TouchHandler;
|
||||
use smithay_client_toolkit::reexports::client::
|
||||
{
|
||||
protocol::{ wl_surface::WlSurface, wl_touch::WlTouch },
|
||||
Connection, QueueHandle,
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState };
|
||||
use crate::tree::find_handlers;
|
||||
|
||||
impl<A: App> TouchHandler for AppData<A>
|
||||
{
|
||||
fn down(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
serial: u32,
|
||||
_time: u32,
|
||||
surface: WlSurface,
|
||||
id: i32,
|
||||
position: ( f64, f64 ),
|
||||
)
|
||||
{
|
||||
self.last_input_serial = serial;
|
||||
let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main );
|
||||
self.touch_focus.insert( id, focus );
|
||||
let pos = self.surface( focus ).to_physical( position.0, position.1 );
|
||||
self.pointer_pos = pos;
|
||||
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
|
||||
{
|
||||
self.dismiss_main_outside_popups( pos );
|
||||
}
|
||||
|
||||
// Built-in context menu intercepts the touch before the
|
||||
// regular gesture machine — same logic as the pointer path.
|
||||
if self.surface( focus ).context_menu.is_some()
|
||||
{
|
||||
if self.handle_context_menu_press( focus, pos )
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let outcome =
|
||||
{
|
||||
let ss = self.surface_mut( focus );
|
||||
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
|
||||
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 — same wiring as the pointer path.
|
||||
if let Some( idx ) = outcome.hit_idx
|
||||
{
|
||||
let immediate = {
|
||||
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
|
||||
if matches!( handlers, Some( crate::widget::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 for touch presses too,
|
||||
// and double-tap selects the word under the press.
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
fn up(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
_serial: u32,
|
||||
_time: u32,
|
||||
id: i32,
|
||||
)
|
||||
{
|
||||
let focus = self.touch_focus.remove( &id ).unwrap_or( SurfaceFocus::Main );
|
||||
// Touch-up does not carry a position in wl_touch — the last
|
||||
// motion's position is the release point.
|
||||
let pos = self.pointer_pos;
|
||||
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 );
|
||||
self.stop_button_repeat();
|
||||
}
|
||||
|
||||
fn motion(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
_time: u32,
|
||||
id: i32,
|
||||
position: ( f64, f64 ),
|
||||
)
|
||||
{
|
||||
let focus = *self.touch_focus.get( &id ).unwrap_or( &SurfaceFocus::Main );
|
||||
let pp = self.surface( focus ).to_physical( position.0, position.1 );
|
||||
self.pointer_pos = pp;
|
||||
|
||||
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 (touch path).
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
fn shape(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
_id: i32,
|
||||
_major: f64,
|
||||
_minor: f64,
|
||||
) {}
|
||||
|
||||
fn orientation(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
_id: i32,
|
||||
_orientation: f64,
|
||||
) {}
|
||||
|
||||
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
|
||||
{
|
||||
self.touch_focus.clear();
|
||||
// The compositor is stealing every active touch — drop all
|
||||
// in-flight gesture state across every surface.
|
||||
let clear = |ss: &mut SurfaceState<A::Message>| { ss.gesture.on_cancel(); };
|
||||
clear( &mut self.main );
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
clear( ss );
|
||||
}
|
||||
self.stop_button_repeat();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user