// Copyright © SixtyFPS GmbH // 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 { 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 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 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 for char { fn from(k: Key) -> Self { match k { $(Key::$name => $name,)* } } } impl From 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 { 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 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, ) { *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) -> Result { keys_from_parts_inner(parts).map_err(KeysParseError) } fn keys_from_parts_inner<'a>( parts: impl Iterator, ) -> Result { 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::().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, ) -> Result { 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 { 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: // - Windows: // - Linux: 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>, /// 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>, /// The new cursor position, when None, the cursor is put after the text that was just inserted pub cursor_position: Option, /// The anchor position, when None, the cursor is put after the text that was just inserted pub anchor_position: Option, } impl InternalKeyEvent { /// If a shortcut was pressed, this function returns `Some(StandardShortcut)`. /// Otherwise it returns None. pub fn shortcut(&self) -> Option { 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 { 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>, click_count: Cell, click_position: Cell, click_button: Cell, } 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, /// 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, /// 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, /// 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, delayed: Option<(crate::timers::Timer, MouseEvent)>, delayed_exit_items: Vec, pub(crate) cursor: MouseCursorInner, } impl MouseInputState { /// Return the item in the top of the stack fn top_item(&self) -> Option { 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 { 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 { let action = self .drop_target .as_ref() .and_then(|t| t.upgrade()) .and_then(|i| i.downcast::()) .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, /// 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, 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, 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::().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, window_adapter: &Rc, ) { // 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, 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, 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| -> 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 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, 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| -> 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 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::().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, cursor_blink_timer: crate::timers::Timer, } impl TextCursorBlinker { /// Creates a new instance, wrapped in a Pin> because the boolean property /// the blinker properties uses the property system that requires pinning. pub fn new() -> Pin> { 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>, prop: &Property, 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>, 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; 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 { 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 }, /// Actively synthesizing PinchGesture/RotationGesture events. Pinching { finger_ids: (i32, i32), initial_distance: f32, last_scale: f32, last_angle: euclid::Angle, }, } /// 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, 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)> { let a = self.active_touches.get(id_a)?; let b = self.active_touches.get(id_b)?; let delta = (b.position - a.position).cast::(); 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 { let (a, b) = self.gesture_finger_positions()?; let mid = a.position.cast::().lerp(b.position.cast::(), 0.5); Some(mid.cast()) } /// Returns (distance, angle) between the two gesture fingers. fn gesture_geometry(&self) -> Option<(f32, euclid::Angle)> { let (a, b) = self.gesture_finger_positions()?; let delta = (b.position - a.position).cast::(); 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 { 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"); } }