gallery/vendor/i-slint-core/input.rs
Greg Pomerantz 2f4768f354 Fix 120Hz scroll jank end-to-end: render-ahead present + full-cache eviction off the UI thread
Three root causes, all fixed and verified on the Pixel 9 Pro:

1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
   render to the vsync wake; RENDER_AHEAD presents the previous frame at
   the start of render() (phase = wake + ~1.1 ms) and holds the new
   frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
   merely dropped, before the next acquire (AlreadyAcquired panic at
   launch otherwise); discard_pending_frame presents too.

2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
   through a ~20-cell viewport forever (137 decodes/20 s) and each
   eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
   ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
   cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
   + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).

3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).

Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.

Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).

Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
2026-10-30 02:49:23 -04:00

3401 lines
132 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
// cSpell: ignore altgr rpos Unapply
/*! Module handling mouse events
*/
#![warn(missing_docs)]
use crate::cursor::MouseCursorInner;
use crate::item_tree::ItemTreeRc;
use crate::item_tree::{ItemRc, ItemWeak, VisitChildrenResult};
use crate::items::{
AllowedDragActions, BuiltInMouseCursor, DropEvent, ItemRef, OperatingSystemType,
TextCursorDirection,
};
pub use crate::items::{FocusReason, KeyEvent, KeyboardModifiers, PointerEventButton};
use crate::lengths::{ItemTransform, LogicalPoint, LogicalVector};
use crate::window::{WindowAdapter, WindowInner};
use crate::{Coord, Property, SharedString};
use alloc::rc::Rc;
use alloc::vec::Vec;
use const_field_offset::FieldOffsets;
use core::cell::Cell;
use core::fmt::Display;
use core::pin::Pin;
use core::time::Duration;
/// A mouse or touch event
///
/// The only difference with [`crate::platform::WindowEvent`] is that it uses untyped `Point`
/// TODO: merge with platform::WindowEvent
#[repr(C)]
#[derive(Debug, Clone, PartialEq)]
pub enum MouseEvent {
/// The mouse or finger was pressed
Pressed {
/// The position of the pointer when the event happened.
position: LogicalPoint,
/// The button that was pressed.
button: PointerEventButton,
/// The current click count reported for this press.
click_count: u8,
/// The touch ID if the event originated from touch input.
touch_finger_id: i32,
},
/// The mouse or finger was released
Released {
/// The position of the pointer when the event happened.
position: LogicalPoint,
/// The button that was released.
button: PointerEventButton,
/// The current click count reported for this release.
click_count: u8,
/// The touch ID if the event originated from touch input.
touch_finger_id: i32,
},
/// The position of the pointer has changed
Moved {
/// The new position of the pointer.
position: LogicalPoint,
/// The touch ID if the event originated from touch input.
touch_finger_id: i32,
},
/// Wheel was operated.
Wheel {
/// The position of the pointer when the event happened.
position: LogicalPoint,
/// The horizontal scroll delta in logical pixels.
delta_x: Coord,
/// The vertical scroll delta in logical pixels.
delta_y: Coord,
/// The gesture phase reported for the wheel event.
phase: TouchPhase,
},
/// The mouse is being dragged over this item.
/// [`InputEventResult::EventIgnored`] means that the item does not handle the drag operation
/// and [`InputEventResult::EventAccepted`] means that the item can accept it.
DragMove {
/// The dragged payload and its current position/proposed action.
event: DropEvent,
/// The actions the drag source permits.
allowed: AllowedDragActions,
},
/// The mouse is released while dragging over this item.
Drop {
/// The dragged payload and its current position/proposed action.
event: DropEvent,
/// The actions the drag source permits.
allowed: AllowedDragActions,
},
/// A platform-recognized pinch gesture (macOS/iOS trackpad, Qt).
PinchGesture {
/// The focal position of the gesture.
position: LogicalPoint,
/// The incremental scale delta for this gesture update.
delta: f32,
/// The gesture phase reported by the platform.
phase: TouchPhase,
},
/// A platform-recognized rotation gesture (macOS/iOS trackpad, Qt).
RotationGesture {
/// The focal position of the gesture.
position: LogicalPoint,
/// The incremental rotation in degrees, where positive means clockwise.
delta: f32,
/// The gesture phase reported by the platform.
phase: TouchPhase,
},
/// The mouse exited the item or component
Exit,
}
impl MouseEvent {
/// The touch ID if the event originated from touch input.
pub fn touch_finger_id(&self) -> i32 {
match self {
MouseEvent::Pressed { touch_finger_id, .. } => *touch_finger_id,
MouseEvent::Released { touch_finger_id, .. } => *touch_finger_id,
MouseEvent::Moved { touch_finger_id, .. } => *touch_finger_id,
_ => 0,
}
}
/// Whether the event originates from a touch screen rather than a mouse.
pub fn is_from_touch(&self) -> bool {
// touch events carry the finger id + 1, events from a mouse carry 0
self.touch_finger_id() != 0
}
/// The position of the cursor for this event, if any
pub fn position(&self) -> Option<LogicalPoint> {
match self {
MouseEvent::Pressed { position, .. } => Some(*position),
MouseEvent::Released { position, .. } => Some(*position),
MouseEvent::Moved { position, .. } => Some(*position),
MouseEvent::Wheel { position, .. } => Some(*position),
MouseEvent::PinchGesture { position, .. } => Some(*position),
MouseEvent::RotationGesture { position, .. } => Some(*position),
MouseEvent::DragMove { event: e, .. } | MouseEvent::Drop { event: e, .. } => {
Some(crate::lengths::logical_point_from_api(e.position))
}
MouseEvent::Exit => None,
}
}
/// Translate the position by the given value
pub fn translate(&mut self, vec: LogicalVector) {
let pos = match self {
MouseEvent::Pressed { position, .. } => Some(position),
MouseEvent::Released { position, .. } => Some(position),
MouseEvent::Moved { position, .. } => Some(position),
MouseEvent::Wheel { position, .. } => Some(position),
MouseEvent::PinchGesture { position, .. } => Some(position),
MouseEvent::RotationGesture { position, .. } => Some(position),
MouseEvent::DragMove { event: e, .. } | MouseEvent::Drop { event: e, .. } => {
e.position = crate::api::LogicalPosition::from_euclid(
crate::lengths::logical_point_from_api(e.position) + vec,
);
None
}
MouseEvent::Exit => None,
};
if let Some(pos) = pos {
*pos += vec;
}
}
/// Transform the position by the given item transform.
pub fn transform(&mut self, transform: ItemTransform) {
let pos = match self {
MouseEvent::Pressed { position, .. } => Some(position),
MouseEvent::Released { position, .. } => Some(position),
MouseEvent::Moved { position, .. } => Some(position),
MouseEvent::Wheel { position, .. } => Some(position),
MouseEvent::PinchGesture { position, .. } => Some(position),
MouseEvent::RotationGesture { position, .. } => Some(position),
MouseEvent::DragMove { event: e, .. } | MouseEvent::Drop { event: e, .. } => {
e.position = crate::api::LogicalPosition::from_euclid(
transform
.transform_point(crate::lengths::logical_point_from_api(e.position).cast())
.cast(),
);
None
}
MouseEvent::Exit => None,
};
if let Some(pos) = pos {
*pos = transform.transform_point(pos.cast()).cast();
}
}
/// Set the click count of the pressed or released event
fn set_click_count(&mut self, count: u8) {
match self {
MouseEvent::Pressed { click_count, .. } | MouseEvent::Released { click_count, .. } => {
*click_count = count
}
_ => (),
}
}
}
/// The mouse events a backend can deliver to the runtime.
#[allow(missing_docs)]
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BackendMouseEvent {
/// The mouse or finger was pressed
Pressed {
position: LogicalPoint,
button: PointerEventButton,
click_count: u8,
touch_finger_id: i32,
},
/// The mouse or finger was released
Released {
position: LogicalPoint,
button: PointerEventButton,
click_count: u8,
touch_finger_id: i32,
},
/// The position of the pointer has changed
Moved { position: LogicalPoint, touch_finger_id: i32 },
/// Wheel was operated.
Wheel { position: LogicalPoint, delta_x: Coord, delta_y: Coord, phase: TouchPhase },
/// A platform-recognized pinch gesture (macOS/iOS trackpad, Qt).
PinchGesture { position: LogicalPoint, delta: f32, phase: TouchPhase },
/// A platform-recognized rotation gesture (macOS/iOS trackpad, Qt).
RotationGesture { position: LogicalPoint, delta: f32, phase: TouchPhase },
/// The mouse exited the item or component
Exit,
}
impl From<BackendMouseEvent> for MouseEvent {
fn from(event: BackendMouseEvent) -> Self {
match event {
BackendMouseEvent::Pressed { position, button, click_count, touch_finger_id } => {
Self::Pressed { position, button, click_count, touch_finger_id }
}
BackendMouseEvent::Released { position, button, click_count, touch_finger_id } => {
Self::Released { position, button, click_count, touch_finger_id }
}
BackendMouseEvent::Moved { position, touch_finger_id } => {
Self::Moved { position, touch_finger_id }
}
BackendMouseEvent::Wheel { position, delta_x, delta_y, phase } => {
Self::Wheel { position, delta_x, delta_y, phase }
}
BackendMouseEvent::PinchGesture { position, delta, phase } => {
Self::PinchGesture { position, delta, phase }
}
BackendMouseEvent::RotationGesture { position, delta, phase } => {
Self::RotationGesture { position, delta, phase }
}
BackendMouseEvent::Exit => Self::Exit,
}
}
}
/// The drag and drop events a backend can deliver, through [`WindowInner::process_drag_event`].
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq)]
pub enum BackendDragEvent {
/// A drag is hovering over the window.
Move { event: DropEvent, allowed: AllowedDragActions },
/// A drag was released over the window.
Drop { event: DropEvent, allowed: AllowedDragActions },
/// A drag left the window, or was cancelled while hovering over it.
Leave,
}
impl From<BackendDragEvent> for MouseEvent {
fn from(event: BackendDragEvent) -> Self {
match event {
BackendDragEvent::Move { event, allowed } => Self::DragMove { event, allowed },
BackendDragEvent::Drop { event, allowed } => Self::Drop { event, allowed },
// A drag leaving tears down the hover state the same way the pointer leaving does.
BackendDragEvent::Leave => Self::Exit,
}
}
}
/// Phase of a touch, gesture event or wheel event.
/// A touchpad is recognized as wheel event and therefore
/// we need to find out when the touch event starts and ends
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TouchPhase {
/// The gesture began (e.g., first finger touched or platform gesture started).
Started,
/// The gesture is ongoing (e.g., fingers moved or platform gesture updated).
Moved,
/// The gesture completed normally.
Ended,
/// The gesture was cancelled (e.g., interrupted by the system) or the mouse wheel was used
Cancelled,
}
/// This value is returned by the `input_event` function of an Item
/// to notify the run-time about how the event was handled and
/// what the next steps are.
/// See [`crate::items::ItemVTable::input_event`].
#[repr(u8)]
#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
pub enum InputEventResult {
/// The event was accepted. This may result in additional events, for example
/// accepting a mouse move will result in a MouseExit event later.
EventAccepted,
/// The event was ignored.
#[default]
EventIgnored,
/// All further mouse events need to be sent to this item or component
GrabMouse,
/// Will start a drag operation. Can only be returned from a [`crate::items::DragArea`] item.
StartDrag,
}
/// This value is returned by the `input_event_filter_before_children` function, which
/// can specify how to further process the event.
/// See [`crate::items::ItemVTable::input_event_filter_before_children`].
#[repr(C)]
#[derive(Debug, Copy, Clone, PartialEq, Default)]
pub enum InputEventFilterResult {
/// The event is going to be forwarded to children, then the [`crate::items::ItemVTable::input_event`]
/// function is called
#[default]
ForwardEvent,
/// The event will be forwarded to the children, but the [`crate::items::ItemVTable::input_event`] is not
/// going to be called for this item
ForwardAndIgnore,
/// Just like `ForwardEvent`, but even in the case that children grabs the mouse, this function
/// will still be called for further events
ForwardAndInterceptGrab,
/// The event will not be forwarded to children, if a child already had the grab, the
/// grab will be cancelled with a [`MouseEvent::Exit`] event
Intercept,
/// The event will be forwarded to the children with a delay (in milliseconds), unless it is
/// being intercepted.
/// This is what happens when the flickable wants to delay the event.
/// This should only be used for Press event, and the event will be sent after the delay, or
/// if a release event is seen before that delay
/// If any other component is handling the event it will be not handled by the component returned this result
//(Can't use core::time::Duration because it is not repr(c))
DelayForwarding(u64),
/// Like `ForwardAndIgnore`, but the item still receives a [`MouseEvent::Exit`]
/// when the pointer leaves, even if a sibling handles the event in between.
ForwardAndObserve,
}
/// This module contains the constant character code used to represent the keys.
#[allow(missing_docs, non_upper_case_globals)]
pub mod key_codes {
macro_rules! declare_consts_for_special_keys {
($($char:literal # $name:ident # $($shifted:ident)? $(=> $($_muda:ident)? # $($_qt:ident)|* # $($_winit:ident $(($_pos:ident))?)|* # $($_xkb:ident)|* )? ;)*) => {
$(pub const $name : char = $char;)*
#[allow(missing_docs)]
#[derive(Debug, Copy, Clone, PartialEq)]
#[non_exhaustive]
/// The `Key` enum is used to map a specific key by name e.g. `Key::Control` to an
/// internal used unicode representation. The enum is convertible to [`std::char`] and [`slint::SharedString`](`crate::SharedString`).
/// Use this with [`slint::platform::WindowEvent`](`crate::platform::WindowEvent`) to supply key events to Slint's platform abstraction.
///
/// # Example
///
/// Send an tab key press event to a window
///
/// ```
/// use slint::platform::{WindowEvent, Key};
/// fn send_tab_pressed(window: &slint::Window) {
/// window.dispatch_event(WindowEvent::KeyPressed { text: Key::Tab.into() });
/// }
/// ```
pub enum Key {
$($name,)*
}
impl From<Key> for char {
fn from(k: Key) -> Self {
match k {
$(Key::$name => $name,)*
}
}
}
impl From<Key> for crate::SharedString {
fn from(k: Key) -> Self {
char::from(k).into()
}
}
};
}
i_slint_common::for_each_keys!(declare_consts_for_special_keys);
}
/// Internal struct to maintain the pressed/released state of the keys that
/// map to keyboard modifiers.
#[derive(Clone, Copy, Default, Debug)]
pub(crate) struct InternalKeyboardModifierState {
left_alt: bool,
right_alt: bool,
altgr: bool,
left_control: bool,
right_control: bool,
left_meta: bool,
right_meta: bool,
left_shift: bool,
right_shift: bool,
}
impl InternalKeyboardModifierState {
/// Updates a flag of the modifiers if the key of the given text is pressed.
/// Returns an updated modifier if detected; None otherwise;
pub(crate) fn state_update(mut self, pressed: bool, text: &SharedString) -> Option<Self> {
if let Some(key_code) = text.chars().next() {
match key_code {
key_codes::Alt => self.left_alt = pressed,
key_codes::AltGr => self.altgr = pressed,
key_codes::Control => self.left_control = pressed,
key_codes::ControlR => self.right_control = pressed,
key_codes::Shift => self.left_shift = pressed,
key_codes::ShiftR => self.right_shift = pressed,
key_codes::Meta => self.left_meta = pressed,
key_codes::MetaR => self.right_meta = pressed,
_ => return None,
};
// Encoded keyboard modifiers must appear as individual key events. This could
// be relaxed by implementing a string split, but right now WindowEvent::KeyPressed
// holds only a single char.
debug_assert_eq!(key_code.len_utf8(), text.len());
}
Some(self)
}
pub fn shift(&self) -> bool {
self.right_shift || self.left_shift
}
pub fn alt(&self) -> bool {
self.right_alt || self.left_alt
}
pub fn meta(&self) -> bool {
self.right_meta || self.left_meta
}
pub fn control(&self) -> bool {
self.right_control || self.left_control
}
pub fn modifiers_for(&self, _event: &InternalKeyEvent) -> KeyboardModifiers {
#[allow(unused_mut)]
let mut alt = self.alt();
#[allow(unused_mut)]
let mut control = self.control();
// Windows treats Ctrl+Alt as implying AltGr, but not vice-versa
// Unfortunately, our different backends produce different key combinations here.
//
// ## Qt
// Qt always sends Ctrl + Alt instead of AltGr, and does not tell us whether this
// was interpreted as AltGr or not. So with Qt we have no way of telling whether
// AltGr is pressed, and we have to assume that it is pressed whenever Ctrl + Alt is pressed.
// In that case the `text_without_modifiers` is also not set.
//
// ## Winit
// Winit sends the actual Ctrl/Alt/AltGr keypress correctly.
// With winit we can detect whether ctrl+alt actually caused a AltGr conversion or not,
// by checking whether the text_without_modifiers is different from the event text.
//
// ## Wasm
// Winit on the web for some reasons sends first a Ctrl and then AltGr event when only AltGr
// is pressed.
// So there we need to get rid of the additional Ctrl event whenever AltGr is pressed.
#[cfg(target_os = "windows")]
{
// Non-web windows (Usually winit or Qt)
if !self.altgr && self.control() && self.alt() {
// AltGr is not pressed, but Ctrl+Alt is pressed.
// Try to detect if an AltGr conversion occurred.
// If so, disable Ctrl and Alt
//
// On platforms that don't provide text_without_modifiers, fall back to a simple
// heuristic that assumes A-Z & 0-9 are not produced with AltGr, but all other keys are.
let implies_altgr = if _event.text_without_modifiers.is_empty() {
_event.key_event.text.chars().any(|c| !c.is_ascii_alphanumeric())
} else {
_event.text_without_modifiers.to_lowercase()
!= _event.key_event.text.to_lowercase()
};
if implies_altgr {
alt = false;
control = false;
}
}
}
#[cfg(target_family = "wasm")]
if crate::detect_operating_system() == OperatingSystemType::Windows {
// Non-native windows (e.g. Winit on the web)
// This currently injects additional Ctrl events, so remove those if AltGr is
// pressed.
let is_altgr = self.altgr
|| (self.control()
&& self.alt()
&& _event.key_event.text.chars().any(|c| !c.is_ascii_alphanumeric()));
if is_altgr {
alt = false;
control = false;
}
}
KeyboardModifiers { alt, control, meta: self.meta(), shift: self.shift() }
}
}
impl From<InternalKeyboardModifierState> for KeyboardModifiers {
fn from(internal_state: InternalKeyboardModifierState) -> Self {
Self {
alt: internal_state.alt(),
control: internal_state.control(),
meta: internal_state.meta(),
shift: internal_state.shift(),
}
}
}
#[i_slint_core_macros::slint_doc]
/// The `Keys` type is the Rust representation of Slint's `keys` primitive type.
///
/// It can be created with the `@keys` macro in Slint and defines which key event(s) activate a KeyBinding.
///
/// See also the Slint documentation on [Key Bindings](slint:KeyBindingOverview).
///
/// In `.slint` files, `Keys` values are typically created via the `@keys(...)` macro.
/// From backend code, they can be created from a list of string parts with the similar
/// syntax as the macro:
///
/// ```rust
/// use slint::Keys;
///
/// let save = Keys::from_parts(["Control", "S"])?;
/// let undo = Keys::from_parts(["Control", "Shift?", "Z"])?;
/// let f5 = Keys::from_parts(["F5"])?;
/// let zoom_in = Keys::from_parts(["Control", "Plus"])?;
/// let euro = Keys::from_parts(["Control", "€"])?;
/// let empty = Keys::from_parts([])?; // same as Keys::default()
/// # Ok::<(), i_slint_core::input::KeysParseError>(())
/// ```
/// ## Parts format
///
/// Each element is either a modifier or a key (case-sensitive, matching the `@keys` macro):
/// - **Modifiers** (optional): `Control`, `Alt`, `Shift`, `Meta`
/// - **Optional modifiers**: `Shift?`, `Alt?` (match regardless of that modifier's state)
/// - **Named key** (required, exactly one): A named key (`Return`, `Tab`, `F1`, `Plus`, `Space`, `A`–`Z`, etc.)
/// - **String literal fallback**: If no named key matches, the part is treated as a string
/// literal — it must be a single lowercase grapheme cluster (e.g., `"€"`, `"é"`)
///
/// Keys with layout-dependent shifted variants (digits `Digit0`–`Digit9`, symbols like
/// `Plus`, `Comma`, etc.) automatically get `Shift?` behavior, just like the `@keys` macro.
#[derive(Clone, Eq, PartialEq, Default)]
#[repr(C)]
pub struct Keys {
inner: KeysInner,
}
/// Internal representation of key-parse errors. Variants are not part of the public API.
#[derive(Debug, Clone, PartialEq, Eq)]
enum KeysParseErrorInner {
/// No key was found (only modifiers were specified).
NoKey,
/// More than one non-modifier key was found.
MultipleKeys,
/// A string literal contains more than one grapheme cluster.
/// The contained string is the offending key part (e.g. `"ab"` or `"return"`).
MultipleGraphemeClusters(SharedString),
/// A string literal is not lowercase.
/// The contained string is the offending key part (e.g. `"É"`).
NotLowercase(SharedString),
/// Incompatible modifiers were specified (e.g. both `Shift` and `Shift?`).
/// The contained string is a human-readable description of the conflict.
IncompatibleModifiers(SharedString),
}
/// Error type returned when constructing a [`Keys`] from string parts.
///
/// This is an opaque error type. Use its [`Display`] implementation
/// to obtain a human-readable description of the problem.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeysParseError(KeysParseErrorInner);
impl core::fmt::Display for KeysParseError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match &self.0 {
KeysParseErrorInner::NoKey => write!(f, "no key found (only modifiers)"),
KeysParseErrorInner::MultipleKeys => {
write!(f, "multiple non-modifier keys found")
}
KeysParseErrorInner::MultipleGraphemeClusters(s) => {
write!(f, "key string must be a single grapheme cluster, got: {s}")
}
KeysParseErrorInner::NotLowercase(s) => {
let lower = s.to_lowercase();
write!(f, "key string must be lowercase, use \"{lower}\" instead")
}
KeysParseErrorInner::IncompatibleModifiers(msg) => write!(f, "{msg}"),
}
}
}
impl core::error::Error for KeysParseError {}
use i_slint_common::key_codes::{ShiftBehavior, lookup_key_name};
/// Re-exported in private_unstable_api to create a Keys struct.
pub fn make_keys(
key: SharedString,
modifiers: KeyboardModifiers,
ignore_shift: bool,
ignore_alt: bool,
) -> Keys {
Keys {
inner: KeysInner { key: key.to_lowercase().into(), modifiers, ignore_shift, ignore_alt },
}
}
#[cfg(feature = "ffi")]
#[allow(unsafe_code)]
pub(crate) mod ffi {
use crate::api::ToSharedString as _;
use super::*;
#[unsafe(no_mangle)]
pub unsafe extern "C" fn slint_keys(
key: &SharedString,
alt: bool,
control: bool,
shift: bool,
meta: bool,
ignore_shift: bool,
ignore_alt: bool,
out: &mut Keys,
) {
*out = make_keys(
key.clone(),
KeyboardModifiers { alt, control, shift, meta },
ignore_shift,
ignore_alt,
);
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn slint_keys_debug_string(shortcut: &Keys, out: &mut SharedString) {
*out = crate::format!("{shortcut:?}");
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn slint_keys_to_string(shortcut: &Keys, out: &mut SharedString) {
*out = shortcut.to_shared_string();
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn slint_keys_from_parts(
parts: crate::slice::Slice<'_, SharedString>,
out: &mut Keys,
) -> bool {
match keys_from_parts(parts.as_slice().iter().map(|s| s.as_str())) {
Ok(keys) => {
*out = keys;
true
}
Err(_) => false,
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn slint_keys_to_parts(
keys: &Keys,
out: &mut crate::SharedVector<SharedString>,
) {
*out = keys.to_parts().map(SharedString::from).collect();
}
}
/// Normalize a key string: lowercase and NFC-normalize.
fn normalize_key(key: &str) -> SharedString {
let lowered = key.to_lowercase();
cfg_if::cfg_if! {
if #[cfg(feature = "shared-parley")] {
let normalizer = icu_normalizer::ComposingNormalizer::new_nfc();
let normalized = normalizer.normalize(&lowered);
SharedString::from(normalized.as_ref())
} else {
SharedString::from(lowered.as_str())
}
}
}
fn keys_from_parts<'a>(parts: impl Iterator<Item = &'a str>) -> Result<Keys, KeysParseError> {
keys_from_parts_inner(parts).map_err(KeysParseError)
}
fn keys_from_parts_inner<'a>(
parts: impl Iterator<Item = &'a str>,
) -> Result<Keys, KeysParseErrorInner> {
use unicode_segmentation::UnicodeSegmentation;
let mut modifiers = KeyboardModifiers::default();
let mut ignore_shift = false;
let mut ignore_alt = false;
let mut key_part: Option<&str> = None;
for part in parts {
// Parts are *not* trimmed: whitespace is significant, so `" "`, `"\t"` and
// `"\n"` are valid literal spellings of the Space, Tab and Return keys, the
// same way `@keys(" ")` is valid in Slint. Trimming would silently swallow
// them and make those keys unreachable through a literal. Empty parts carry
// no information and are skipped, which keeps `from_parts([""])` equivalent
// to `from_parts([])`.
if part.is_empty() {
continue;
}
match part {
"Control" => modifiers.control = true,
"Alt" => {
if ignore_alt {
return Err(KeysParseErrorInner::IncompatibleModifiers(
"Alt and Alt? cannot be combined".into(),
));
}
modifiers.alt = true;
}
"Shift" => {
if ignore_shift {
return Err(KeysParseErrorInner::IncompatibleModifiers(
"Shift and Shift? cannot be combined".into(),
));
}
modifiers.shift = true;
}
"Meta" => modifiers.meta = true,
"Shift?" => {
if modifiers.shift {
return Err(KeysParseErrorInner::IncompatibleModifiers(
"Shift and Shift? cannot be combined".into(),
));
}
ignore_shift = true;
}
"Alt?" => {
if modifiers.alt {
return Err(KeysParseErrorInner::IncompatibleModifiers(
"Alt and Alt? cannot be combined".into(),
));
}
ignore_alt = true;
}
_ => {
if key_part.is_some() {
return Err(KeysParseErrorInner::MultipleKeys);
}
key_part = Some(part);
}
}
}
let key_name = match key_part {
Some(k) => k,
None if modifiers == KeyboardModifiers::default() && !ignore_shift && !ignore_alt => {
// Empty input (or only empty parts) → Keys::default(), same as @keys()
return Ok(Keys::default());
}
None => return Err(KeysParseErrorInner::NoKey),
};
// First: try named-key lookup (case-sensitive, like the @keys macro)
if let Some((key_char, shift_behavior)) = lookup_key_name(key_name) {
// Auto-set ignore_shift for keys with localized shifted variants
if matches!(shift_behavior, ShiftBehavior::LocalizedShiftable { .. }) {
if modifiers.shift {
return Err(KeysParseErrorInner::IncompatibleModifiers(
alloc::format!(
"Key bindings involving {key_name} ignore Shift to support different keyboard layouts; remove Shift"
).into(),
));
}
ignore_shift = true;
}
// Key code literals in key_codes.rs are already NFC-normalized, just lowercase.
let key: SharedString = key_char.to_lowercase().collect::<alloc::string::String>().into();
return Ok(Keys { inner: KeysInner { key, modifiers, ignore_shift, ignore_alt } });
}
// Fallback: treat as a string literal (like @keys("€"))
// Must be a single grapheme cluster
let grapheme_count = key_name.graphemes(true).count();
if grapheme_count > 1 {
return Err(KeysParseErrorInner::MultipleGraphemeClusters(key_name.into()));
}
// Must be lowercase
let lowered = key_name.to_lowercase();
if lowered != key_name {
return Err(KeysParseErrorInner::NotLowercase(key_name.into()));
}
let key = normalize_key(key_name);
Ok(Keys { inner: KeysInner { key, modifiers, ignore_shift, ignore_alt } })
}
/// Internal representation of the `Keys` type.
/// This is semver exempt and is only used to set up the native menu in the backends.
#[derive(PartialEq, Eq, Clone, Default)]
#[repr(C)]
pub struct KeysInner {
/// The `key` used to trigger the shortcut
///
/// Note: This is currently converted to lowercase when the shortcut is created!
pub key: SharedString,
/// `KeyboardModifier`s that need to be pressed for the shortcut to fire
pub modifiers: KeyboardModifiers,
/// Whether to ignore shift state when matching the shortcut
pub ignore_shift: bool,
/// Whether to ignore alt state when matching the shortcut
pub ignore_alt: bool,
}
impl KeysInner {
/// Private access to the KeysInner for a given Keys value.
pub fn from_pub(keys: &Keys) -> &Self {
&keys.inner
}
}
impl Keys {
#[i_slint_core_macros::slint_doc]
/// Create a `Keys` from an iterator of string parts (matching `@keys` macro syntax).
///
/// Each element is either a modifier (`Control`, `Shift`, `Alt`, `Meta`, `Shift?`, `Alt?`)
/// or a key. Keys are first looked up by name (case-sensitive) in the Key namespace;
/// if not found, treated as a string literal (must be a single lowercase grapheme cluster).
/// Exactly one non-modifier key must be present.
///
/// Parts are taken verbatim — they are not trimmed — so whitespace is significant:
/// `" "`, `"\t"` and `"\n"` are literal spellings of the `Space`, `Tab` and `Return`
/// keys, just as `@keys(" ")` is valid in Slint. A part must therefore match a
/// modifier or key exactly; `" Control "` is not the `Control` modifier.
///
/// An empty iterator returns `Keys::default()` (same as `@keys()`). Empty parts are
/// skipped, so `from_parts([""])` is also `Keys::default()`.
///
/// See also the Slint documentation on [Key Bindings](slint:KeyBindingOverview).
///
/// Note: This currently only supports a **single shortcut** (one key + modifiers).
pub fn from_parts<'a>(
parts: impl IntoIterator<Item = &'a str>,
) -> Result<Keys, KeysParseError> {
keys_from_parts(parts.into_iter())
}
#[i_slint_core_macros::slint_doc]
/// Decompose this `Keys` value into the string parts that
/// [`Keys::from_parts`] accepts.
///
/// See also the Slint documentation on [Key Bindings](slint:KeyBindingOverview).
///
/// A `Keys` value that is converted into parts and then re-created from those parts
/// with [`Keys::from_parts`] will be equal to the input `Keys` value:
///
/// ```
/// use slint::Keys;
/// let k = Keys::from_parts(["Control", "Shift?", "Z"])?;
/// let k_from_parts = Keys::from_parts(k.to_parts())?;
/// assert_eq!(k_from_parts, k);
/// # Ok::<(), i_slint_core::input::KeysParseError>(())
/// ```
///
/// Note that while a round-trip guarantees that the resulting `Keys` instances will
/// be equal, the parts returned by `to_parts` can be different from the parts used
/// to construct the `Keys` instance with `from_parts`.
///
/// A part is not necessarily printable, so a text format storing parts has to quote
/// or escape them. The
/// [`runtime_key_bindings`](https://github.com/slint-ui/slint/tree/master/examples/runtime_key_bindings)
/// example shows one way to persist a user-configured shortcut and restore it.
///
/// An empty `Keys` (i.e. [`Keys::default()`]) returns an empty iterator.
pub fn to_parts(&self) -> impl Iterator<Item = &str> {
let inner = &self.inner;
let has_key = !inner.key.is_empty();
// Order matches the `@keys` macro / Debug impl: Meta, Control, Alt, Shift.
//
// The key itself is always emitted as the stored character, never as the
// name it may have been created from. Names are not reversed back: a
// `LocalizedShiftable` name auto-applies `ignore_shift` on re-parse, so
// emitting one would break the round-trip of a literal such as
// `["Control", "+"]` (which has `ignore_shift = false`). Emitting the raw
// character lets `ignore_shift` be carried explicitly by `Shift?`, so
// `@keys(Control + Plus)` comes back as `["Control", "Shift?", "+"]`.
[
(has_key && inner.modifiers.meta).then_some("Meta"),
(has_key && inner.modifiers.control).then_some("Control"),
(has_key && inner.modifiers.alt).then_some("Alt"),
(has_key && !inner.modifiers.alt && inner.ignore_alt).then_some("Alt?"),
(has_key && inner.modifiers.shift).then_some("Shift"),
(has_key && !inner.modifiers.shift && inner.ignore_shift).then_some("Shift?"),
has_key.then(|| inner.key.as_str()),
]
.into_iter()
.flatten()
}
/// Check whether a `Keys` can be triggered by the given `KeyEvent`
pub(crate) fn matches(&self, key_event: &KeyEvent) -> bool {
let inner = &self.inner;
// An empty Keys is never triggered, even if the modifiers match.
if inner.key.is_empty() {
return false;
}
// TODO: Should this check the event_type and only match on KeyReleased?
let mut expected_modifiers = inner.modifiers;
if inner.ignore_shift {
expected_modifiers.shift = key_event.modifiers.shift;
}
if inner.ignore_alt {
expected_modifiers.alt = key_event.modifiers.alt;
}
// Note: The shortcut's key is already in lowercase and NFC-normalized
// (by the compiler and backends respectively), so we only need to
// lowercase the event text. Backends are expected to NFC-normalize
// key event text before dispatching.
//
// This improves our handling of CapsLock and Shift, as the event text will be in uppercase
// if caps lock is active, even if shift is not pressed.
let event_text = key_event.text.chars().flat_map(|character| character.to_lowercase());
event_text.eq(inner.key.chars()) && key_event.modifiers == expected_modifiers
}
fn format_key_for_display(&self) -> crate::SharedString {
let key_str = self.inner.key.as_str();
let first_char = key_str.chars().next();
if let Some(first_char) = first_char {
macro_rules! check_special_key {
($($char:literal # $name:ident # $($shifted:ident)? $(=> $($_muda:ident)? # $($qt:ident)|* # $($winit:ident $(($_pos:ident))?)|* # $($xkb:ident)|*)? ;)*) => {
match first_char {
$($(
// Use $qt as a marker - if it exists, generate the check
$char => {
let _ = stringify!($($qt)|*); // Use $qt to enable this branch
return stringify!($name).into();
}
)?)*
_ => ()
}
};
}
i_slint_common::for_each_keys!(check_special_key);
}
if key_str.chars().count() == 1 {
return key_str.to_uppercase().into();
}
key_str.into()
}
}
impl Display for Keys {
/// Converts the [`Keys`] to a string that looks native on the current platform.
///
/// For example, the shortcut created with `@keys(Meta + Control + A)`
/// will be converted like this:
/// - **macOS**: `⌃⌘A`
/// - **Windows**: `Win+Ctrl+A`
/// - **Linux**: `Super+Ctrl+A`
///
/// Note that this functions output is best-effort and may be adjusted/improved at any time,
/// do not rely on this output to be stable!
//
// References for implementation
// - macOS: <https://developer.apple.com/design/human-interface-guidelines/keyboards>
// - Windows: <https://learn.microsoft.com/en-us/windows/apps/design/input/keyboard-accelerators>
// - Linux: <https://developer.gnome.org/hig/guidelines/keyboard.html>
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let inner = &self.inner;
if inner.key.is_empty() {
return Ok(());
}
if crate::is_apple_platform() {
// Slint remaps modifiers on macOS: control → Command, meta → Control
// From Apple's documentation:
//
// List modifier keys in the correct order.
// If you use more than one modifier key in a custom shortcut, always list them in this order:
// Control, Option, Shift, Command
if inner.modifiers.meta {
f.write_str("⌃")?;
}
if !inner.ignore_alt && inner.modifiers.alt {
f.write_str("⌥")?;
}
if !inner.ignore_shift && inner.modifiers.shift {
f.write_str("⇧")?;
}
if inner.modifiers.control {
f.write_str("⌘")?;
}
} else {
let separator = "+";
// TODO: These should probably be translated, but better to have at least
// platform-local names than nothing.
let (ctrl_str, alt_str, shift_str, meta_str) =
if crate::detect_operating_system() == OperatingSystemType::Windows {
("Ctrl", "Alt", "Shift", "Win")
} else {
("Ctrl", "Alt", "Shift", "Super")
};
if inner.modifiers.meta {
f.write_str(meta_str)?;
f.write_str(separator)?;
}
if inner.modifiers.control {
f.write_str(ctrl_str)?;
f.write_str(separator)?;
}
if !inner.ignore_alt && inner.modifiers.alt {
f.write_str(alt_str)?;
f.write_str(separator)?;
}
if !inner.ignore_shift && inner.modifiers.shift {
f.write_str(shift_str)?;
f.write_str(separator)?;
}
}
f.write_str(&self.format_key_for_display())
}
}
impl core::fmt::Debug for Keys {
/// Formats the keyboard shortcut so that the output would be accepted by the @keys macro in Slint.
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let inner = &self.inner;
// Make sure to keep this in sync with the implementation in compiler/langtype.rs
if inner.key.is_empty() {
write!(f, "")
} else {
let alt = inner
.ignore_alt
.then_some("Alt?+")
.or(inner.modifiers.alt.then_some("Alt+"))
.unwrap_or_default();
let ctrl = if inner.modifiers.control { "Control+" } else { "" };
let meta = if inner.modifiers.meta { "Meta+" } else { "" };
let shift = inner
.ignore_shift
.then_some("Shift?+")
.or(inner.modifiers.shift.then_some("Shift+"))
.unwrap_or_default();
let keycode: SharedString = inner
.key
.chars()
.flat_map(|character| {
let mut escaped = alloc::vec![];
if character.is_control() {
escaped.extend(character.escape_unicode());
} else {
escaped.push(character);
}
escaped
})
.collect();
write!(f, "{meta}{ctrl}{alt}{shift}\"{keycode}\"")
}
}
}
/// This enum defines the different kinds of key events that can happen.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum KeyEventType {
/// A key on a keyboard was pressed.
#[default]
KeyPressed = 0,
/// A key on a keyboard was released.
KeyReleased = 1,
/// The input method updates the currently composed text. The KeyEvent's text field is the pre-edit text and
/// composition_selection specifies the placement of the cursor within the pre-edit text.
UpdateComposition = 2,
/// The input method replaces the currently composed text with the final result of the composition.
CommitComposition = 3,
}
#[derive(Default, Debug, Clone, PartialEq)]
/// This struct is used to pass key events to the runtime.
pub struct InternalKeyEvent {
/// That's the public type with only public fields
pub key_event: KeyEvent,
/// Indicates whether the key was pressed or released
pub event_type: KeyEventType,
/// The key without any modifiers held
/// Important on Windows, to distinguish between key presses when Ctrl+Alt was pressed
/// vs. AltGr.
/// This is optional, and we will fall back to a heuristic for Ctrl+Alt on Windows if this
/// isn't provided.
#[cfg(target_os = "windows")]
pub text_without_modifiers: SharedString,
/// If the event type is KeyEventType::UpdateComposition or KeyEventType::CommitComposition,
/// then this field specifies what part of the current text to replace.
/// Relative to the offset of the pre-edit text within the text input element's text.
pub replacement_range: Option<core::ops::Range<i32>>,
/// If the event type is KeyEventType::UpdateComposition, this is the new pre-edit text
pub preedit_text: SharedString,
/// The selection within the preedit_text
pub preedit_selection: Option<core::ops::Range<i32>>,
/// The new cursor position, when None, the cursor is put after the text that was just inserted
pub cursor_position: Option<i32>,
/// The anchor position, when None, the cursor is put after the text that was just inserted
pub anchor_position: Option<i32>,
}
impl InternalKeyEvent {
/// If a shortcut was pressed, this function returns `Some(StandardShortcut)`.
/// Otherwise it returns None.
pub fn shortcut(&self) -> Option<StandardShortcut> {
if self.key_event.modifiers.control && !self.key_event.modifiers.shift {
match self.key_event.text.as_str() {
#[cfg(not(target_arch = "wasm32"))]
"c" => Some(StandardShortcut::Copy),
#[cfg(not(target_arch = "wasm32"))]
"x" => Some(StandardShortcut::Cut),
#[cfg(not(target_arch = "wasm32"))]
"v" => Some(StandardShortcut::Paste),
"a" => Some(StandardShortcut::SelectAll),
"f" => Some(StandardShortcut::Find),
"s" => Some(StandardShortcut::Save),
"p" => Some(StandardShortcut::Print),
"z" => Some(StandardShortcut::Undo),
#[cfg(target_os = "windows")]
"y" => Some(StandardShortcut::Redo),
"r" => Some(StandardShortcut::Refresh),
_ => None,
}
} else if self.key_event.modifiers.control && self.key_event.modifiers.shift {
match self.key_event.text.as_str() {
#[cfg(not(target_os = "windows"))]
"z" | "Z" => Some(StandardShortcut::Redo),
_ => None,
}
} else {
None
}
}
/// If a shortcut concerning text editing was pressed, this function
/// returns `Some(TextShortcut)`. Otherwise it returns None.
pub fn text_shortcut(&self) -> Option<TextShortcut> {
let ke = &self.key_event;
let keycode = ke.text.chars().next()?;
let is_apple = crate::is_apple_platform();
let move_mod = if is_apple {
ke.modifiers.alt && !ke.modifiers.control && !ke.modifiers.meta
} else {
ke.modifiers.control && !ke.modifiers.alt && !ke.modifiers.meta
};
if move_mod {
match keycode {
key_codes::LeftArrow => {
return Some(TextShortcut::Move(TextCursorDirection::BackwardByWord));
}
key_codes::RightArrow => {
return Some(TextShortcut::Move(TextCursorDirection::ForwardByWord));
}
key_codes::UpArrow => {
return Some(TextShortcut::Move(TextCursorDirection::StartOfParagraph));
}
key_codes::DownArrow => {
return Some(TextShortcut::Move(TextCursorDirection::EndOfParagraph));
}
key_codes::Backspace => {
return Some(TextShortcut::DeleteWordBackward);
}
key_codes::Delete => {
return Some(TextShortcut::DeleteWordForward);
}
_ => (),
};
}
#[cfg(not(target_os = "macos"))]
{
if ke.modifiers.control && !ke.modifiers.alt && !ke.modifiers.meta {
match keycode {
key_codes::Home => {
return Some(TextShortcut::Move(TextCursorDirection::StartOfText));
}
key_codes::End => {
return Some(TextShortcut::Move(TextCursorDirection::EndOfText));
}
_ => (),
};
}
}
if is_apple && ke.modifiers.control {
match keycode {
key_codes::LeftArrow => {
return Some(TextShortcut::Move(TextCursorDirection::StartOfLine));
}
key_codes::RightArrow => {
return Some(TextShortcut::Move(TextCursorDirection::EndOfLine));
}
key_codes::UpArrow => {
return Some(TextShortcut::Move(TextCursorDirection::StartOfText));
}
key_codes::DownArrow => {
return Some(TextShortcut::Move(TextCursorDirection::EndOfText));
}
key_codes::Backspace => {
return Some(TextShortcut::DeleteToStartOfLine);
}
_ => (),
};
}
if let Ok(direction) = TextCursorDirection::try_from(keycode) {
Some(TextShortcut::Move(direction))
} else {
match keycode {
key_codes::Backspace => Some(TextShortcut::DeleteBackward),
key_codes::Delete => Some(TextShortcut::DeleteForward),
_ => None,
}
}
}
}
/// Represents a non context specific shortcut.
pub enum StandardShortcut {
/// Copy Something
Copy,
/// Cut Something
Cut,
/// Paste Something
Paste,
/// Select All
SelectAll,
/// Find/Search Something
Find,
/// Save Something
Save,
/// Print Something
Print,
/// Undo the last action
Undo,
/// Redo the last undone action
Redo,
/// Refresh
Refresh,
}
/// Shortcuts that are used when editing text
pub enum TextShortcut {
/// Move the cursor
Move(TextCursorDirection),
/// Delete the Character to the right of the cursor
DeleteForward,
/// Delete the Character to the left of the cursor (aka Backspace).
DeleteBackward,
/// Delete the word to the right of the cursor
DeleteWordForward,
/// Delete the word to the left of the cursor (aka Ctrl + Backspace).
DeleteWordBackward,
/// Delete to the left of the cursor until the start of the line
DeleteToStartOfLine,
}
/// Represents how an item's key_event handler dealt with a key event.
/// An accepted event results in no further event propagation.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum KeyEventResult {
/// The event was handled.
EventAccepted,
/// The event was not handled and should be sent to other items.
#[default]
EventIgnored,
}
/// Represents how an item's focus_event handler dealt with a focus event.
/// An accepted event results in no further event propagation.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum FocusEventResult {
/// The event was handled.
FocusAccepted,
/// The event was not handled and should be sent to other items.
#[default]
FocusIgnored,
}
/// This event is sent to a component and items when they receive or lose
/// the keyboard focus.
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(u8)]
pub enum FocusEvent {
/// This event is sent when an item receives the focus.
FocusIn(FocusReason),
/// This event is sent when an item loses the focus.
FocusOut(FocusReason),
}
/// This state is used to count the clicks separated by [`crate::platform::Platform::click_interval`]
#[derive(Default)]
pub struct ClickState {
click_count_time_stamp: Cell<Option<crate::animations::Instant>>,
click_count: Cell<u8>,
click_position: Cell<LogicalPoint>,
click_button: Cell<PointerEventButton>,
}
impl ClickState {
/// Resets the timer and count.
fn restart(
&self,
position: LogicalPoint,
button: PointerEventButton,
now: crate::animations::Instant,
) {
self.click_count.set(0);
self.click_count_time_stamp.set(Some(now));
self.click_position.set(position);
self.click_button.set(button);
}
/// Reset to an invalid state
pub fn reset(&self) {
self.click_count.set(0);
self.click_count_time_stamp.replace(None);
}
/// Check if the click is repeated.
/// Takes the context rather than just the interval: the timestamps it compares have to
/// come from the same clock, which only the context can name.
pub fn check_repeat(&self, mouse_event: MouseEvent, ctx: &crate::SlintContext) -> MouseEvent {
let click_interval = ctx.platform().click_interval();
match mouse_event {
MouseEvent::Pressed { position, button, touch_finger_id, .. } => {
let instant_now = crate::animations::Instant::now(ctx);
if let Some(click_count_time_stamp) = self.click_count_time_stamp.get() {
if instant_now - click_count_time_stamp < click_interval
&& button == self.click_button.get()
&& (position - self.click_position.get()).square_length() < 100 as _
{
self.click_count.set(self.click_count.get().wrapping_add(1));
self.click_count_time_stamp.set(Some(instant_now));
} else {
self.restart(position, button, instant_now);
}
} else {
self.restart(position, button, instant_now);
}
return MouseEvent::Pressed {
position,
button,
click_count: self.click_count.get(),
touch_finger_id,
};
}
MouseEvent::Released { position, button, touch_finger_id, .. } => {
return MouseEvent::Released {
position,
button,
click_count: self.click_count.get(),
touch_finger_id,
};
}
_ => {}
};
mouse_event
}
}
/// The data for an in-flight drag-and-drop operation, held while a drag is active.
#[derive(Clone)]
pub(crate) struct DragData {
/// The dragged payload together with its current position and proposed action.
/// The `position` is updated on every move.
pub(crate) event: DropEvent,
/// The actions the drag source permits, captured at drag start.
pub(crate) allowed: AllowedDragActions,
}
/// The state which a window should hold for the mouse input
#[derive(Default)]
pub struct MouseInputState {
/// The stack of item which contain the mouse cursor (or grab),
/// along with the last result from the input function
item_stack: Vec<(ItemWeak, InputEventFilterResult)>,
/// Passive trackers that saw the last event without claiming it (see
/// [`InputEventResult::ObserveEvent`]). Held outside `item_stack` so the stack
/// stays a single root-to-leaf path; entries here receive a synthesized
/// [`MouseEvent::Exit`] when they no longer appear after a new event.
observers: Vec<ItemWeak>,
/// Offset to apply to the first item of the stack (used if there is a popup)
pub(crate) offset: LogicalPoint,
/// true if the top item of the stack has the mouse grab
grabbed: bool,
/// When this is Some, it means we are in the middle of a drag-drop operation and it contains the dragged data.
/// The `position` field has no signification
pub(crate) drag_data: Option<DragData>,
/// The `DragArea` that initiated the in-flight drag.
/// `None` for drags coming from outside (native cross-window/cross-process DnD).
pub(crate) drag_source: Option<ItemWeak>,
/// The DropArea that accepted the most recent DragMove, if any. On release we use
/// this to decide whether to deliver a Drop — matching OS DnD pipelines, where a
/// target that didn't previously accept never receives a drop.
pub(crate) drop_target: Option<ItemWeak>,
delayed: Option<(crate::timers::Timer, MouseEvent)>,
delayed_exit_items: Vec<ItemWeak>,
pub(crate) cursor: MouseCursorInner,
}
impl MouseInputState {
/// Return the item in the top of the stack
fn top_item(&self) -> Option<ItemRc> {
self.item_stack.last().and_then(|x| x.0.upgrade())
}
/// Arm the in-window drag: seed `drag_data`/`drag_source` from `drag_area` at `seed_position`
/// and mark it dragging.
pub(crate) fn arm_in_window_drag(
&mut self,
drag_area: core::pin::Pin<&crate::items::DragArea>,
source: ItemWeak,
seed_position: crate::api::LogicalPosition,
) {
let (mut drop_event, allowed) = drag_area.initial_drop_event();
drop_event.position = seed_position;
self.drag_data = Some(DragData { event: drop_event, allowed });
self.drag_source = Some(source);
drag_area.dragging.set(true);
}
/// Returns the item in the top of the stack, if there is a delayed event, this would be the top of the delayed stack
pub fn top_item_including_delayed(&self) -> Option<ItemRc> {
self.delayed_exit_items.last().and_then(|x| x.upgrade()).or_else(|| self.top_item())
}
/// Returns true if there is a pending delayed event (e.g. from a Flickable)
pub fn has_delayed_event(&self) -> bool {
self.delayed.is_some()
}
/// The action negotiated with the `DropArea` that accepted the most recent
/// `DragMove`/`Drop`, or `None` if none accepted.
pub fn drop_target_action(&self) -> Option<crate::items::DragAction> {
let action = self
.drop_target
.as_ref()
.and_then(|t| t.upgrade())
.and_then(|i| i.downcast::<crate::items::DropArea>())
.map(|d| d.as_pin_ref().current_action())?;
(action != crate::items::DragAction::None).then_some(action)
}
}
pub(crate) struct MouseGrabResult {
/// The event that still needs normal hit-test dispatch. `None` means the grabber
/// fully handled the original event.
pub event: Option<MouseEvent>,
/// Whether the grabber consumed the original event before any follow-up event was
/// synthesized for hover/grab refresh.
pub accepted: bool,
}
/// Start a drag from `drag_area`, preferring a native (OS-level) drag and falling back to the
/// in-window drag (armed on `state`) when no backend takes over.
fn offer_native_drag(
window_adapter: &Rc<dyn WindowAdapter>,
drag_area: core::pin::Pin<&crate::items::DragArea>,
source: ItemWeak,
seed_position: crate::api::LogicalPosition,
state: &mut MouseInputState,
) {
let data = drag_area.data();
if data.has_native_data() {
let request = crate::window::DragRequest {
data: data.clone(),
allowed: drag_area.allowed_actions(),
drag_image: drag_area.drag_image(),
drag_image_offset: euclid::vec2(
drag_area.drag_image_offset_x(),
drag_area.drag_image_offset_y(),
),
};
if window_adapter.internal(crate::InternalToken).is_some_and(|i| i.start_drag(&request)) {
// The backend took over (and defers the actual drag). Stash it so it can report
// completion or fall back, and so a drop back onto this window restores the data.
let drag = crate::window::NativePendingDrag { request, source, seed_position };
crate::window::WindowInner::from_pub(window_adapter.window())
.set_native_drag(Some(drag));
drag_area.dragging.set(true);
return;
}
}
// No backend took over: fall back to the in-window drag.
state.arm_in_window_drag(drag_area, source, seed_position);
}
/// Try to handle the mouse grabber.
pub(crate) fn handle_mouse_grab(
mouse_event: &MouseEvent,
window_adapter: &Rc<dyn WindowAdapter>,
mouse_input_state: &mut MouseInputState,
) -> MouseGrabResult {
if !mouse_input_state.grabbed || mouse_input_state.item_stack.is_empty() {
return MouseGrabResult { event: Some(mouse_event.clone()), accepted: false };
};
let mut event = mouse_event.clone();
let mut intercept = false;
let mut invalid = false;
event.translate(-mouse_input_state.offset.to_vector());
mouse_input_state.item_stack.retain(|it| {
if invalid {
return false;
}
let item = if let Some(item) = it.0.upgrade() {
item
} else {
invalid = true;
return false;
};
if intercept {
item.borrow().as_ref().input_event(
&MouseEvent::Exit,
window_adapter,
&item,
&mut mouse_input_state.cursor,
);
return false;
}
let g = item.geometry();
event.translate(-g.origin.to_vector());
if window_adapter.renderer().supports_transformations()
&& let Some(inverse_transform) = item.inverse_children_transform()
{
event.transform(inverse_transform);
}
let interested = matches!(
it.1,
InputEventFilterResult::ForwardAndInterceptGrab
| InputEventFilterResult::DelayForwarding(_)
);
if interested
&& item.borrow().as_ref().input_event_filter_before_children(
&event,
window_adapter,
&item,
&mut mouse_input_state.cursor,
) == InputEventFilterResult::Intercept
{
intercept = true;
}
true
});
if invalid {
return MouseGrabResult { event: Some(mouse_event.clone()), accepted: false };
}
let grabber = mouse_input_state.top_item().unwrap();
let input_result = grabber.borrow().as_ref().input_event(
&event,
window_adapter,
&grabber,
&mut mouse_input_state.cursor,
);
match input_result {
InputEventResult::GrabMouse => MouseGrabResult { event: None, accepted: true },
InputEventResult::StartDrag => {
mouse_input_state.grabbed = false;
let drag_area_item = grabber.downcast::<crate::items::DragArea>().unwrap();
let drag_area = drag_area_item.as_pin_ref();
// Seed the drag position from the event that crossed the drag threshold so
// the renderer can place the drag-image overlay before the first DragMove.
let seed_position = mouse_event
.position()
.map(crate::lengths::logical_position_to_api)
.unwrap_or_default();
offer_native_drag(
window_adapter,
drag_area,
grabber.downgrade(),
seed_position,
mouse_input_state,
);
MouseGrabResult { event: None, accepted: true }
}
InputEventResult::EventAccepted | InputEventResult::EventIgnored => {
mouse_input_state.grabbed = false;
// Return a move event so that the new position can be registered properly
MouseGrabResult {
event: Some(mouse_event.position().map_or(MouseEvent::Exit, |position| {
MouseEvent::Moved { position, touch_finger_id: mouse_event.touch_finger_id() }
})),
accepted: input_result == InputEventResult::EventAccepted,
}
}
}
}
pub(crate) fn send_exit_events(
old_input_state: &MouseInputState,
new_input_state: &mut MouseInputState,
mut pos: Option<LogicalPoint>,
window_adapter: &Rc<dyn WindowAdapter>,
) {
// Note that exit events can't actually change the cursor from default so we'll ignore the result
let cursor = &mut MouseCursorInner::BuiltIn(BuiltInMouseCursor::Default);
for it in core::mem::take(&mut new_input_state.delayed_exit_items) {
let Some(item) = it.upgrade() else { continue };
item.borrow().as_ref().input_event(&MouseEvent::Exit, window_adapter, &item, cursor);
}
let mut clipped = false;
for (idx, it) in old_input_state.item_stack.iter().enumerate() {
let Some(item) = it.0.upgrade() else { break };
let g = item.geometry();
let contains = pos.is_some_and(|p| g.contains(p));
if let Some(p) = pos.as_mut() {
*p -= g.origin.to_vector();
if window_adapter.renderer().supports_transformations()
&& let Some(inverse_transform) = item.inverse_children_transform()
{
*p = inverse_transform.transform_point(p.cast()).cast();
}
}
if !contains || clipped {
if item.borrow().as_ref().clips_children() {
clipped = true;
}
item.borrow().as_ref().input_event(&MouseEvent::Exit, window_adapter, &item, cursor);
} else if new_input_state.item_stack.get(idx).is_none_or(|(x, _)| *x != it.0) {
// The item is still under the mouse, but no longer in the item stack. We should also sent the exit event, unless we delay it
if new_input_state.delayed.is_some() {
new_input_state.delayed_exit_items.push(it.0.clone());
} else {
item.borrow().as_ref().input_event(
&MouseEvent::Exit,
window_adapter,
&item,
cursor,
);
}
}
}
// Observers live outside the path-stack and are tracked by identity. Exit fires
// only when the item is missing from BOTH the new observer set and the new path
// stack: an item whose ForwardAndObserve filter never ran (because a child aborted
// before reaching it) is still on the path stack with another filter result, and
// should not receive Exit.
for obs in &old_input_state.observers {
if new_input_state.observers.iter().any(|x| x == obs)
|| new_input_state.item_stack.iter().any(|(x, _)| x == obs)
{
continue;
}
let Some(item) = obs.upgrade() else { continue };
item.borrow().as_ref().input_event(&MouseEvent::Exit, window_adapter, &item, cursor);
}
}
/// Outcome of [`process_mouse_input`].
pub struct MouseInputResult {
/// The new dispatch state to install in place of the one passed in.
pub state: MouseInputState,
/// `true` when an item consumed the event (`EventAccepted`, `GrabMouse`,
/// `StartDrag`, or a `DropArea` taking a `DragMove`/`Drop`).
pub accepted: bool,
}
/// Process the `mouse_event` on the `component`. The `mouse_input_state` is the previous
/// dispatch state (grab stack, cursor, in-flight drag); the returned [`MouseInputResult`]
/// carries the state that replaces it and whether the event was consumed.
pub fn process_mouse_input(
root: ItemRc,
mouse_event: &MouseEvent,
window_adapter: &Rc<dyn WindowAdapter>,
mut mouse_input_state: MouseInputState,
) -> MouseInputResult {
let mut result = MouseInputState {
drag_data: mouse_input_state.drag_data.clone(),
drag_source: mouse_input_state.drag_source.clone(),
drop_target: mouse_input_state.drop_target.clone(),
cursor: mouse_input_state.cursor.clone(),
..Default::default()
};
let r = send_mouse_event_to_item(
mouse_event,
root.clone(),
window_adapter,
&mut result,
mouse_input_state.top_item().as_ref(),
false,
);
let accepted = r.has_aborted();
if matches!(mouse_event, MouseEvent::DragMove { .. }) {
// Remember the accepting DropArea (or forget if none did) so the subsequent
// Release knows whether to deliver a Drop.
result.drop_target =
accepted.then(|| result.item_stack.last().map(|(w, _)| w.clone())).flatten();
}
if mouse_input_state.delayed.is_some()
&& (!accepted
|| Option::zip(result.item_stack.last(), mouse_input_state.item_stack.last())
.is_none_or(|(a, b)| a.0 != b.0))
{
// Keep the delayed event but transfer the just-attempted dispatch's cursor.
mouse_input_state.cursor = result.cursor;
return MouseInputResult { state: mouse_input_state, accepted };
}
send_exit_events(&mouse_input_state, &mut result, mouse_event.position(), window_adapter);
if let MouseEvent::Wheel { position, .. } = mouse_event
&& accepted
{
// An accepted wheel event might have moved things. Send a synthetic Moved to refresh
// has-hover. The original wheel's `accepted` (always `true` in this branch) is the
// outcome the caller sees — the synthetic Moved is an internal implementation detail.
let moved = process_mouse_input(
root,
&MouseEvent::Moved { position: *position, touch_finger_id: 0 },
window_adapter,
result,
);
return MouseInputResult { state: moved.state, accepted: true };
}
MouseInputResult { state: result, accepted }
}
pub(crate) fn process_delayed_event(
window_adapter: &Rc<dyn WindowAdapter>,
mut mouse_input_state: MouseInputState,
) -> MouseInputState {
// the take bellow will also destroy the Timer
let event = match mouse_input_state.delayed.take() {
Some(e) => e.1,
None => return mouse_input_state,
};
let top_item = match mouse_input_state.top_item() {
Some(i) => i,
None => return MouseInputState::default(),
};
// Recover the real previous click target so click_count is preserved across delayed events
let prev_target = mouse_input_state.delayed_exit_items.last().and_then(|x| x.upgrade());
let last_top_item = prev_target.as_ref().unwrap_or(&top_item);
let mut actual_visitor =
|component: &ItemTreeRc, index: u32, _: Pin<ItemRef>| -> VisitChildrenResult {
send_mouse_event_to_item(
&event,
ItemRc::new(component.clone(), index),
window_adapter,
&mut mouse_input_state,
Some(last_top_item),
true,
)
};
vtable::new_vref!(let mut actual_visitor : VRefMut<crate::item_tree::ItemVisitorVTable> for crate::item_tree::ItemVisitor = &mut actual_visitor);
vtable::VRc::borrow_pin(top_item.item_tree()).as_ref().visit_children_item(
top_item.index() as isize,
crate::item_tree::TraversalOrder::FrontToBack,
actual_visitor,
);
mouse_input_state
}
fn send_mouse_event_to_item(
mouse_event: &MouseEvent,
item_rc: ItemRc,
window_adapter: &Rc<dyn WindowAdapter>,
result: &mut MouseInputState,
last_top_item: Option<&ItemRc>,
ignore_delays: bool,
) -> VisitChildrenResult {
let item = item_rc.borrow();
let geom = item_rc.geometry();
// translated in our coordinate
let mut event_for_children = mouse_event.clone();
// Unapply the translation to go from 'world' space to local space
event_for_children.translate(-geom.origin.to_vector());
if window_adapter.renderer().supports_transformations() {
// Unapply other transforms.
if let Some(inverse_transform) = item_rc.inverse_children_transform() {
event_for_children.transform(inverse_transform);
}
}
let filter_result = if mouse_event.position().is_some_and(|p| geom.contains(p))
|| item.as_ref().clips_children()
{
item.as_ref().input_event_filter_before_children(
&event_for_children,
window_adapter,
&item_rc,
&mut result.cursor,
)
} else {
InputEventFilterResult::ForwardAndIgnore
};
let (forward_to_children, ignore) = match filter_result {
InputEventFilterResult::ForwardEvent => (true, false),
InputEventFilterResult::ForwardAndIgnore => (true, true),
InputEventFilterResult::ForwardAndInterceptGrab => (true, false),
InputEventFilterResult::Intercept => (false, false),
InputEventFilterResult::DelayForwarding(_) if ignore_delays => (true, false),
InputEventFilterResult::DelayForwarding(duration) => {
let timer = WindowInner::from_pub(window_adapter.window()).context().new_timer();
let w = Rc::downgrade(window_adapter);
timer.start(
crate::timers::TimerMode::SingleShot,
Duration::from_millis(duration),
move || {
if let Some(w) = w.upgrade() {
WindowInner::from_pub(w.window()).process_delayed_event();
}
},
);
result.delayed = Some((timer, event_for_children));
result
.item_stack
.push((item_rc.downgrade(), InputEventFilterResult::DelayForwarding(duration)));
return VisitChildrenResult::abort(item_rc.index(), 0);
}
// Like ForwardAndIgnore: forward to children, skip input_event. The
// EventIgnored arm below moves our entry from the path stack to the observers
// side list instead of dropping it.
InputEventFilterResult::ForwardAndObserve => (true, true),
};
result.item_stack.push((item_rc.downgrade(), filter_result));
if forward_to_children {
let mut actual_visitor =
|component: &ItemTreeRc, index: u32, _: Pin<ItemRef>| -> VisitChildrenResult {
send_mouse_event_to_item(
&event_for_children,
ItemRc::new(component.clone(), index),
window_adapter,
result,
last_top_item,
ignore_delays,
)
};
vtable::new_vref!(let mut actual_visitor : VRefMut<crate::item_tree::ItemVisitorVTable> for crate::item_tree::ItemVisitor = &mut actual_visitor);
let r = vtable::VRc::borrow_pin(item_rc.item_tree()).as_ref().visit_children_item(
item_rc.index() as isize,
crate::item_tree::TraversalOrder::FrontToBack,
actual_visitor,
);
if r.has_aborted() {
return r;
}
};
let r = if ignore {
InputEventResult::EventIgnored
} else {
let mut event = mouse_event.clone();
event.translate(-geom.origin.to_vector());
if last_top_item.is_none_or(|x| *x != item_rc) {
event.set_click_count(0);
}
item.as_ref().input_event(&event, window_adapter, &item_rc, &mut result.cursor)
};
match r {
InputEventResult::EventAccepted => VisitChildrenResult::abort(item_rc.index(), 0),
InputEventResult::EventIgnored => {
let popped = result.item_stack.pop();
debug_assert_eq!(
popped.as_ref().map(|x| (x.0.upgrade().unwrap().index(), x.1)).unwrap(),
(item_rc.index(), filter_result)
);
// For ForwardAndObserve, migrate the entry to the observers side list (dedup)
// so a later Exit can still reach it.
if filter_result == InputEventFilterResult::ForwardAndObserve
&& let Some((weak, _)) = popped
&& !result.observers.contains(&weak)
{
result.observers.push(weak);
}
VisitChildrenResult::CONTINUE
}
InputEventResult::GrabMouse => {
result.item_stack.last_mut().unwrap().1 =
InputEventFilterResult::ForwardAndInterceptGrab;
result.grabbed = true;
VisitChildrenResult::abort(item_rc.index(), 0)
}
InputEventResult::StartDrag => {
result.item_stack.last_mut().unwrap().1 =
InputEventFilterResult::ForwardAndInterceptGrab;
result.grabbed = false;
let drag_area_item = item_rc.downcast::<crate::items::DragArea>().unwrap();
let drag_area = drag_area_item.as_pin_ref();
// `mouse_event` here is in the parent item's coords (this function is called
// recursively); translate into the DragArea's local coords, then map back to
// window coords so the drag-image overlay places at the right spot from the start.
let seed_position = mouse_event
.position()
.map(|p| p - geom.origin.to_vector())
.map(|p| item_rc.map_to_window(p))
.map(crate::lengths::logical_position_to_api)
.unwrap_or_default();
offer_native_drag(
window_adapter,
drag_area,
item_rc.downgrade(),
seed_position,
result,
);
VisitChildrenResult::abort(item_rc.index(), 0)
}
}
}
/// The TextCursorBlinker takes care of providing a toggled boolean property
/// that can be used to animate a blinking cursor. It's typically stored in the
/// Window using a Weak and set_binding() can be used to set up a binding on a given
/// property that'll keep it up-to-date. That binding keeps a strong reference to the
/// blinker. If the underlying item that uses it goes away, the binding goes away and
/// so does the blinker.
#[derive(FieldOffsets)]
#[repr(C)]
#[pin]
pub(crate) struct TextCursorBlinker {
cursor_visible: Property<bool>,
cursor_blink_timer: crate::timers::Timer,
}
impl TextCursorBlinker {
/// Creates a new instance, wrapped in a Pin<Rc<_>> because the boolean property
/// the blinker properties uses the property system that requires pinning.
pub fn new() -> Pin<Rc<Self>> {
Rc::pin(Self {
cursor_visible: Property::new(true),
cursor_blink_timer: Default::default(),
})
}
/// Sets a binding on the provided property that will ensure that the property value
/// is true when the cursor should be shown and false if not.
pub fn set_binding(
instance: Pin<Rc<TextCursorBlinker>>,
prop: &Property<bool>,
ctx: &crate::SlintContext,
cycle_duration: Duration,
) {
instance.as_ref().cursor_visible.set(true);
// Re-start timer, in case.
Self::start(&instance, ctx, cycle_duration);
prop.set_binding(move || {
TextCursorBlinker::FIELD_OFFSETS.cursor_visible().apply_pin(instance.as_ref()).get()
});
}
/// Starts the blinking cursor timer that will toggle the cursor and update all bindings that
/// were installed on properties with set_binding call.
pub fn start(self: &Pin<Rc<Self>>, ctx: &crate::SlintContext, cycle_duration: Duration) {
if self.cursor_blink_timer.running() {
self.cursor_blink_timer.restart();
} else {
let toggle_cursor = {
let weak_blinker = pin_weak::rc::PinWeak::downgrade(self.clone());
move || {
if let Some(blinker) = weak_blinker.upgrade() {
let visible = TextCursorBlinker::FIELD_OFFSETS
.cursor_visible()
.apply_pin(blinker.as_ref())
.get();
blinker.cursor_visible.set(!visible);
}
}
};
if !cycle_duration.is_zero() {
self.cursor_blink_timer.start_on(
ctx,
crate::timers::TimerMode::Repeated,
cycle_duration / 2,
toggle_cursor,
);
}
}
}
/// Stops the blinking cursor timer. This is usually used for example when the window that contains
/// text editable elements looses the focus or is hidden.
pub fn stop(&self) {
self.cursor_blink_timer.stop()
}
}
/// A single active touch point.
#[derive(Clone, Copy, Default)]
struct TouchPoint {
id: i32,
position: LogicalPoint,
}
/// Fixed-capacity map of touch IDs to touch points.
///
/// Touchscreens rarely report more than 5 simultaneous contacts, and gesture
/// recognition only uses the first two. A linear-scan array avoids the heap
/// allocation and pointer-chasing overhead of `BTreeMap` for this tiny collection.
const MAX_TRACKED_TOUCHES: usize = 5;
#[derive(Clone)]
struct TouchMap {
entries: [TouchPoint; MAX_TRACKED_TOUCHES],
len: usize,
}
impl Default for TouchMap {
fn default() -> Self {
Self { entries: [TouchPoint::default(); MAX_TRACKED_TOUCHES], len: 0 }
}
}
impl TouchMap {
fn get(&self, id: i32) -> Option<&TouchPoint> {
self.entries[..self.len].iter().find(|tp| tp.id == id)
}
fn get_mut(&mut self, id: i32) -> Option<&mut TouchPoint> {
self.entries[..self.len].iter_mut().find(|tp| tp.id == id)
}
fn insert(&mut self, point: TouchPoint) {
if let Some(existing) = self.entries[..self.len].iter_mut().find(|tp| tp.id == point.id) {
*existing = point;
} else if self.len < MAX_TRACKED_TOUCHES {
self.entries[self.len] = point;
self.len += 1;
}
}
fn remove(&mut self, id: i32) {
if let Some(idx) = self.entries[..self.len].iter().position(|tp| tp.id == id) {
self.len -= 1;
self.entries[idx] = self.entries[self.len];
}
}
fn len(&self) -> usize {
self.len
}
/// Returns the first two distinct IDs, or `None` if fewer than 2 entries.
fn first_two_ids(&self) -> Option<(i32, i32)> {
if self.len >= 2 { Some((self.entries[0].id, self.entries[1].id)) } else { None }
}
/// Returns the first entry, if any.
fn first(&self) -> Option<&TouchPoint> {
if self.len > 0 { Some(&self.entries[0]) } else { None }
}
}
/// Fixed-capacity buffer for [`MouseEvent`]s produced by the touch state machine.
///
/// No branch in [`TouchState::process`] emits more than 3 events (gesture end
/// produces PinchEnded + RotationEnded + Pressed/Exit). Capacity 4 provides a
/// margin without heap allocation.
const MAX_TOUCH_EVENTS: usize = 4;
#[derive(Clone)]
pub(crate) struct TouchEventBuffer {
events: [Option<MouseEvent>; MAX_TOUCH_EVENTS],
len: usize,
}
impl TouchEventBuffer {
fn new() -> Self {
Self { events: [None, None, None, None], len: 0 }
}
fn push(&mut self, event: MouseEvent) {
debug_assert!(self.len < MAX_TOUCH_EVENTS, "TouchEventBuffer overflow");
if self.len < MAX_TOUCH_EVENTS {
self.events[self.len] = Some(event);
self.len += 1;
}
}
/// Returns an iterator over the buffered events.
pub(crate) fn into_iter(self) -> impl Iterator<Item = MouseEvent> {
let len = self.len;
self.events.into_iter().take(len).flatten()
}
}
/// State of the multi-touch gesture recognizer.
#[derive(Default, Debug, Clone, Copy)]
enum GestureRecognitionState {
/// 0-1 fingers; forwarding as mouse events.
#[default]
Idle,
/// 2 fingers down, waiting for movement to exceed threshold.
TwoFingersDown { finger_ids: (i32, i32), initial_distance: f32, last_angle: euclid::Angle<f32> },
/// Actively synthesizing PinchGesture/RotationGesture events.
Pinching {
finger_ids: (i32, i32),
initial_distance: f32,
last_scale: f32,
last_angle: euclid::Angle<f32>,
},
}
/// Tracks all active touch points and recognizes pinch/rotation gestures.
///
/// When only one finger is down, touch events are forwarded as mouse events.
/// When two fingers are down and move beyond a threshold, synthesized
/// `PinchGesture` and `RotationGesture` events are emitted — the same events
/// that platform gesture recognition (e.g. macOS trackpad) produces.
pub(crate) struct TouchState {
active_touches: TouchMap,
/// The finger forwarded as mouse events during single-touch.
primary_touch_id: Option<i32>,
gesture_state: GestureRecognitionState,
}
impl Default for TouchState {
fn default() -> Self {
Self {
active_touches: TouchMap::default(),
primary_touch_id: None,
gesture_state: GestureRecognitionState::Idle,
}
}
}
impl TouchState {
/// Minimum movement (in logical pixels) before two fingers are recognized as a pinch.
const PINCH_THRESHOLD: f32 = 8.0;
/// Minimum angular change (in degrees) before two fingers are recognized as a rotation.
const ROTATION_THRESHOLD: f32 = 5.0;
/// Returns the finger IDs from the current gesture state, if any.
fn gesture_finger_ids(&self) -> Option<(i32, i32)> {
match self.gesture_state {
GestureRecognitionState::TwoFingersDown { finger_ids, .. }
| GestureRecognitionState::Pinching { finger_ids, .. } => Some(finger_ids),
GestureRecognitionState::Idle => None,
}
}
/// Returns (distance, angle) between two specific touch points.
fn geometry_for(&self, (id_a, id_b): (i32, i32)) -> Option<(f32, euclid::Angle<f32>)> {
let a = self.active_touches.get(id_a)?;
let b = self.active_touches.get(id_b)?;
let delta = (b.position - a.position).cast::<f32>();
Some((delta.length(), delta.angle_from_x_axis()))
}
/// Returns the positions of the two gesture fingers, or `None` if not available.
fn gesture_finger_positions(&self) -> Option<(&TouchPoint, &TouchPoint)> {
let (id_a, id_b) = self.gesture_finger_ids()?;
let a = self.active_touches.get(id_a)?;
let b = self.active_touches.get(id_b)?;
Some((a, b))
}
/// Returns the midpoint between the two gesture fingers, or `None`.
fn gesture_midpoint(&self) -> Option<LogicalPoint> {
let (a, b) = self.gesture_finger_positions()?;
let mid = a.position.cast::<f32>().lerp(b.position.cast::<f32>(), 0.5);
Some(mid.cast())
}
/// Returns (distance, angle) between the two gesture fingers.
fn gesture_geometry(&self) -> Option<(f32, euclid::Angle<f32>)> {
let (a, b) = self.gesture_finger_positions()?;
let delta = (b.position - a.position).cast::<f32>();
Some((delta.length(), delta.angle_from_x_axis()))
}
/// Returns true if the given touch ID is one of the two gesture fingers.
fn is_gesture_finger(&self, id: i32) -> bool {
self.gesture_finger_ids().is_some_and(|(a, b)| id == a || id == b)
}
/// Run the touch state machine for a single event and return the
/// [`MouseEvent`]s to dispatch.
///
/// This is intentionally separated from [`crate::window::WindowInner::process_touch_input`]
/// so that the `RefCell` borrow can be dropped *once* before dispatching,
/// rather than requiring a manual `drop` at every branch.
pub(crate) fn process(
&mut self,
id: i32,
position: LogicalPoint,
phase: TouchPhase,
) -> TouchEventBuffer {
let mut events = TouchEventBuffer::new();
match phase {
TouchPhase::Started => self.process_started(id, position, &mut events),
TouchPhase::Moved => self.process_moved(id, position, &mut events),
TouchPhase::Ended => self.process_ended(id, position, false, &mut events),
TouchPhase::Cancelled => self.process_ended(id, position, true, &mut events),
}
events
}
fn process_started(&mut self, id: i32, position: LogicalPoint, events: &mut TouchEventBuffer) {
self.active_touches.insert(TouchPoint { id, position });
let total = self.active_touches.len();
if total == 1 {
// First finger: become primary, forward as mouse press.
self.primary_touch_id = Some(id);
self.gesture_state = GestureRecognitionState::Idle;
events.push(MouseEvent::Pressed {
position,
button: PointerEventButton::Left,
click_count: 0,
touch_finger_id: id + 1,
});
} else if total == 2 {
// Second finger: transition Idle → TwoFingersDown.
let finger_ids = self.active_touches.first_two_ids().unwrap_or((0, 0));
// Synthesize a Release for the primary finger to clear any
// Flickable grab / delay state.
let primary_pos = self
.primary_touch_id
.and_then(|pid| self.active_touches.get(pid))
.map(|tp| tp.position)
.unwrap_or(position);
// Compute initial geometry for threshold detection.
let (initial_distance, last_angle) =
self.geometry_for(finger_ids).unwrap_or((0.0, euclid::Angle::zero()));
self.gesture_state = GestureRecognitionState::TwoFingersDown {
finger_ids,
initial_distance,
last_angle,
};
events.push(MouseEvent::Released {
position: primary_pos,
button: PointerEventButton::Left,
click_count: 0,
touch_finger_id: id + 1,
});
}
// 3+ fingers: tracked in active_touches but ignored for gesture.
}
#[allow(clippy::collapsible_match)]
fn process_moved(&mut self, id: i32, position: LogicalPoint, events: &mut TouchEventBuffer) {
if let Some(tp) = self.active_touches.get_mut(id) {
tp.position = position;
}
let is_gesture_finger = self.is_gesture_finger(id);
match self.gesture_state {
GestureRecognitionState::Idle => {
if self.primary_touch_id == Some(id) {
events.push(MouseEvent::Moved { position, touch_finger_id: id + 1 });
}
}
GestureRecognitionState::TwoFingersDown {
finger_ids,
initial_distance,
last_angle,
} if is_gesture_finger => {
if let Some((dist, angle)) = self.gesture_geometry() {
let delta_dist = (dist - initial_distance).abs();
let delta_angle = (angle - last_angle).signed().to_degrees().abs();
if delta_dist > Self::PINCH_THRESHOLD || delta_angle > Self::ROTATION_THRESHOLD
{
// Re-snapshot so the first gesture event starts from
// the current geometry rather than accumulating the
// threshold movement.
self.gesture_state = GestureRecognitionState::Pinching {
finger_ids,
initial_distance: dist,
last_scale: 1.0,
last_angle: angle,
};
let midpoint = self.gesture_midpoint().unwrap_or(position);
events.push(MouseEvent::PinchGesture {
position: midpoint,
delta: 0.0,
phase: TouchPhase::Started,
});
events.push(MouseEvent::RotationGesture {
position: midpoint,
delta: 0.0,
phase: TouchPhase::Started,
});
}
}
}
GestureRecognitionState::Pinching {
initial_distance, last_scale, last_angle, ..
} if is_gesture_finger => {
if let Some((dist, angle)) = self.gesture_geometry() {
let midpoint = self.gesture_midpoint().unwrap_or(position);
let current_scale =
if initial_distance > 0.0 { dist / initial_distance } else { 1.0 };
let scale_delta = current_scale - last_scale;
// `.signed()` wraps to [-pi, pi] so crossing the ±180°
// atan2 boundary doesn't produce a full-revolution jump.
let rotation_delta = (angle - last_angle).signed().to_degrees();
// Update the mutable state for next frame.
if let GestureRecognitionState::Pinching {
last_scale: ref mut ls,
last_angle: ref mut la,
..
} = self.gesture_state
{
*ls = current_scale;
*la = angle;
}
events.push(MouseEvent::PinchGesture {
position: midpoint,
delta: scale_delta,
phase: TouchPhase::Moved,
});
events.push(MouseEvent::RotationGesture {
position: midpoint,
delta: rotation_delta,
phase: TouchPhase::Moved,
});
}
}
_ => {}
}
}
#[allow(clippy::collapsible_match)]
fn process_ended(
&mut self,
id: i32,
position: LogicalPoint,
is_cancelled: bool,
events: &mut TouchEventBuffer,
) {
// Check gesture membership *before* removing from the map.
let is_gesture_finger = self.is_gesture_finger(id);
let midpoint = self.gesture_midpoint().unwrap_or(position);
self.active_touches.remove(id);
match self.gesture_state {
GestureRecognitionState::Idle => {
if self.primary_touch_id == Some(id) {
self.primary_touch_id = None;
events.push(MouseEvent::Released {
position,
button: PointerEventButton::Left,
click_count: 0,
touch_finger_id: id + 1,
});
events.push(MouseEvent::Exit);
}
}
GestureRecognitionState::TwoFingersDown { .. } if is_gesture_finger => {
self.gesture_state = GestureRecognitionState::Idle;
if !is_cancelled {
if let Some(remaining) = self.active_touches.first() {
let remaining_pos = remaining.position;
self.primary_touch_id = Some(remaining.id);
events.push(MouseEvent::Pressed {
position: remaining_pos,
button: PointerEventButton::Left,
click_count: 0,
touch_finger_id: remaining.id + 1,
});
} else {
self.primary_touch_id = None;
events.push(MouseEvent::Exit);
}
} else {
self.primary_touch_id = None;
events.push(MouseEvent::Exit);
}
}
GestureRecognitionState::Pinching { .. } if is_gesture_finger => {
self.gesture_state = GestureRecognitionState::Idle;
let gesture_phase =
if is_cancelled { TouchPhase::Cancelled } else { TouchPhase::Ended };
let remaining = if !is_cancelled {
self.active_touches.first().map(|tp| (tp.id, tp.position))
} else {
None
};
if let Some((rid, _)) = remaining {
self.primary_touch_id = Some(rid);
} else {
self.primary_touch_id = None;
}
events.push(MouseEvent::PinchGesture {
position: midpoint,
delta: 0.0,
phase: gesture_phase,
});
events.push(MouseEvent::RotationGesture {
position: midpoint,
delta: 0.0,
phase: gesture_phase,
});
if let Some((rid, rpos)) = remaining {
events.push(MouseEvent::Pressed {
position: rpos,
button: PointerEventButton::Left,
click_count: 0,
touch_finger_id: rid + 1,
});
} else {
events.push(MouseEvent::Exit);
}
}
_ => {}
}
}
}
#[cfg(test)]
mod touch_tests {
extern crate alloc;
use alloc::vec;
use alloc::vec::Vec;
use super::*;
use crate::lengths::LogicalPoint;
fn pt(x: f32, y: f32) -> LogicalPoint {
euclid::point2(x, y)
}
// -----------------------------------------------------------------------
// TouchMap tests
// -----------------------------------------------------------------------
#[test]
fn touch_map_insert_and_get() {
let mut map = TouchMap::default();
assert_eq!(map.len(), 0);
map.insert(TouchPoint { id: 1, position: pt(10.0, 20.0) });
assert_eq!(map.len(), 1);
assert!(map.get(1).is_some());
assert!((map.get(1).unwrap().position.x - 10.0).abs() < f32::EPSILON);
assert!(map.get(2).is_none());
}
#[test]
fn touch_map_update_existing() {
let mut map = TouchMap::default();
map.insert(TouchPoint { id: 1, position: pt(10.0, 20.0) });
map.insert(TouchPoint { id: 1, position: pt(30.0, 40.0) });
assert_eq!(map.len(), 1);
assert!((map.get(1).unwrap().position.x - 30.0).abs() < f32::EPSILON);
}
#[test]
fn touch_map_remove() {
let mut map = TouchMap::default();
map.insert(TouchPoint { id: 1, position: pt(10.0, 20.0) });
map.insert(TouchPoint { id: 2, position: pt(30.0, 40.0) });
assert_eq!(map.len(), 2);
map.remove(1);
assert_eq!(map.len(), 1);
assert!(map.get(1).is_none());
assert!(map.get(2).is_some());
}
#[test]
fn touch_map_remove_nonexistent() {
let mut map = TouchMap::default();
map.insert(TouchPoint { id: 1, position: pt(10.0, 20.0) });
map.remove(99);
assert_eq!(map.len(), 1);
}
#[test]
fn touch_map_capacity() {
let mut map = TouchMap::default();
for i in 0..MAX_TRACKED_TOUCHES {
map.insert(TouchPoint { id: i as i32, position: pt(i as f32, 0.0) });
}
assert_eq!(map.len(), MAX_TRACKED_TOUCHES);
// Inserting beyond capacity is silently ignored.
map.insert(TouchPoint { id: 99, position: pt(99.0, 0.0) });
assert_eq!(map.len(), MAX_TRACKED_TOUCHES);
assert!(map.get(99).is_none());
}
#[test]
fn touch_map_first_two_ids() {
let mut map = TouchMap::default();
assert!(map.first_two_ids().is_none());
map.insert(TouchPoint { id: 5, position: pt(0.0, 0.0) });
assert!(map.first_two_ids().is_none());
map.insert(TouchPoint { id: 10, position: pt(0.0, 0.0) });
assert_eq!(map.first_two_ids(), Some((5, 10)));
}
#[test]
fn touch_map_first() {
let mut map = TouchMap::default();
assert!(map.first().is_none());
map.insert(TouchPoint { id: 7, position: pt(1.0, 2.0) });
let tp = map.first().unwrap();
assert_eq!(tp.id, 7);
assert!((tp.position.x - 1.0).abs() < f32::EPSILON);
}
#[test]
fn touch_map_get_mut() {
let mut map = TouchMap::default();
map.insert(TouchPoint { id: 1, position: pt(0.0, 0.0) });
map.get_mut(1).unwrap().position = pt(5.0, 6.0);
assert!((map.get(1).unwrap().position.x - 5.0).abs() < f32::EPSILON);
}
// -----------------------------------------------------------------------
// Helper: extract event types for readable assertions
// -----------------------------------------------------------------------
#[derive(Debug, PartialEq)]
enum Ev {
Pressed(f32, f32),
Released(f32, f32),
Moved(f32, f32),
Exit,
PinchStarted,
PinchMoved(f32),
PinchEnded,
PinchCancelled,
RotationStarted,
RotationMoved(f32),
RotationEnded,
RotationCancelled,
}
fn classify(events: &TouchEventBuffer) -> Vec<Ev> {
events
.clone()
.into_iter()
.map(|e| match e {
MouseEvent::Pressed { position, .. } => Ev::Pressed(position.x, position.y),
MouseEvent::Released { position, .. } => Ev::Released(position.x, position.y),
MouseEvent::Moved { position, .. } => Ev::Moved(position.x, position.y),
MouseEvent::Exit => Ev::Exit,
MouseEvent::PinchGesture { delta, phase, .. } => match phase {
TouchPhase::Started => Ev::PinchStarted,
TouchPhase::Moved => Ev::PinchMoved(delta),
TouchPhase::Ended => Ev::PinchEnded,
TouchPhase::Cancelled => Ev::PinchCancelled,
},
MouseEvent::RotationGesture { delta, phase, .. } => match phase {
TouchPhase::Started => Ev::RotationStarted,
TouchPhase::Moved => Ev::RotationMoved(delta),
TouchPhase::Ended => Ev::RotationEnded,
TouchPhase::Cancelled => Ev::RotationCancelled,
},
_ => panic!("unexpected event: {:?}", e),
})
.collect()
}
// -----------------------------------------------------------------------
// TouchState: single-finger forwarding
// -----------------------------------------------------------------------
#[test]
fn single_finger_press_move_release() {
let mut state = TouchState::default();
let evs = state.process(1, pt(100.0, 200.0), TouchPhase::Started);
assert_eq!(classify(&evs), vec![Ev::Pressed(100.0, 200.0)]);
let evs = state.process(1, pt(110.0, 200.0), TouchPhase::Moved);
assert_eq!(classify(&evs), vec![Ev::Moved(110.0, 200.0)]);
let evs = state.process(1, pt(110.0, 200.0), TouchPhase::Ended);
assert_eq!(classify(&evs), vec![Ev::Released(110.0, 200.0), Ev::Exit]);
}
#[test]
fn single_finger_cancel() {
let mut state = TouchState::default();
state.process(1, pt(100.0, 200.0), TouchPhase::Started);
let evs = state.process(1, pt(100.0, 200.0), TouchPhase::Cancelled);
assert_eq!(classify(&evs), vec![Ev::Released(100.0, 200.0), Ev::Exit]);
}
#[test]
fn non_primary_move_ignored() {
let mut state = TouchState::default();
// Touch 1 is primary.
state.process(1, pt(100.0, 200.0), TouchPhase::Started);
// Move for a different ID that was never started (edge case).
let evs = state.process(99, pt(50.0, 50.0), TouchPhase::Moved);
assert!(classify(&evs).is_empty());
}
// -----------------------------------------------------------------------
// TouchState: two-finger → gesture transition
// -----------------------------------------------------------------------
#[test]
fn two_fingers_synthesize_release_then_gesture() {
let mut state = TouchState::default();
// Finger 1 down.
let evs = state.process(1, pt(100.0, 200.0), TouchPhase::Started);
assert_eq!(classify(&evs), vec![Ev::Pressed(100.0, 200.0)]);
// Finger 2 down → synthesized release for finger 1.
let evs = state.process(2, pt(200.0, 200.0), TouchPhase::Started);
assert_eq!(classify(&evs), vec![Ev::Released(100.0, 200.0)]);
assert!(matches!(state.gesture_state, GestureRecognitionState::TwoFingersDown { .. }));
// Move finger 2 far enough to trigger pinch (> 8px threshold).
let evs = state.process(2, pt(220.0, 200.0), TouchPhase::Moved);
assert_eq!(classify(&evs), vec![Ev::PinchStarted, Ev::RotationStarted]);
assert!(matches!(state.gesture_state, GestureRecognitionState::Pinching { .. }));
}
#[test]
fn two_fingers_below_threshold_no_gesture() {
let mut state = TouchState::default();
state.process(1, pt(100.0, 200.0), TouchPhase::Started);
state.process(2, pt(200.0, 200.0), TouchPhase::Started);
// Small movement within threshold.
let evs = state.process(2, pt(202.0, 200.0), TouchPhase::Moved);
assert!(classify(&evs).is_empty());
assert!(matches!(state.gesture_state, GestureRecognitionState::TwoFingersDown { .. }));
}
#[test]
fn pinch_produces_scale_deltas() {
let mut state = TouchState::default();
// Set up: finger 1 at (0, 0), finger 2 at (100, 0) → distance = 100.
state.process(1, pt(0.0, 0.0), TouchPhase::Started);
state.process(2, pt(100.0, 0.0), TouchPhase::Started);
// Move finger 2 to (120, 0) to exceed threshold and start pinching.
state.process(2, pt(120.0, 0.0), TouchPhase::Moved);
assert!(matches!(state.gesture_state, GestureRecognitionState::Pinching { .. }));
// Now move finger 2 further to (180, 0).
// New distance = 180, initial distance (re-snapshotted) = 120.
// Scale = 180/120 = 1.5, delta = 1.5 - 1.0 = 0.5.
let evs = state.process(2, pt(180.0, 0.0), TouchPhase::Moved);
let classified = classify(&evs);
assert_eq!(classified.len(), 2);
if let Ev::PinchMoved(delta) = classified[0] {
assert!((delta - 0.5).abs() < 0.01, "expected ~0.5, got {}", delta);
} else {
panic!("expected PinchMoved, got {:?}", classified[0]);
}
}
#[test]
fn rotation_produces_correct_deltas() {
let mut state = TouchState::default();
// Finger 1 at origin, finger 2 on the X axis at (100, 0).
// Initial angle = atan2(0, 100) = 0°.
state.process(1, pt(0.0, 0.0), TouchPhase::Started);
state.process(2, pt(100.0, 0.0), TouchPhase::Started);
// Move finger 2 far enough to trigger gesture.
state.process(2, pt(120.0, 0.0), TouchPhase::Moved);
assert!(matches!(state.gesture_state, GestureRecognitionState::Pinching { .. }));
// Rotate ~45° clockwise: move finger 2 from (120, 0) to roughly
// (70.7, 70.7) which is at 45° from origin.
// atan2(70.7, 70.7) ≈ 45°. Delta from re-snapshotted 0° = +45°.
// Slint convention: positive = clockwise → delta ≈ +45°.
let evs = state.process(2, pt(70.7, 70.7), TouchPhase::Moved);
let classified = classify(&evs);
assert_eq!(classified.len(), 2);
if let Ev::RotationMoved(delta) = classified[1] {
assert!((delta - 45.0).abs() < 1.0, "expected ~45.0 (clockwise), got {}", delta);
} else {
panic!("expected RotationMoved, got {:?}", classified[1]);
}
}
#[test]
fn rotation_across_180_degree_boundary() {
let mut state = TouchState::default();
// Finger 1 at origin, finger 2 at (-100, -10).
// angle = atan2(-10, -100) ≈ -174.3°.
state.process(1, pt(0.0, 0.0), TouchPhase::Started);
state.process(2, pt(-100.0, -10.0), TouchPhase::Started);
// Trigger gesture by moving far enough.
state.process(2, pt(-120.0, -10.0), TouchPhase::Moved);
assert!(matches!(state.gesture_state, GestureRecognitionState::Pinching { .. }));
// Rotate across the ±180° boundary: move finger 2 to (-100, 10).
// New angle = atan2(10, -100) ≈ 174.3°.
// Raw angular change crosses ±180°, but per-frame delta should be
// small (~11.4° which is 2 * 5.7°), NOT a ~349° jump.
let evs = state.process(2, pt(-100.0, 10.0), TouchPhase::Moved);
let classified = classify(&evs);
if let Ev::RotationMoved(delta) = classified[1] {
assert!(
delta.abs() < 20.0,
"rotation should be a small delta (~11°), got {} (discontinuity!)",
delta
);
} else {
panic!("expected RotationMoved, got {:?}", classified[1]);
}
}
// -----------------------------------------------------------------------
// TouchState: gesture end transitions
// -----------------------------------------------------------------------
#[test]
fn pinch_end_with_remaining_finger() {
let mut state = TouchState::default();
state.process(1, pt(0.0, 0.0), TouchPhase::Started);
state.process(2, pt(100.0, 0.0), TouchPhase::Started);
// Trigger pinch.
state.process(2, pt(120.0, 0.0), TouchPhase::Moved);
// Lift finger 2 → gesture ends, finger 1 gets re-pressed.
let evs = state.process(2, pt(120.0, 0.0), TouchPhase::Ended);
let classified = classify(&evs);
assert_eq!(classified, vec![Ev::PinchEnded, Ev::RotationEnded, Ev::Pressed(0.0, 0.0)]);
assert!(matches!(state.gesture_state, GestureRecognitionState::Idle));
assert_eq!(state.primary_touch_id, Some(1));
}
#[test]
fn pinch_cancel_emits_cancelled_and_exit() {
let mut state = TouchState::default();
state.process(1, pt(0.0, 0.0), TouchPhase::Started);
state.process(2, pt(100.0, 0.0), TouchPhase::Started);
state.process(2, pt(120.0, 0.0), TouchPhase::Moved);
// Cancel finger 2.
let evs = state.process(2, pt(120.0, 0.0), TouchPhase::Cancelled);
let classified = classify(&evs);
assert_eq!(classified, vec![Ev::PinchCancelled, Ev::RotationCancelled, Ev::Exit]);
assert!(state.primary_touch_id.is_none());
}
#[test]
fn two_fingers_down_lift_before_threshold_returns_to_idle() {
let mut state = TouchState::default();
state.process(1, pt(100.0, 200.0), TouchPhase::Started);
state.process(2, pt(200.0, 200.0), TouchPhase::Started);
assert!(matches!(state.gesture_state, GestureRecognitionState::TwoFingersDown { .. }));
// Lift finger 2 without exceeding movement threshold.
let evs = state.process(2, pt(200.0, 200.0), TouchPhase::Ended);
let classified = classify(&evs);
// Remaining finger 1 gets re-pressed.
assert_eq!(classified, vec![Ev::Pressed(100.0, 200.0)]);
assert!(matches!(state.gesture_state, GestureRecognitionState::Idle));
assert_eq!(state.primary_touch_id, Some(1));
}
#[test]
fn two_fingers_down_cancel_both_emits_exit() {
let mut state = TouchState::default();
state.process(1, pt(100.0, 200.0), TouchPhase::Started);
state.process(2, pt(200.0, 200.0), TouchPhase::Started);
// Cancel finger 2 (gesture finger, no remaining → Exit).
let evs = state.process(2, pt(200.0, 200.0), TouchPhase::Cancelled);
assert_eq!(classify(&evs), vec![Ev::Exit]);
// Cancel finger 1 (now in Idle, but not primary since cancel cleared it).
let evs = state.process(1, pt(100.0, 200.0), TouchPhase::Cancelled);
assert!(classify(&evs).is_empty());
}
// -----------------------------------------------------------------------
// TouchState: 3+ fingers
// -----------------------------------------------------------------------
#[test]
fn third_finger_ignored_for_gesture() {
let mut state = TouchState::default();
state.process(1, pt(0.0, 0.0), TouchPhase::Started);
state.process(2, pt(100.0, 0.0), TouchPhase::Started);
// Third finger: no additional events.
let evs = state.process(3, pt(50.0, 50.0), TouchPhase::Started);
assert!(classify(&evs).is_empty());
assert_eq!(state.active_touches.len(), 3);
}
// -----------------------------------------------------------------------
// Angle wrapping via Euclid
// -----------------------------------------------------------------------
#[test]
fn euclid_angle_signed_wrapping() {
use euclid::Angle;
let wrap = |deg: f32| Angle::degrees(deg).signed().to_degrees();
assert!(wrap(0.0).abs() < f32::EPSILON);
assert!((wrap(180.0) - 180.0).abs() < 0.01);
assert!((wrap(181.0) - (-179.0)).abs() < 0.01);
assert!((wrap(-181.0) - 179.0).abs() < 0.01);
assert!(wrap(360.0).abs() < 0.01);
}
#[test]
fn zero_distance_fingers_no_division_by_zero() {
let mut state = TouchState::default();
// Two fingers at the exact same position → distance = 0.
state.process(1, pt(100.0, 100.0), TouchPhase::Started);
state.process(2, pt(100.0, 100.0), TouchPhase::Started);
assert!(matches!(state.gesture_state, GestureRecognitionState::TwoFingersDown { .. }));
// Move one finger far enough to trigger gesture.
let evs = state.process(2, pt(120.0, 100.0), TouchPhase::Moved);
assert!(matches!(state.gesture_state, GestureRecognitionState::Pinching { .. }));
let classified = classify(&evs);
assert_eq!(classified.len(), 2);
assert_eq!(classified[0], Ev::PinchStarted);
// Move further — scale should not be inf/NaN despite initial_distance
// having been 0 (re-snapshotted to 20.0 at threshold crossing).
let evs = state.process(2, pt(140.0, 100.0), TouchPhase::Moved);
let classified = classify(&evs);
if let Ev::PinchMoved(delta) = classified[0] {
assert!(delta.is_finite(), "scale delta should be finite, got {}", delta);
} else {
panic!("expected PinchMoved, got {:?}", classified[0]);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
extern crate alloc;
#[test]
fn test_to_string() {
let test_cases = [
(
"a",
KeyboardModifiers { alt: false, control: true, shift: false, meta: false },
false,
false,
"⌘A",
"Ctrl+A",
"Ctrl+A",
),
(
"a",
KeyboardModifiers { alt: true, control: true, shift: true, meta: true },
false,
false,
"⌃⌥⇧⌘A",
"Win+Ctrl+Alt+Shift+A",
"Super+Ctrl+Alt+Shift+A",
),
(
"\u{001b}",
KeyboardModifiers { alt: false, control: true, shift: true, meta: false },
false,
false,
"⇧⌘Escape",
"Ctrl+Shift+Escape",
"Ctrl+Shift+Escape",
),
(
"+",
KeyboardModifiers { alt: false, control: true, shift: false, meta: false },
true,
false,
"⌘+",
"Ctrl++",
"Ctrl++",
),
(
"a",
KeyboardModifiers { alt: true, control: true, shift: false, meta: false },
false,
true,
"⌘A",
"Ctrl+A",
"Ctrl+A",
),
(
"",
KeyboardModifiers { alt: false, control: true, shift: false, meta: false },
false,
false,
"",
"",
"",
),
(
"\u{000a}",
KeyboardModifiers { alt: false, control: false, shift: false, meta: false },
false,
false,
"Return",
"Return",
"Return",
),
(
"\u{0009}",
KeyboardModifiers { alt: false, control: false, shift: false, meta: false },
false,
false,
"Tab",
"Tab",
"Tab",
),
(
"\u{0020}",
KeyboardModifiers { alt: false, control: false, shift: false, meta: false },
false,
false,
"Space",
"Space",
"Space",
),
(
"\u{0008}",
KeyboardModifiers { alt: false, control: false, shift: false, meta: false },
false,
false,
"Backspace",
"Backspace",
"Backspace",
),
];
for (
key,
modifiers,
ignore_shift,
ignore_alt,
_expected_macos,
_expected_windows,
_expected_linux,
) in test_cases
{
let shortcut = make_keys(key.into(), modifiers, ignore_shift, ignore_alt);
use crate::alloc::string::ToString;
let result = shortcut.to_string();
#[cfg(target_os = "macos")]
assert_eq!(result.as_str(), _expected_macos, "Failed for key: {:?}", key);
#[cfg(target_os = "windows")]
assert_eq!(result.as_str(), _expected_windows, "Failed for key: {:?}", key);
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
assert_eq!(result.as_str(), _expected_linux, "Failed for key: {:?}", key);
}
}
#[test]
fn test_from_parts_valid() {
let f5_key = alloc::string::String::from(char::from(key_codes::Key::F5));
let ret_key = alloc::string::String::from(char::from(key_codes::Key::Return));
let pause_key = alloc::string::String::from(char::from(key_codes::Key::Pause));
// (description, input parts, expected key, modifiers, ignore_shift, ignore_alt)
let cases: &[(&str, &[&str], &str, KeyboardModifiers, bool, bool)] = &[
(
"Control+A",
&["Control", "A"],
"a",
KeyboardModifiers { control: true, ..Default::default() },
false,
false,
),
(
"Control+Shift+A",
&["Control", "Shift", "A"],
"a",
KeyboardModifiers { control: true, shift: true, ..Default::default() },
false,
false,
),
(
"Control+Shift?+Z (explicit ignore_shift)",
&["Control", "Shift?", "Z"],
"z",
KeyboardModifiers { control: true, ..Default::default() },
true,
false,
),
(
"Control+Alt?+A (ignore_alt)",
&["Control", "Alt?", "A"],
"a",
KeyboardModifiers { control: true, ..Default::default() },
false,
true,
),
(
"F5 alone (special key)",
&["F5"],
&f5_key,
KeyboardModifiers::default(),
false,
false,
),
("Return key", &["Return"], &ret_key, KeyboardModifiers::default(), false, false),
(
"Control+Plus (LocalizedShiftable → auto ignore_shift)",
&["Control", "Plus"],
"+",
KeyboardModifiers { control: true, ..Default::default() },
true,
false,
),
(
"Control+'+' (literal, no auto ignore_shift)",
&["Control", "+"],
"+",
KeyboardModifiers { control: true, ..Default::default() },
false,
false,
),
(
"Control+Shift+Alt+A (all modifiers)",
&["Control", "Shift", "Alt", "A"],
"a",
KeyboardModifiers { control: true, shift: true, alt: true, ..Default::default() },
false,
false,
),
("empty input → Keys::default()", &[], "", KeyboardModifiers::default(), false, false),
(
"Control+€ (unicode literal)",
&["Control", "€"],
"€",
KeyboardModifiers { control: true, ..Default::default() },
false,
false,
),
(
"Control+é (lowercase literal)",
&["Control", "é"],
"é",
KeyboardModifiers { control: true, ..Default::default() },
false,
false,
),
("A alone (named key)", &["A"], "a", KeyboardModifiers::default(), false, false),
// The special keys are represented by reserved unicode codepoints. Passing
// one of those characters as a literal must produce the same `Keys` as its
// name, so `to_parts` output stays acceptable to `from_parts`.
(
"F5 codepoint literal",
&[&f5_key],
&f5_key,
KeyboardModifiers::default(),
false,
false,
),
(
"Pause codepoint literal",
&[&pause_key],
&pause_key,
KeyboardModifiers::default(),
false,
false,
),
(
"Control + F5 codepoint literal",
&["Control", &f5_key],
&f5_key,
KeyboardModifiers { control: true, ..Default::default() },
false,
false,
),
// Whitespace is significant: these literals name the Space/Tab/Return keys
// and must agree with their named spellings (parts are not trimmed).
("\" \" literal → Space", &[" "], " ", KeyboardModifiers::default(), false, false),
("Space named", &["Space"], " ", KeyboardModifiers::default(), false, false),
("\"\\t\" literal → Tab", &["\t"], "\t", KeyboardModifiers::default(), false, false),
("Tab named", &["Tab"], "\t", KeyboardModifiers::default(), false, false),
(
"\"\\n\" literal → Return",
&["\n"],
&ret_key,
KeyboardModifiers::default(),
false,
false,
),
(
"Control+\" \" (literal space with a modifier)",
&["Control", " "],
" ",
KeyboardModifiers { control: true, ..Default::default() },
false,
false,
),
(
"empty part is skipped → Keys::default()",
&[""],
"",
KeyboardModifiers::default(),
false,
false,
),
(
"a alone (literal fallback, same result as named A)",
&["a"],
"a",
KeyboardModifiers::default(),
false,
false,
),
];
for (desc, parts, expected_key, mods, is, ia) in cases {
let result =
Keys::from_parts(parts.iter().copied()).unwrap_or_else(|e| panic!("{desc}: {e}"));
assert_eq!(result, make_keys((*expected_key).into(), *mods, *is, *ia), "{desc}");
}
}
#[test]
fn test_from_parts_invalid() {
use super::KeysParseErrorInner;
let cases: &[(&str, &[&str], KeysParseError)] = &[
// Case-sensitive modifiers: unrecognized modifier parses as a second key
("lowercase 'control'", &["control", "A"], KeysParseError(KeysParseErrorInner::MultipleKeys)),
("uppercase 'CONTROL'", &["CONTROL", "A"], KeysParseError(KeysParseErrorInner::MultipleKeys)),
("'Ctrl' alias", &["Ctrl", "A"], KeysParseError(KeysParseErrorInner::MultipleKeys)),
("'ctrl' alias", &["ctrl", "A"], KeysParseError(KeysParseErrorInner::MultipleKeys)),
// Meta aliases not accepted
("'Win' alias", &["Win", "A"], KeysParseError(KeysParseErrorInner::MultipleKeys)),
("'Super' alias", &["Super", "A"], KeysParseError(KeysParseErrorInner::MultipleKeys)),
// No key
("modifiers only", &["Control", "Shift"], KeysParseError(KeysParseErrorInner::NoKey)),
// Multiple keys
("two keys", &["A", "B"], KeysParseError(KeysParseErrorInner::MultipleKeys)),
// Multi-grapheme cluster
(
"multi-char unknown",
&["Control", "Foobar"],
KeysParseError(KeysParseErrorInner::MultipleGraphemeClusters("Foobar".into())),
),
(
"two-char literal",
&["Control", "ab"],
KeysParseError(KeysParseErrorInner::MultipleGraphemeClusters("ab".into())),
),
// Parts are not trimmed, so a padded modifier is no longer a modifier: it
// falls through to the key branch and fails as a multi-grapheme literal.
(
"padded modifier ' Control ' alone",
&[" Control "],
KeysParseError(KeysParseErrorInner::MultipleGraphemeClusters(" Control ".into())),
),
(
"padded modifier ' Control ' is a key, so 'A' is a second key",
&[" Control ", "A"],
KeysParseError(KeysParseErrorInner::MultipleKeys),
),
(
"padded key ' A '",
&["Control", " A "],
KeysParseError(KeysParseErrorInner::MultipleGraphemeClusters(" A ".into())),
),
// Two whitespace literals are two keys, not one trimmed-away key.
("two space literals", &[" ", " "], KeysParseError(KeysParseErrorInner::MultipleKeys)),
(
"lowercase 'return' (not a named key)",
&["return"],
KeysParseError(KeysParseErrorInner::MultipleGraphemeClusters("return".into())),
),
// Not lowercase
(
"uppercase literal É",
&["Control", "É"],
KeysParseError(KeysParseErrorInner::NotLowercase("É".into())),
),
// Incompatible modifiers
(
"Shift + Shift?",
&["Shift", "Shift?", "A"],
KeysParseError(KeysParseErrorInner::IncompatibleModifiers("Shift and Shift? cannot be combined".into())),
),
(
"Alt + Alt?",
&["Alt", "Alt?", "A"],
KeysParseError(KeysParseErrorInner::IncompatibleModifiers("Alt and Alt? cannot be combined".into())),
),
(
"Shift + LocalizedShiftable key (Plus)",
&["Control", "Shift", "Plus"],
KeysParseError(KeysParseErrorInner::IncompatibleModifiers(
"Key bindings involving Plus ignore Shift to support different keyboard layouts; remove Shift".into(),
)),
),
];
for (desc, parts, expected_err) in cases {
let result = Keys::from_parts(parts.iter().copied());
assert!(result.is_err(), "{desc}: expected error, got {result:?}");
assert_eq!(&result.unwrap_err(), expected_err, "{desc}");
}
}
#[test]
fn test_to_parts_roundtrip() {
// Inputs that should round-trip identically through from_parts → to_parts.
let inputs: &[&[&str]] = &[
&[],
&["A"],
&["Control", "A"],
&["Control", "Shift", "A"],
&["Control", "Shift?", "Z"],
&["Control", "Alt?", "A"],
&["Control", "Alt", "Shift", "Meta", "A"],
&["Meta", "Control", "Alt", "Shift", "A"],
&["F5"],
&["Return"],
&["Space"],
&[" "], // literal space: to_parts emits the "Space" name
&["\t"], // literal tab: to_parts emits the "Tab" name
&["\n"], // literal newline: to_parts emits the "Return" name
&["Control", " "],
&["Control", "Plus"], // LocalizedShiftable: desugars to ["Control", "Shift?", "+"]
&["Control", "+"], // literal '+': stays ["Control", "+"] (no auto ignore_shift)
&["Control", "Digit0"],
&["Control", "€"],
&["Control", "é"],
];
for parts in inputs {
let k = Keys::from_parts(parts.iter().copied()).unwrap();
let out_strs: alloc::vec::Vec<&str> = k.to_parts().collect();
let k2 = Keys::from_parts(out_strs.iter().copied()).unwrap();
assert_eq!(k, k2, "round-trip mismatch for {parts:?} → {out_strs:?}");
}
}
#[test]
fn test_to_parts_canonical_form() {
// Spot-check the exact strings to lock in the canonical output. Modifiers keep
// their names; the key is always the stored character, never a key name.
let f5 = alloc::string::String::from(char::from(key_codes::Key::F5));
let ret = alloc::string::String::from(char::from(key_codes::Key::Return));
let pause = alloc::string::String::from(char::from(key_codes::Key::Pause));
let cases: &[(&[&str], &[&str])] = &[
(&[], &[]),
(&["A"], &["a"]), // named key → its stored character
(&["a"], &["a"]),
(&["Control", "S"], &["Control", "s"]),
(&["Control", "Shift?", "Z"], &["Control", "Shift?", "z"]),
(&["Control", "Alt?", "A"], &["Control", "Alt?", "a"]),
(&["F5"], &[&f5]),
(&[&f5], &[&f5]), // reserved codepoint as a literal
(&["Pause"], &[&pause]),
(&[&pause], &[&pause]),
// Whitespace and control characters are emitted raw, which is why a text
// format storing these has to escape them (see the runtime_key_bindings
// example). They still round-trip, because from_parts does not trim.
(&[" "], &[" "]),
(&["Space"], &[" "]),
(&["\t"], &["\t"]),
(&["Tab"], &["\t"]),
(&["\n"], &[&ret]),
(&["Return"], &[&ret]),
(&["Control", " "], &["Control", " "]),
// LocalizedShiftable: the auto ignore_shift is surfaced as Shift? and the
// raw character is emitted, so the "Plus" name is not reproduced.
(&["Control", "Plus"], &["Control", "Shift?", "+"]),
(&["Control", "+"], &["Control", "+"]), // literal '+': no auto ignore_shift
(&["Control", "€"], &["Control", "€"]),
(&["Meta", "Control", "Alt", "Shift", "A"], &["Meta", "Control", "Alt", "Shift", "a"]),
];
for (input, expected) in cases {
let k = Keys::from_parts(input.iter().copied()).unwrap();
let out_strs: alloc::vec::Vec<&str> = k.to_parts().collect();
assert_eq!(&out_strs.as_slice(), expected, "for input {input:?}");
}
}
#[test]
fn test_from_parts_matching() {
// (description, input parts, event text, event modifiers, should_match)
let cases: &[(&str, &[&str], &str, KeyboardModifiers, bool)] = &[
(
"Control+A matches",
&["Control", "A"],
"a",
KeyboardModifiers { control: true, ..Default::default() },
true,
),
(
"Control+A wrong key",
&["Control", "A"],
"b",
KeyboardModifiers { control: true, ..Default::default() },
false,
),
(
"Control+A wrong modifier",
&["Control", "A"],
"a",
KeyboardModifiers { alt: true, ..Default::default() },
false,
),
(
"Shift? matches with shift",
&["Control", "Shift?", "Z"],
"z",
KeyboardModifiers { control: true, shift: true, ..Default::default() },
true,
),
(
"Shift? matches without shift",
&["Control", "Shift?", "Z"],
"z",
KeyboardModifiers { control: true, ..Default::default() },
true,
),
];
for (desc, parts, text, mods, expected) in cases {
let k =
Keys::from_parts(parts.iter().copied()).unwrap_or_else(|e| panic!("{desc}: {e}"));
let event = KeyEvent { text: (*text).into(), modifiers: *mods, ..Default::default() };
assert_eq!(k.matches(&event), *expected, "{desc}");
}
// Special key matching: Return
let return_char: char = key_codes::Key::Return.into();
let k = Keys::from_parts(["Return"]).unwrap();
let event = KeyEvent {
text: SharedString::from(alloc::string::String::from(return_char)),
modifiers: KeyboardModifiers::default(),
..Default::default()
};
assert!(k.matches(&event), "Return key should match Return event");
}
}