diff --git a/CHANGELOG.md b/CHANGELOG.md index f0671c51943..a8dda6ec07b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ # Changelog All notable changes to this project are documented in this file. +## [1.18.0] - Unreleased + +### General + +- Added support for physical keys to `keys` and `KeyBinding` + ## [1.17.1] - 2026-07-07 - Fixed a panic/crash on startup when a global reads `Palette.color-scheme` (or accent-color) during its initialization. @@ -2489,4 +2495,4 @@ as well as the [Rust migration guide for the `sixtyfps` crate](api/rs/slint/migr [1.15.1]: https://github.com/slint-ui/slint/releases/tag/v1.15.1 [1.16.0]: https://github.com/slint-ui/slint/releases/tag/v1.16.0 [1.16.1]: https://github.com/slint-ui/slint/releases/tag/v1.16.1 -[1.17.0]: https://github.com/slint-ui/slint/releases/tag/v1.17.0 \ No newline at end of file +[1.17.0]: https://github.com/slint-ui/slint/releases/tag/v1.17.0 diff --git a/api/cpp/cbindgen.rs b/api/cpp/cbindgen.rs index 35f4f5f0fb3..e35c8b7dbdd 100644 --- a/api/cpp/cbindgen.rs +++ b/api/cpp/cbindgen.rs @@ -469,6 +469,8 @@ fn gen_corelib( "PointerEventButton", "PointerEvent", "PointerScrollEvent", + "WindowKeyEventType", + "WindowKeyEvent", "Rect", "BitmapFont", "DataTransferOpaque", diff --git a/api/cpp/include/private/slint_keys.h b/api/cpp/include/private/slint_keys.h index 9863d3dda3e..bae108a102b 100644 --- a/api/cpp/include/private/slint_keys.h +++ b/api/cpp/include/private/slint_keys.h @@ -18,7 +18,7 @@ class Keys; namespace private_api { void make_keys(Keys &out, const slint::SharedString &key, bool alt, bool control, bool shift, - bool meta, bool ignoreShift, bool ignoreAlt); + bool meta, bool ignoreShift, bool ignoreAlt, bool isPhysical); } // namespace private_api @@ -103,7 +103,7 @@ class Keys Keys(cbindgen_private::types::Keys &&data) : data(std::move(data)) { } friend void private_api::make_keys(Keys &out, const slint::SharedString &key, bool alt, bool control, bool shift, bool meta, bool ignoreShift, - bool ignoreAlt); + bool ignoreAlt, bool isPhysical); }; namespace private_api { @@ -111,10 +111,10 @@ namespace private_api { // We need to use Keys& out so that we can forward-declare this and then use it as a friend // Otherwise the size of `Keys` is not yet known. inline void make_keys(Keys &out, const slint::SharedString &key, bool alt, bool control, bool shift, - bool meta, bool ignoreShift, bool ignoreAlt) + bool meta, bool ignoreShift, bool ignoreAlt, bool isPhysical) { ::slint::cbindgen_private::types::slint_keys(&key, alt, control, shift, meta, ignoreShift, - ignoreAlt, &out.data); + ignoreAlt, isPhysical, &out.data); } } // namespace private_api diff --git a/api/python/slint/tests/test_named_tuples.py b/api/python/slint/tests/test_named_tuples.py index 05f58507840..9fac9b8e3e8 100644 --- a/api/python/slint/tests/test_named_tuples.py +++ b/api/python/slint/tests/test_named_tuples.py @@ -7,7 +7,7 @@ NAMED_TUPLES = [ (StandardListViewItem, {"text": ""}), - (KeyEvent, {"text": "", "modifiers": None, "repeat": False}), + (KeyEvent, {"text": "", "physical_key": "", "modifiers": None, "repeat": False}), ( KeyboardModifiers, {"shift": False, "control": False, "alt": False, "meta": False}, diff --git a/cspell.json b/cspell.json index 2e45deb780f..02a876a62f9 100644 --- a/cspell.json +++ b/cspell.json @@ -5,6 +5,7 @@ { "languageId": "rust", "words": [ + "Backquote", "bbox", "bindgen", "boxshadowcache", @@ -13,6 +14,7 @@ "codemap", "concat", "Consts", + "evdev", // Linux input event device key codes "evenodd", "FBOs", // plural of FBO (frame buffer object) "fontdb", diff --git a/docs/astro/src/content/docs/reference/keyboard-input/overview.mdx b/docs/astro/src/content/docs/reference/keyboard-input/overview.mdx index 525bda9dbbe..48b95314337 100644 --- a/docs/astro/src/content/docs/reference/keyboard-input/overview.mdx +++ b/docs/astro/src/content/docs/reference/keyboard-input/overview.mdx @@ -2,7 +2,7 @@ title: Key Handling Overview description: Key Handling Overview --- -{/* cSpell: ignore Backtab */} +{/* cSpell: ignore Backtab WASD */} import Link from '@slint/common-files/src/components/Link.astro'; import SpecialKeys from "/src/content/docs/reference/generated/enums/_keys.md" @@ -21,6 +21,7 @@ Slint's elements and the built-in < This structure is generated and passed to the key press and release callbacks of the `FocusScope` element. - **`text`** (_string_): The unicode representation of the key pressed. +- **`physical-key`** (_string_): The physical key that was pressed, if the backend reports it. This uses the same names as `@physical-keys(...)`, for example `A`, `Digit1`, or `LeftArrow`. - **`modifiers`** (_KeyboardModifiers_): The keyboard modifiers active at the time of the key press event. - **`repeat`** (_bool_): This field is set to true for key press events that are repeated, i.e. the key is held down. It's always false for key release events. @@ -29,7 +30,7 @@ Slint's elements and the built-in < Use the inside a `FocusScope` to declare key bindings (or keyboard shortcuts). The `KeyBinding`'s `keys` property takes an instance of the and invokes the `activated` callback when the key combination is detected. -The `keys` type represents a key, combined with modifiers and is constructed with the `@keys` macro. +The `keys` type represents a key, combined with modifiers and is constructed with either the `@keys` macro for logical keys or the `@physical-keys` macro for physical key positions. Key bindings in Slint are based on **logical keys** — the character a keypress produces on the current keyboard layout — not the physical position of a key. This means, for example, that `@keys(Control + Z)` activates when the user presses the key that produces `z` on their layout, regardless of where that key sits on the keyboard. @@ -101,6 +102,27 @@ This will allow your users to reach the binding on almost all keyboard layouts t As another example, `@keys(Control + Plus)` is equivalent to `@keys(Control + Shift? + "+")`. +### Physical Key Bindings + +Use `@physical-keys(..)` to match a physical key position instead of the character produced on the current keyboard layout. +For example, WASD bindings in a game should use `@physical-keys(..)` so the movement keys are always in the same position on all layouts. + +Physical key names reuse familiar `Key` names such as `A`, `Digit1`, `BackQuote`, or `LeftArrow`, but they refer to a physical key location on a U.S. keyboard layout, instead of logical text. +Unlike `@keys(..)`, `@physical-keys(..)` does not currently accept string literals. + +```slint +export component Example inherits Window { + forward-focus: scope; + + scope := FocusScope { + KeyBinding { + keys: @physical-keys(Control + A); + activated => { debug("The physical A key was pressed"); } + } + } +} +``` + ## Key Namespace The `Key` namespace contains a list of well-known logical key codes (including any non-printable characters). diff --git a/docs/astro/src/content/docs/reference/primitive-types.mdx b/docs/astro/src/content/docs/reference/primitive-types.mdx index b46fc805a11..81df14ae4ff 100644 --- a/docs/astro/src/content/docs/reference/primitive-types.mdx +++ b/docs/astro/src/content/docs/reference/primitive-types.mdx @@ -328,7 +328,7 @@ In Python, properties or struct fields of the color type are mapped to for details. diff --git a/editors/tree-sitter-slint/grammar.js b/editors/tree-sitter-slint/grammar.js index 949b0790ba5..2ad32997044 100644 --- a/editors/tree-sitter-slint/grammar.js +++ b/editors/tree-sitter-slint/grammar.js @@ -412,6 +412,7 @@ module.exports = grammar({ prec.right( choice( $.keys, + $.physical_keys, $.parens_op, $.index_op, $.tr, @@ -488,6 +489,23 @@ module.exports = grammar({ ")", ), + // @physical-keys(...) + _physical_keys_entry: ($) => seq($.simple_identifier, optional("?")), + physical_keys: ($) => + seq( + "@physical-keys", + "(", + optional( + seq( + $._physical_keys_entry, + repeat(seq( + "+", + $._physical_keys_entry + )) + )), + ")", + ), + member_access: ($) => prec.left( 17, diff --git a/internal/backends/android-activity/androidwindowadapter.rs b/internal/backends/android-activity/androidwindowadapter.rs index f65ce4c6ba7..8d77ef7dc41 100644 --- a/internal/backends/android-activity/androidwindowadapter.rs +++ b/internal/backends/android-activity/androidwindowadapter.rs @@ -17,7 +17,8 @@ use i_slint_core::api::{ use i_slint_core::input::{InternalKeyEvent, KeyEvent, KeyEventType, TouchPhase}; use i_slint_core::lengths::PhysicalEdges; use i_slint_core::platform::{ - Key, PointerEventButton, WindowAdapter, WindowEvent, WindowProperties, + Key, PointerEventButton, WindowAdapter, WindowEvent, WindowKeyEvent, WindowKeyEventType, + WindowProperties, }; use i_slint_core::timers::{Timer, TimerMode}; use i_slint_core::window::{InputMethodRequest, WindowInner}; @@ -680,16 +681,27 @@ fn button_for_event( } fn map_key_event(key_event: &android_activity::input::KeyEvent) -> Option { - let text = map_key_code(key_event.key_code())?; - let repeat = key_event.repeat_count() > 0; - match key_event.action() { - KeyAction::Down if repeat => Some(WindowEvent::KeyPressRepeated { text }), - KeyAction::Down => Some(WindowEvent::KeyPressed { text }), - KeyAction::Up => Some(WindowEvent::KeyReleased { text }), - KeyAction::Multiple if repeat => Some(WindowEvent::KeyPressRepeated { text }), - KeyAction::Multiple => Some(WindowEvent::KeyPressed { text }), - _ => None, + let mut slint_event = KeyEvent::default(); + slint_event.text = map_key_code(key_event.key_code())?; + slint_event.physical_key = physical_key_name(key_event.scan_code()); + slint_event.repeat = key_event.repeat_count() > 0; + let event_type = match key_event.action() { + KeyAction::Down | KeyAction::Multiple => WindowKeyEventType::Pressed, + KeyAction::Up => WindowKeyEventType::Released, + _ => return None, + }; + let slint_event = WindowKeyEvent::new(event_type, slint_event); + Some(WindowEvent::Key(slint_event)) +} + +// The Android scan code is the evdev key code, which maps to XKB with a fixed offset of 8. +fn physical_key_name(scan_code: i32) -> SharedString { + if scan_code <= 0 { + return SharedString::default(); } + i_slint_common::physical_key_codes::physical_key_name_from_xkb(scan_code as u32 + 8) + .unwrap_or_default() + .into() } fn map_key_code(code: android_activity::input::Keycode) -> Option { diff --git a/internal/backends/linuxkms/calloop_backend/input.rs b/internal/backends/linuxkms/calloop_backend/input.rs index 41fd79e3c0f..5c2f6272a9f 100644 --- a/internal/backends/linuxkms/calloop_backend/input.rs +++ b/internal/backends/linuxkms/calloop_backend/input.rs @@ -18,7 +18,9 @@ use std::path::Path; use std::pin::Pin; use std::rc::Rc; +use i_slint_common::physical_key_codes::physical_key_name_from_xkb; use i_slint_core::api::LogicalPosition; +use i_slint_core::input::KeyEvent; use i_slint_core::lengths::logical_point_from_api; use i_slint_core::platform::{PlatformError, PointerEventButton, WindowEvent}; use i_slint_core::window::{WindowAdapter, WindowInner}; @@ -318,7 +320,8 @@ impl<'a> calloop::EventSource for LibInputHandler<'a> { }, input::Event::Keyboard(input::event::KeyboardEvent::Key(key_event)) => { // On Linux key codes have a fixed offset of 8: https://docs.rs/xkbcommon/0.6.0/xkbcommon/xkb/struct.Keycode.html - let key_code = xkb::Keycode::new(key_event.key() + 8); + let xkb_keycode = key_event.key() + 8; + let key_code = xkb::Keycode::new(xkb_keycode); let state = key_event.key_state(); let xkb_key_state = self.keystate.get_or_insert_with(|| { @@ -365,11 +368,23 @@ impl<'a> calloop::EventSource for LibInputHandler<'a> { } if let Some(text) = map_key_sym(sym) { - let event = match state { - KeyState::Pressed => WindowEvent::KeyPressed { text }, - KeyState::Released => WindowEvent::KeyReleased { text }, + let mut key_event = KeyEvent::default(); + key_event.text = text; + key_event.physical_key = + physical_key_name_from_xkb(xkb_keycode).unwrap_or_default().into(); + let event_type = match state { + KeyState::Pressed => { + i_slint_core::platform::WindowKeyEventType::Pressed + } + KeyState::Released => { + i_slint_core::platform::WindowKeyEventType::Released + } }; - window.dispatch_event_with_result(event).map_err(Self::Error::other)?; + let key_event = + i_slint_core::platform::WindowKeyEvent::new(event_type, key_event); + window + .dispatch_event_with_result(WindowEvent::Key(key_event)) + .map_err(Self::Error::other)?; } } _ => {} diff --git a/internal/backends/qt/qt_window.rs b/internal/backends/qt/qt_window.rs index 229c03dda73..abc42b24c93 100644 --- a/internal/backends/qt/qt_window.rs +++ b/internal/backends/qt/qt_window.rs @@ -32,7 +32,7 @@ use i_slint_core::lengths::{ LogicalBorderRadius, LogicalLength, LogicalPoint, LogicalRect, LogicalSize, LogicalVector, PhysicalPx, ScaleFactor, logical_size_from_api, }; -use i_slint_core::platform::{PlatformError, WindowEvent}; +use i_slint_core::platform::{PlatformError, WindowEvent, WindowKeyEvent, WindowKeyEventType}; use i_slint_core::string::ToSharedString; use i_slint_core::textlayout::sharedparley::{self, GlyphRenderer, fontique, parley}; use i_slint_core::window::{ @@ -350,9 +350,11 @@ cpp! {{ return; QString text = event->text(); int key = event->key(); + quint32 scan_code = event->nativeScanCode(); + quint32 virtual_key = event->nativeVirtualKey(); bool repeat = event->isAutoRepeat(); - rust!(Slint_keyPress [rust_window: &QtWindow as "void*", key: i32 as "int", text: qttypes::QString as "QString", repeat: bool as "bool"] { - rust_window.key_event(key, text.clone(), false, repeat); + rust!(Slint_keyPress [rust_window: &QtWindow as "void*", key: i32 as "int", text: qttypes::QString as "QString", scan_code: u32 as "quint32", virtual_key: u32 as "quint32", repeat: bool as "bool"] { + rust_window.key_event(key, text.clone(), scan_code, virtual_key, false, repeat); }); } void keyReleaseEvent(QKeyEvent *event) override { @@ -365,8 +367,10 @@ cpp! {{ QString text = event->text(); int key = event->key(); - rust!(Slint_keyRelease [rust_window: &QtWindow as "void*", key: i32 as "int", text: qttypes::QString as "QString"] { - rust_window.key_event(key, text.clone(), true, false); + quint32 scan_code = event->nativeScanCode(); + quint32 virtual_key = event->nativeVirtualKey(); + rust!(Slint_keyRelease [rust_window: &QtWindow as "void*", key: i32 as "int", text: qttypes::QString as "QString", scan_code: u32 as "quint32", virtual_key: u32 as "quint32"] { + rust_window.key_event(key, text.clone(), scan_code, virtual_key, true, false); }); } @@ -2201,20 +2205,28 @@ impl QtWindow { .unwrap_or(key_generated::Qt_DropAction_IgnoreAction) } - fn key_event(&self, key: i32, text: qttypes::QString, released: bool, repeat: bool) { + fn key_event( + &self, + key: i32, + text: qttypes::QString, + scan_code: u32, + virtual_key: u32, + released: bool, + repeat: bool, + ) { i_slint_core::animations::update_animations(); let text: String = text.into(); - let text = qt_key_to_string(key as key_generated::Qt_Key, text); + let mut key_event = KeyEvent::default(); + key_event.text = qt_key_to_string(key as key_generated::Qt_Key, text); + key_event.physical_key = native_code_to_physical_key(scan_code, virtual_key); + key_event.repeat = repeat; - let event = if released { - WindowEvent::KeyReleased { text } - } else if repeat { - WindowEvent::KeyPressRepeated { text } - } else { - WindowEvent::KeyPressed { text } - }; - self.window.dispatch_event(event); + let event_type = + if released { WindowKeyEventType::Released } else { WindowKeyEventType::Pressed }; + + let event = WindowKeyEvent::new(event_type, key_event); + self.window.dispatch_event(WindowEvent::Key(event)); timer_event(); } @@ -2934,6 +2946,49 @@ mod key_codes { i_slint_common::for_each_keys!(define_qt_key_to_string_fn); } +/// On X11 and Wayland the native scan code is the XKB keycode. +#[cfg(target_os = "linux")] +fn native_code_to_physical_key(scan_code: u32, _virtual_key: u32) -> SharedString { + i_slint_common::physical_key_codes::physical_key_name_from_xkb(scan_code) + .unwrap_or_default() + .into() +} + +/// Qt reports extended keys with bit 8 set; Chromium's table uses 0xe0-prefixed scan codes. +#[cfg(target_os = "windows")] +fn native_code_to_physical_key(scan_code: u32, _virtual_key: u32) -> SharedString { + // Rewrite Qt's extended-key flag (bit 8) into the table's 0xe0 prefix, e.g. 0x14d -> 0xe04d. + let scan_code = if scan_code & 0x100 != 0 { (scan_code - 0x100) | 0xe000 } else { scan_code }; + macro_rules! scan_code_to_physical_key { + ($($name:ident # $_w:ident # $_xkb:literal # $win:literal # $($_mac:literal)?;)*) => { + match scan_code { + $($win => stringify!($name).into(),)* + _ => Default::default(), + } + }; + } + i_slint_common::for_each_physical_keys!(scan_code_to_physical_key) +} + +/// Uses the macOS virtual key +#[cfg(target_os = "macos")] +fn native_code_to_physical_key(_scan_code: u32, virtual_key: u32) -> SharedString { + macro_rules! virtual_key_to_physical_key { + ($($name:ident # $_w:ident # $_xkb:literal # $_win:literal # $($mac:literal)?;)*) => { + match virtual_key { + $($($mac => stringify!($name).into(),)?)* + _ => Default::default(), + } + }; + } + i_slint_common::for_each_physical_keys!(virtual_key_to_physical_key) +} + +#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] +fn native_code_to_physical_key(_scan_code: u32, _virtual_key: u32) -> SharedString { + SharedString::default() +} + fn qt_key_to_string(key: key_generated::Qt_Key, event_text: String) -> SharedString { // First try to see if we received one of the non-ascii keys that we have // a special representation for. If that fails, try to use the provided @@ -3050,3 +3105,42 @@ fn qt_password_character() -> char { }} as u32) .unwrap_or('●') } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::native_code_to_physical_key; + + #[test] + fn xkb_scan_codes_map_to_physical_keys() { + assert_eq!(native_code_to_physical_key(38, 0), "A"); + assert_eq!(native_code_to_physical_key(10, 0), "Digit1"); + assert_eq!(native_code_to_physical_key(113, 0), "LeftArrow"); + assert_eq!(native_code_to_physical_key(202, 0), "F24"); + assert_eq!(native_code_to_physical_key(0, 0), ""); + } +} + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::native_code_to_physical_key; + + #[test] + fn windows_scan_codes_map_to_physical_keys() { + assert_eq!(native_code_to_physical_key(0x001e, 0), "A"); + assert_eq!(native_code_to_physical_key(0x0002, 0), "Digit1"); + assert_eq!(native_code_to_physical_key(0x014d, 0), "RightArrow"); + assert_eq!(native_code_to_physical_key(0, 0), ""); + } +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::native_code_to_physical_key; + + #[test] + fn macos_virtual_keys_map_to_physical_keys() { + assert_eq!(native_code_to_physical_key(1, 0x00), "A"); + assert_eq!(native_code_to_physical_key(1, 0x7b), "LeftArrow"); + assert_eq!(native_code_to_physical_key(1, 0x99), ""); + } +} diff --git a/internal/backends/testing/internal_tests.rs b/internal/backends/testing/internal_tests.rs index e1404e03003..67cc4ffcb9b 100644 --- a/internal/backends/testing/internal_tests.rs +++ b/internal/backends/testing/internal_tests.rs @@ -75,6 +75,24 @@ pub fn send_key_combo_with_text< } } +/// Simulate entering a keyboard shortcut where each key has text and an optional physical key string. +/// +/// This is needed for tests that need to exercise physical keys (`@physical-keys(...)`). +pub fn send_key_combo_with_text_and_physical_keys< + X: vtable::HasStaticVTable, + Component: Into> + ComponentHandle, +>( + component: &Component, + keys: &[(SharedString, SharedString)], +) { + for (text, physical_key) in keys { + send_keyboard_key_text_with_physical_key(component, text, physical_key, true); + } + for (text, physical_key) in keys.iter().rev() { + send_keyboard_key_text_with_physical_key(component, text, physical_key, false); + } +} + /// Simulate a single key event with the given text (pressed or released). /// /// Unlike [`send_keyboard_char`], the text is dispatched as a single event, @@ -94,6 +112,29 @@ pub fn send_keyboard_key_text< ) } +/// Simulate a single key event with the given text and physical key string (pressed or released). +pub fn send_keyboard_key_text_with_physical_key< + X: vtable::HasStaticVTable, + Component: Into> + ComponentHandle, +>( + component: &Component, + text: &SharedString, + physical_key: &SharedString, + pressed: bool, +) { + use i_slint_core::input::{InternalKeyEvent, KeyEventType}; + + let mut key_event = i_slint_core::items::KeyEvent::default(); + key_event.text = text.clone(); + key_event.physical_key = physical_key.clone(); + + WindowInner::from_pub(component.window()).process_key_input(InternalKeyEvent { + event_type: if pressed { KeyEventType::KeyPressed } else { KeyEventType::KeyReleased }, + key_event, + ..Default::default() + }); +} + /// Simulate entering a sequence of ascii characters key by (pressed or released). pub fn send_keyboard_char< X: vtable::HasStaticVTable, diff --git a/internal/backends/winit/event_loop.rs b/internal/backends/winit/event_loop.rs index 11300e3f4c1..f5b52fae914 100644 --- a/internal/backends/winit/event_loop.rs +++ b/internal/backends/winit/event_loop.rs @@ -297,8 +297,26 @@ impl winit::application::ApplicationHandler for EventLoopState { } i_slint_common::for_each_keys!(winit_key_to_char) } + + fn to_slint_physical_key( + physical_key: &winit::keyboard::PhysicalKey, + ) -> SharedString { + macro_rules! winit_physical_key_to_name { + ($($name:ident # $code:ident # $_xkb:literal # $_win:literal # $($_mac:literal)?;)*) => { + #[cfg_attr(slint_nightly_test, allow(non_exhaustive_omitted_patterns))] + match physical_key { + $(winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::$code) => stringify!($name).into(),)* + _ => Default::default(), + } + }; + } + + i_slint_common::for_each_physical_keys!(winit_physical_key_to_name) + } + #[allow(unused_mut)] let mut text = to_slint_key(&event, &key_code); + let physical_key = to_slint_physical_key(&event.physical_key); #[cfg(target_os = "windows")] let text_without_modifiers = { @@ -346,6 +364,7 @@ impl winit::application::ApplicationHandler for EventLoopState { }; let mut key_event = KeyEvent::default(); key_event.text = text; + key_event.physical_key = physical_key; let event = corelib::input::InternalKeyEvent { key_event, diff --git a/internal/backends/winit/muda.rs b/internal/backends/winit/muda.rs index 688ce1271df..b00fee437ad 100644 --- a/internal/backends/winit/muda.rs +++ b/internal/backends/winit/muda.rs @@ -338,6 +338,125 @@ fn key_string_to_key(string: &str) -> muda::accelerator::Key { i_slint_common::for_each_keys!(key_string_to_code_impl) } +fn physical_key_string_to_code(string: &str) -> Option { + use muda::accelerator::Code; + + static WARN_ONCE: std::sync::Once = std::sync::Once::new(); + WARN_ONCE.call_once(|| { + i_slint_core::debug_log!( + "Warning: Physical keys used in menu shortcuts are interpreted as logical keys based on a US keyboard layout. Use @keys(...) for menu shortcuts." + ); + }); + + macro_rules! key_string_to_code_impl { + ($($name:ident # $code:ident # $_xkb:literal # $_win:literal # $($_mac:literal)?;)*) => { + match string { + $(stringify!($name) => Some(Code::$code),)* + _ => None, + } + }; + } + + i_slint_common::for_each_physical_keys!(key_string_to_code_impl) +} + +fn code_to_logical_key(code: muda::accelerator::Code) -> muda::accelerator::Key { + use muda::accelerator::{Code, Key}; + match code { + Code::KeyA => Key::Character("a".into()), + Code::KeyB => Key::Character("b".into()), + Code::KeyC => Key::Character("c".into()), + Code::KeyD => Key::Character("d".into()), + Code::KeyE => Key::Character("e".into()), + Code::KeyF => Key::Character("f".into()), + Code::KeyG => Key::Character("g".into()), + Code::KeyH => Key::Character("h".into()), + Code::KeyI => Key::Character("i".into()), + Code::KeyJ => Key::Character("j".into()), + Code::KeyK => Key::Character("k".into()), + Code::KeyL => Key::Character("l".into()), + Code::KeyM => Key::Character("m".into()), + Code::KeyN => Key::Character("n".into()), + Code::KeyO => Key::Character("o".into()), + Code::KeyP => Key::Character("p".into()), + Code::KeyQ => Key::Character("q".into()), + Code::KeyR => Key::Character("r".into()), + Code::KeyS => Key::Character("s".into()), + Code::KeyT => Key::Character("t".into()), + Code::KeyU => Key::Character("u".into()), + Code::KeyV => Key::Character("v".into()), + Code::KeyW => Key::Character("w".into()), + Code::KeyX => Key::Character("x".into()), + Code::KeyY => Key::Character("y".into()), + Code::KeyZ => Key::Character("z".into()), + Code::Digit0 => Key::Character("0".into()), + Code::Digit1 => Key::Character("1".into()), + Code::Digit2 => Key::Character("2".into()), + Code::Digit3 => Key::Character("3".into()), + Code::Digit4 => Key::Character("4".into()), + Code::Digit5 => Key::Character("5".into()), + Code::Digit6 => Key::Character("6".into()), + Code::Digit7 => Key::Character("7".into()), + Code::Digit8 => Key::Character("8".into()), + Code::Digit9 => Key::Character("9".into()), + Code::Backquote => Key::Character("`".into()), + Code::Minus => Key::Character("-".into()), + Code::Equal => Key::Character("=".into()), + Code::BracketLeft => Key::Character("[".into()), + Code::BracketRight => Key::Character("]".into()), + Code::Backslash => Key::Character("\\".into()), + Code::Semicolon => Key::Character(";".into()), + Code::Quote => Key::Character("'".into()), + Code::Comma => Key::Character(",".into()), + Code::Period => Key::Character(".".into()), + Code::Slash => Key::Character("/".into()), + Code::Space => Key::Character(" ".into()), + Code::Escape => Key::Escape, + Code::Tab => Key::Tab, + Code::Enter => Key::Enter, + Code::Backspace => Key::Backspace, + Code::Delete => Key::Delete, + Code::Insert => Key::Insert, + Code::Home => Key::Home, + Code::End => Key::End, + Code::PageUp => Key::PageUp, + Code::PageDown => Key::PageDown, + Code::ArrowUp => Key::ArrowUp, + Code::ArrowDown => Key::ArrowDown, + Code::ArrowLeft => Key::ArrowLeft, + Code::ArrowRight => Key::ArrowRight, + Code::ContextMenu => Key::ContextMenu, + Code::CapsLock => Key::CapsLock, + Code::ScrollLock => Key::ScrollLock, + Code::Pause => Key::Pause, + Code::F1 => Key::F1, + Code::F2 => Key::F2, + Code::F3 => Key::F3, + Code::F4 => Key::F4, + Code::F5 => Key::F5, + Code::F6 => Key::F6, + Code::F7 => Key::F7, + Code::F8 => Key::F8, + Code::F9 => Key::F9, + Code::F10 => Key::F10, + Code::F11 => Key::F11, + Code::F12 => Key::F12, + Code::F13 => Key::F13, + Code::F14 => Key::F14, + Code::F15 => Key::F15, + Code::F16 => Key::F16, + Code::F17 => Key::F17, + Code::F18 => Key::F18, + Code::F19 => Key::F19, + Code::F20 => Key::F20, + Code::F21 => Key::F21, + Code::F22 => Key::F22, + Code::F23 => Key::F23, + Code::F24 => Key::F24, + _ => Key::Unidentified, + } +} + fn keys_to_accelerator( keys: &i_slint_core::input::Keys, ) -> Option { @@ -370,9 +489,13 @@ fn keys_to_accelerator( modifiers |= Modifiers::SUPER; } } - let key = key_string_to_key(&shortcut.key); - - Some(KeyAccelerator::new(Some(modifiers), key)) + if shortcut.is_physical { + let code = physical_key_string_to_code(&shortcut.key)?; + Some(KeyAccelerator::new(Some(modifiers), code_to_logical_key(code))) + } else { + let key = key_string_to_key(&shortcut.key); + Some(KeyAccelerator::new(Some(modifiers), key)) + } } fn install_event_handler_if_necessary(proxy: EventLoopProxy) { diff --git a/internal/backends/winit/wasm_input_helper.rs b/internal/backends/winit/wasm_input_helper.rs index 0d5b2d8146c..ce1d88a5544 100644 --- a/internal/backends/winit/wasm_input_helper.rs +++ b/internal/backends/winit/wasm_input_helper.rs @@ -157,6 +157,24 @@ impl WasmInputHelper { } } }); + + fn process_key_event( + window: &i_slint_core::api::Window, + event: &web_sys::KeyboardEvent, + text: SharedString, + event_type: KeyEventType, + ) { + let mut key_event = KeyEvent::default(); + key_event.text = text; + key_event.physical_key = physical_key_name(&event.code()); + key_event.repeat = event.repeat(); + WindowInner::from_pub(window).process_key_input(InternalKeyEvent { + key_event, + event_type, + ..Default::default() + }); + } + let win = window_adapter.clone(); let shared_state2 = shared_state.clone(); h.add_event_listener("keydown", move |e: web_sys::KeyboardEvent| { @@ -177,12 +195,7 @@ impl WasmInputHelper { } shared_state2.borrow_mut().has_key_down = true; - let win_event = if e.repeat() { - WindowEvent::KeyPressRepeated { text } - } else { - WindowEvent::KeyPressed { text } - }; - window_adapter.window().dispatch_event(win_event); + process_key_event(window_adapter.window(), &e, text, KeyEventType::KeyPressed); } }); @@ -194,7 +207,7 @@ impl WasmInputHelper { { e.prevent_default(); shared_state2.borrow_mut().has_key_down = false; - window_adapter.window().dispatch_event(WindowEvent::KeyReleased { text }); + process_key_event(window_adapter.window(), &e, text, KeyEventType::KeyReleased); } }); @@ -326,6 +339,19 @@ fn event_text(e: &web_sys::KeyboardEvent, is_apple: bool) -> Option SharedString { + macro_rules! code_to_name { + ($($name:ident # $code:ident # $_xkb:literal # $_win:literal # $($_mac:literal)?;)*) => { + match code { + $(stringify!($code) => stringify!($name).into(),)* + _ => Default::default(), + } + }; + } + i_slint_common::for_each_physical_keys!(code_to_name) +} + scoped_tls_hkt::scoped_thread_local!(static CURRENT_WASM_CLIPBOARD_DATA : for<'a> &'a RefCell); pub(crate) fn set_clipboard_text(data: String, clipboard: i_slint_core::platform::Clipboard) { diff --git a/internal/common/builtin_structs.rs b/internal/common/builtin_structs.rs index 1e91c367fdc..99c8e196021 100644 --- a/internal/common/builtin_structs.rs +++ b/internal/common/builtin_structs.rs @@ -102,6 +102,12 @@ macro_rules! for_each_builtin_structs { pub struct KeyEvent { /// The unicode representation of the key pressed. text: SharedString, + /// The physical key that was pressed, if the backend can report it. + /// + /// This uses the same names as `@physical-keys(...)`, for example `A`, + /// `Digit1`, or `LeftArrow`. It is empty when the backend doesn't provide + /// physical key information. + physical_key: SharedString, /// The keyboard modifiers active at the time of the key press event. modifiers: KeyboardModifiers, /// This field is set to true for key press events that are repeated, diff --git a/internal/common/lib.rs b/internal/common/lib.rs index a5dbdcb6b63..9dcc4a7dac3 100644 --- a/internal/common/lib.rs +++ b/internal/common/lib.rs @@ -12,6 +12,7 @@ pub mod builtin_structs; pub mod color_parsing; pub mod enums; pub mod key_codes; +pub mod physical_key_codes; #[cfg(feature = "shared-fontique")] pub mod sharedfontique; diff --git a/internal/common/physical_key_codes.rs b/internal/common/physical_key_codes.rs new file mode 100644 index 00000000000..51f188b2875 --- /dev/null +++ b/internal/common/physical_key_codes.rs @@ -0,0 +1,136 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +//! Shared physical key names used by `@physical-keys(...)`. +//! +//! Currently these names are based on the US keyboard key names. +//! +//! This is currently in the shape: +//! +//! Slint # Winit # Xkb # Win # Mac +//! +//! `Xkb`/`Win`/`Mac` are the native key codes for: +//! Xkb: Linux X11/Wayland +//! Win: PS/2 set-1 scan codes for Windows with 0xe0 prefix for extended keys +//! Mac: The virtual_key code in the Qt backend +//! +//! See the comment in key_codes.rs for the usage of this macro. + +#[macro_export] +macro_rules! for_each_physical_keys { + ($macro:ident) => { + $macro! { + A # KeyA # 38 # 0x001e # 0x0000; + B # KeyB # 56 # 0x0030 # 0x000b; + C # KeyC # 54 # 0x002e # 0x0008; + D # KeyD # 40 # 0x0020 # 0x0002; + E # KeyE # 26 # 0x0012 # 0x000e; + F # KeyF # 41 # 0x0021 # 0x0003; + G # KeyG # 42 # 0x0022 # 0x0005; + H # KeyH # 43 # 0x0023 # 0x0004; + I # KeyI # 31 # 0x0017 # 0x0022; + J # KeyJ # 44 # 0x0024 # 0x0026; + K # KeyK # 45 # 0x0025 # 0x0028; + L # KeyL # 46 # 0x0026 # 0x0025; + M # KeyM # 58 # 0x0032 # 0x002e; + N # KeyN # 57 # 0x0031 # 0x002d; + O # KeyO # 32 # 0x0018 # 0x001f; + P # KeyP # 33 # 0x0019 # 0x0023; + Q # KeyQ # 24 # 0x0010 # 0x000c; + R # KeyR # 27 # 0x0013 # 0x000f; + S # KeyS # 39 # 0x001f # 0x0001; + T # KeyT # 28 # 0x0014 # 0x0011; + U # KeyU # 30 # 0x0016 # 0x0020; + V # KeyV # 55 # 0x002f # 0x0009; + W # KeyW # 25 # 0x0011 # 0x000d; + X # KeyX # 53 # 0x002d # 0x0007; + Y # KeyY # 29 # 0x0015 # 0x0010; + Z # KeyZ # 52 # 0x002c # 0x0006; + + Digit0 # Digit0 # 19 # 0x000b # 0x001d; + Digit1 # Digit1 # 10 # 0x0002 # 0x0012; + Digit2 # Digit2 # 11 # 0x0003 # 0x0013; + Digit3 # Digit3 # 12 # 0x0004 # 0x0014; + Digit4 # Digit4 # 13 # 0x0005 # 0x0015; + Digit5 # Digit5 # 14 # 0x0006 # 0x0017; + Digit6 # Digit6 # 15 # 0x0007 # 0x0016; + Digit7 # Digit7 # 16 # 0x0008 # 0x001a; + Digit8 # Digit8 # 17 # 0x0009 # 0x001c; + Digit9 # Digit9 # 18 # 0x000a # 0x0019; + + BackQuote # Backquote # 49 # 0x0029 # 0x0032; + HyphenMinus # Minus # 20 # 0x000c # 0x001b; + Equals # Equal # 21 # 0x000d # 0x0018; + OpenBracket # BracketLeft # 34 # 0x001a # 0x0021; + CloseBracket # BracketRight # 35 # 0x001b # 0x001e; + BackSlash # Backslash # 51 # 0x002b # 0x002a; + Semicolon # Semicolon # 47 # 0x0027 # 0x0029; + Quote # Quote # 48 # 0x0028 # 0x0027; + Comma # Comma # 59 # 0x0033 # 0x002b; + Period # Period # 60 # 0x0034 # 0x002f; + Slash # Slash # 61 # 0x0035 # 0x002c; + Space # Space # 65 # 0x0039 # 0x0031; + + Escape # Escape # 9 # 0x0001 # 0x0035; + Tab # Tab # 23 # 0x000f # 0x0030; + Return # Enter # 36 # 0x001c # 0x0024; + Backspace # Backspace # 22 # 0x000e # 0x0033; + Delete # Delete # 119 # 0xe053 # 0x0075; + Insert # Insert # 118 # 0xe052 # 0x0072; + Home # Home # 110 # 0xe047 # 0x0073; + End # End # 115 # 0xe04f # 0x0077; + PageUp # PageUp # 112 # 0xe049 # 0x0074; + PageDown # PageDown # 117 # 0xe051 # 0x0079; + UpArrow # ArrowUp # 111 # 0xe048 # 0x007e; + DownArrow # ArrowDown # 116 # 0xe050 # 0x007d; + LeftArrow # ArrowLeft # 113 # 0xe04b # 0x007b; + RightArrow # ArrowRight # 114 # 0xe04d # 0x007c; + Menu # ContextMenu # 135 # 0xe05d # 0x006e; + CapsLock # CapsLock # 66 # 0x003a # 0x0039; + ScrollLock # ScrollLock # 78 # 0x0046 # ; + Pause # Pause # 127 # 0x0045 # ; + + F1 # F1 # 67 # 0x003b # 0x007a; + F2 # F2 # 68 # 0x003c # 0x0078; + F3 # F3 # 69 # 0x003d # 0x0063; + F4 # F4 # 70 # 0x003e # 0x0076; + F5 # F5 # 71 # 0x003f # 0x0060; + F6 # F6 # 72 # 0x0040 # 0x0061; + F7 # F7 # 73 # 0x0041 # 0x0062; + F8 # F8 # 74 # 0x0042 # 0x0064; + F9 # F9 # 75 # 0x0043 # 0x0065; + F10 # F10 # 76 # 0x0044 # 0x006d; + F11 # F11 # 95 # 0x0057 # 0x0067; + F12 # F12 # 96 # 0x0058 # 0x006f; + F13 # F13 # 191 # 0x0064 # 0x0069; + F14 # F14 # 192 # 0x0065 # 0x006b; + F15 # F15 # 193 # 0x0066 # 0x0071; + F16 # F16 # 194 # 0x0067 # 0x006a; + F17 # F17 # 195 # 0x0068 # 0x0040; + F18 # F18 # 196 # 0x0069 # 0x004f; + F19 # F19 # 197 # 0x006a # 0x0050; + F20 # F20 # 198 # 0x006b # 0x005a; + F21 # F21 # 199 # 0x006c # ; + F22 # F22 # 200 # 0x006d # ; + F23 # F23 # 201 # 0x006e # ; + F24 # F24 # 202 # 0x0076 # ; + } + }; +} + +/// Maps an XKB keycode (the evdev keycode plus 8) to a Slint physical key name. +/// +/// This is what X11, Wayland, and libinput-based backends report as the native key code. +/// On Android the evdev scan code (plus 8) matches this too. +#[cfg(any(target_os = "linux", target_os = "android"))] +pub fn physical_key_name_from_xkb(keycode: u32) -> Option<&'static str> { + macro_rules! xkb_to_name { + ($($name:ident # $_winit:ident # $xkb:literal # $_win:literal # $($_mac:literal)?;)*) => { + match keycode { + $($xkb => Some(stringify!($name)),)* + _ => None, + } + }; + } + for_each_physical_keys!(xkb_to_name) +} diff --git a/internal/compiler/builtins.slint b/internal/compiler/builtins.slint index 2665725806d..e4ddc956f97 100644 --- a/internal/compiler/builtins.slint +++ b/internal/compiler/builtins.slint @@ -1322,6 +1322,8 @@ component MenuItem { /// The keyboard shortcut for this `MenuItem`. /// /// This property can only be set in a `MenuItem` that is part of a . + /// + /// Only shortcuts created with @keys() are supported, @physical-keys() are not supported. in property shortcut; /// When true, a checkmark will be shown next to the title of the `MenuItem`. /// \default false diff --git a/internal/compiler/expression_tree.rs b/internal/compiler/expression_tree.rs index 97fed60fddc..9332cc5eec6 100644 --- a/internal/compiler/expression_tree.rs +++ b/internal/compiler/expression_tree.rs @@ -2357,7 +2357,11 @@ pub fn pretty_print(f: &mut dyn std::fmt::Write, expression: &Expression) -> std None => write!(f, "{}.{}", e.enumeration.name, e.value), }, Expression::Keys(keys) => { - write!(f, "@keys({keys})") + if keys.is_physical { + write!(f, "@physical-keys({keys})") + } else { + write!(f, "@keys({keys})") + } } Expression::ReturnStatement(e) => { write!(f, "return ")?; diff --git a/internal/compiler/generator/cpp.rs b/internal/compiler/generator/cpp.rs index f4a7a577513..69d1efcbca5 100644 --- a/internal/compiler/generator/cpp.rs +++ b/internal/compiler/generator/cpp.rs @@ -3984,11 +3984,11 @@ fn compile_expression(expr: &llr::Expression, ctx: &EvaluationContext) -> String Expression::BoolLiteral(b) => b.to_string(), Expression::KeysLiteral(ks) => { format!( - "[&](const slint::SharedString &key, bool alt, bool control, bool shift, bool meta, bool ignoreShift, bool ignoreAlt) {{ + "[&](const slint::SharedString &key, bool alt, bool control, bool shift, bool meta, bool ignoreShift, bool ignoreAlt, bool isPhysical) {{ slint::Keys out; - slint::private_api::make_keys(out, key, alt, control, shift, meta, ignoreShift, ignoreAlt); + slint::private_api::make_keys(out, key, alt, control, shift, meta, ignoreShift, ignoreAlt, isPhysical); return out; - }}({}, {}, {}, {}, {}, {}, {})", + }}({}, {}, {}, {}, {}, {}, {}, {})", shared_string_literal(&ks.key), ks.modifiers.alt, ks.modifiers.control, @@ -3996,6 +3996,7 @@ fn compile_expression(expr: &llr::Expression, ctx: &EvaluationContext) -> String ks.modifiers.meta, ks.ignore_shift, ks.ignore_alt, + ks.is_physical, ) } Expression::PropertyReference(nr) => access_member(nr, ctx).get_property(), diff --git a/internal/compiler/generator/rust.rs b/internal/compiler/generator/rust.rs index 6973c759fea..ecbc4f448b8 100644 --- a/internal/compiler/generator/rust.rs +++ b/internal/compiler/generator/rust.rs @@ -3294,7 +3294,7 @@ fn compile_expression(expr: &Expression, ctx: &EvaluationContext) -> TokenStream let s = s.as_str(); quote!(sp::SharedString::from(#s)) } - Expression::KeysLiteral(..) => compile_keys_literal(expr), + Expression::KeysLiteral(keys) => compile_keys_literal(&keys), Expression::NumberLiteral(n) => { if n.is_nan() { quote!(f64::NAN) @@ -3476,8 +3476,7 @@ fn compile_expression(expr: &Expression, ctx: &EvaluationContext) -> TokenStream } #[inline(never)] -fn compile_keys_literal(expr: &Expression) -> TokenStream { - let Expression::KeysLiteral(keys) = expr else { unreachable!() }; +fn compile_keys_literal(keys: &crate::langtype::Keys) -> TokenStream { let key = &*keys.key; let alt = keys.modifiers.alt; let control = keys.modifiers.control; @@ -3485,20 +3484,22 @@ fn compile_keys_literal(expr: &Expression) -> TokenStream { let meta = keys.modifiers.meta; let ignore_shift = keys.ignore_shift; let ignore_alt = keys.ignore_alt; + let is_physical = keys.is_physical; quote!( sp::make_keys( - #key.into(), - { - let mut modifiers = sp::KeyboardModifiers::default(); - modifiers.alt = #alt; - modifiers.control = #control; - modifiers.shift = #shift; - modifiers.meta = #meta; - modifiers - }, - #ignore_shift, - #ignore_alt)) + #key.into(), + { + let mut modifiers = sp::KeyboardModifiers::default(); + modifiers.alt = #alt; + modifiers.control = #control; + modifiers.shift = #shift; + modifiers.meta = #meta; + modifiers + }, + #ignore_shift, + #ignore_alt, + #is_physical)) } #[inline(never)] diff --git a/internal/compiler/langtype.rs b/internal/compiler/langtype.rs index 6a3b4839856..bc3a3ead73e 100644 --- a/internal/compiler/langtype.rs +++ b/internal/compiler/langtype.rs @@ -1180,6 +1180,7 @@ pub struct Keys { pub modifiers: KeyboardModifiers, pub ignore_shift: bool, pub ignore_alt: bool, + pub is_physical: bool, } impl std::fmt::Display for Keys { @@ -1200,20 +1201,24 @@ impl std::fmt::Display for Keys { .then_some("Shift?+") .or(self.modifiers.shift.then_some("Shift+")) .unwrap_or_default(); - let keycode: String = self - .key - .chars() - .flat_map(|character| { - let mut escaped = vec![]; - if character.is_control() { - escaped.extend(character.escape_unicode()); - } else { - escaped.push(character); - } - escaped - }) - .collect(); - write!(f, "{meta}{ctrl}{alt}{shift}\"{keycode}\"") + if self.is_physical { + write!(f, "{meta}{ctrl}{alt}{shift}{}", self.key) + } else { + let keycode: String = self + .key + .chars() + .flat_map(|character| { + let mut escaped = vec![]; + if character.is_control() { + escaped.extend(character.escape_unicode()); + } else { + escaped.push(character); + } + escaped + }) + .collect(); + write!(f, "{meta}{ctrl}{alt}{shift}\"{keycode}\"") + } } } } diff --git a/internal/compiler/llr/pretty_print.rs b/internal/compiler/llr/pretty_print.rs index 5c826913fdc..e2c40be1b5a 100644 --- a/internal/compiler/llr/pretty_print.rs +++ b/internal/compiler/llr/pretty_print.rs @@ -480,7 +480,11 @@ impl<'a, T> Display for DisplayExpression<'a, T> { Expression::NumberLiteral(x) => write!(f, "{x:?}"), Expression::BoolLiteral(x) => write!(f, "{x:?}"), Expression::KeysLiteral(keys) => { - write!(f, "@keys({keys})",) + if keys.is_physical { + write!(f, "@physical-keys({keys})",) + } else { + write!(f, "@keys({keys})",) + } } Expression::PropertyReference(x) => write!(f, "{}", DisplayPropertyRef(x, ctx)), Expression::FunctionParameterReference { index } => write!(f, "arg_{index}"), diff --git a/internal/compiler/parser.rs b/internal/compiler/parser.rs index a096454a055..3e2e7137b9a 100644 --- a/internal/compiler/parser.rs +++ b/internal/compiler/parser.rs @@ -398,7 +398,7 @@ declare_syntax! { Expression-> [ ?Expression, ?FunctionCallExpression, ?IndexExpression, ?SelfAssignment, ?ConditionalExpression, ?QualifiedName, ?BinaryExpression, ?Array, ?ObjectLiteral, ?UnaryOpExpression, ?CodeBlock, ?StringTemplate, ?AtImageUrl, ?AtGradient, ?AtTr, - ?MemberAccess, ?AtKeys ], + ?MemberAccess, ?AtKeys, ?AtPhysicalKeys ], /// Concatenate the children Expressions and StringLiteral to make a string StringTemplate -> [*Expression], /// `@image-url("foo.png")` @@ -414,6 +414,8 @@ declare_syntax! { TrPlural -> [Expression], /// `@keys(...)` AtKeys -> [], + /// `@physical-keys(...)` + AtPhysicalKeys -> [], /// expression() FunctionCallExpression -> [*Expression], /// `expression[index]` diff --git a/internal/compiler/parser/expressions.rs b/internal/compiler/parser/expressions.rs index f414a9e9d62..c4314da5709 100644 --- a/internal/compiler/parser/expressions.rs +++ b/internal/compiler/parser/expressions.rs @@ -261,10 +261,13 @@ fn parse_at_keyword(p: &mut impl Parser) { "keys" => { parse_keys(p); } + "physical-keys" | "physical_keys" => { + parse_physical_keys(p); + } _ => { p.consume(); p.test(SyntaxKind::Identifier); // consume the identifier, so that autocomplete works - p.error("Expected 'image-url', 'tr', 'keys', 'markdown' 'conic-gradient', 'linear-gradient', or 'radial-gradient' after '@'"); + p.error("Expected 'image-url', 'tr', 'keys', 'physical-keys', 'markdown', 'conic-gradient', 'linear-gradient', or 'radial-gradient' after '@'"); } } } @@ -487,10 +490,26 @@ fn parse_markdown(p: &mut impl Parser) { /// @keys(Control +Shift + Alt?+Meta+Return) /// ``` fn parse_keys(p: &mut impl Parser) { - let mut p = p.start_node(SyntaxKind::AtKeys); + parse_key_like_macro(p, SyntaxKind::AtKeys, "keys"); +} + +#[cfg_attr(test, parser_test)] +/// ```test,AtPhysicalKeys +/// @physical-keys(A) +/// @physical-keys(Control + Shift + Digit1) +/// @physical-keys(Meta + LeftArrow) +/// @physical-keys(Alt? + BackQuote) +/// @physical-keys(Control + PageDown) +/// ``` +fn parse_physical_keys(p: &mut impl Parser) { + parse_key_like_macro(p, SyntaxKind::AtPhysicalKeys, "physical-keys"); +} + +fn parse_key_like_macro(p: &mut impl Parser, syntax_kind: SyntaxKind, macro_name: &str) { + let mut p = p.start_node(syntax_kind); p.expect(SyntaxKind::At); - debug_assert_eq!(p.peek().as_str(), "keys"); - p.expect(SyntaxKind::Identifier); //"keys" + debug_assert_eq!(p.peek().as_str(), macro_name); + p.expect(SyntaxKind::Identifier); p.expect(SyntaxKind::LParent); // Parse custom syntax here... diff --git a/internal/compiler/passes/lower_menus.rs b/internal/compiler/passes/lower_menus.rs index d71c702f0af..a89bd1766d0 100644 --- a/internal/compiler/passes/lower_menus.rs +++ b/internal/compiler/passes/lower_menus.rs @@ -663,6 +663,16 @@ fn lower_menu_items( &*binding, ); } + if in_menubar + && let Some(binding) = element.borrow().binding("shortcut") + && matches!(&binding.expression.ignore_debug_hooks(), Expression::Keys(k) if k.is_physical) + { + diag.push_error( + "@physical-keys(..) in MenuItem shortcuts are not supported.\nUse @keys(...) for menu shortcuts instead" + .into(), + &*binding, + ); + } if element.borrow().base_type.type_name() == Some("MenuSeparator") { element.borrow_mut().bindings.insert( diff --git a/internal/compiler/passes/resolving.rs b/internal/compiler/passes/resolving.rs index f4abe98e9a6..e3c9db9604a 100644 --- a/internal/compiler/passes/resolving.rs +++ b/internal/compiler/passes/resolving.rs @@ -19,9 +19,11 @@ use crate::parser::{NodeOrToken, SyntaxKind, SyntaxNode, identifier_text, syntax use crate::symbol_counters::SymbolCounters; use crate::typeregister::TypeRegister; use core::num::IntErrorKind; +use i_slint_common::for_each_physical_keys; use smol_str::{SmolStr, ToSmolStr}; use std::collections::BTreeMap; use std::rc::Rc; +use std::sync::LazyLock; use unicode_segmentation::UnicodeSegmentation; mod remove_noop; @@ -100,6 +102,9 @@ fn resolve_expression( SyntaxKind::AtKeys => { Expression::from_at_keys_node(node.clone().into(), &mut lookup_ctx) } + SyntaxKind::AtPhysicalKeys => { + Expression::from_at_physical_keys_node(node.clone().into(), &mut lookup_ctx) + } _ => { debug_assert!(diag.has_errors()); Expression::Invalid @@ -481,6 +486,11 @@ impl Expression { ctx.diag.slint_sc_error("@keys() expressions are", &node); return Self::from_at_keys_node(node.into(), ctx); } + SyntaxKind::AtPhysicalKeys => { + #[cfg(feature = "slint-sc")] + ctx.diag.slint_sc_error("@physical-keys() expressions are", &node); + return Self::from_at_physical_keys_node(node.into(), ctx); + } SyntaxKind::QualifiedName => { #[cfg(feature = "slint-sc")] ctx.diag.slint_sc_error("Identifier references are", &node); @@ -1486,6 +1496,77 @@ impl Expression { Expression::Keys(keys) } + pub fn from_at_physical_keys_node( + node: syntax_nodes::AtPhysicalKeys, + ctx: &mut LookupCtx, + ) -> Self { + let mut keys = langtype::Keys { is_physical: true, ..Default::default() }; + + let idents_and_questions: Vec<_> = node + .children_with_tokens() + .filter(|n| matches!(n.kind(), SyntaxKind::Identifier | SyntaxKind::Question)) + .skip(1) + .collect(); + + for (index, ident_or_question) in idents_and_questions.iter().enumerate() { + if ident_or_question.kind() == SyntaxKind::Question { + continue; + } + let identifier = ident_or_question; + + let is_question = || -> bool { + matches!( + idents_and_questions.get(index + 1).map(NodeOrToken::kind), + Some(SyntaxKind::Question) + ) + }; + + match identifier.as_token().unwrap().text() { + "Alt" => { + if is_question() { + keys.ignore_alt = true; + } else { + keys.modifiers.alt = true; + } + } + "Control" => keys.modifiers.control = true, + "Meta" => keys.modifiers.meta = true, + "Shift" => { + if is_question() { + keys.ignore_shift = true; + } else { + keys.modifiers.shift = true; + } + } + key_name => { + if let Some(key) = lookup_physical_key(key_name) { + keys.key = key.into(); + } else { + ctx.diag.push_error( + format!( + "{key_name} is not supported by @physical-keys\n\ + Use a physical key name such as A, Digit1, BackQuote, or LeftArrow" + ), + identifier, + ); + keys.modifiers = KeyboardModifiers::default(); + break; + } + } + } + } + + if let Some(token) = node.child_token(SyntaxKind::StringLiteral) { + ctx.diag.push_error( + "String literals are not supported in @physical-keys (use a key name such as A or LeftArrow)" + .into(), + &token, + ); + } + + Expression::Keys(keys) + } + /// Perform the lookup fn from_qualified_name_node(node: syntax_nodes::QualifiedName, ctx: &mut LookupCtx) -> Self { Self::from_lookup_result( @@ -2088,6 +2169,27 @@ impl Expression { use i_slint_common::key_codes::{ShiftBehavior, lookup_key_name}; +fn with_physical_key_map( + fun: impl FnOnce(&std::collections::HashMap<&'static str, &'static str>) -> R, +) -> R { + macro_rules! generate_physical_key_map { + [ $($name:ident # $code:ident # $_xkb:literal # $_win:literal # $($_mac:literal)?;)* ] => { + { + [$( (stringify!($name), stringify!($name)) ),*] + } + }; + } + + static PHYSICAL_KEY_MAP: LazyLock> = + LazyLock::new(|| for_each_physical_keys!(generate_physical_key_map).into_iter().collect()); + + fun(&PHYSICAL_KEY_MAP) +} + +fn lookup_physical_key(keycode: &str) -> Option<&'static str> { + with_physical_key_map(|map| map.get(keycode).copied()) +} + /// Return the type that merge two times when they are used in two branch of a condition /// /// Ideally this could just be Expression::common_target_type_for_type_list, but that function diff --git a/internal/compiler/tests/syntax/elements/menu-shortcuts.slint b/internal/compiler/tests/syntax/elements/menu-shortcuts.slint index 53bea0f9db5..8e0c58dd3f7 100644 --- a/internal/compiler/tests/syntax/elements/menu-shortcuts.slint +++ b/internal/compiler/tests/syntax/elements/menu-shortcuts.slint @@ -7,6 +7,10 @@ export component Test inherits Window { MenuItem { shortcut: @keys(Control + N); } + MenuItem { + shortcut: @physical-keys(Control + N); +// > +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export component SuperSimple { + property <[keys]> invalid_shortcuts: [ + // Right-side modifier keys + @physical-keys(AltR + A), +// > +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export component SuperSimple { + property <[keys]> unknown_physical_keys_list: [ + @physical-keys(a), +// ^error{a is not supported by @physical-keys↵Use a physical key name such as A, Digit1, BackQuote, or LeftArrow} + @physical-keys(FooBar), +// > +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export component SuperSimple { + property <[keys]> shortcuts: [ + @physical-keys(A), + @physical-keys(Control + Shift + Digit1), + @physical-keys(Meta + LeftArrow), + @physical-keys(Alt? + BackQuote), + @physical-keys(Control + PageDown), + ]; +} diff --git a/internal/core/api.rs b/internal/core/api.rs index d78eea96a68..8b4d018408b 100644 --- a/internal/core/api.rs +++ b/internal/core/api.rs @@ -736,6 +736,17 @@ impl Window { self.0.process_mouse_input(MouseEvent::Exit); WindowEventDispatchResult::Accepted } + crate::platform::WindowEvent::Key(event) => self + .0 + .process_key_input(InternalKeyEvent { + event_type: match event.event_type { + crate::platform::WindowKeyEventType::Pressed => KeyEventType::KeyPressed, + crate::platform::WindowKeyEventType::Released => KeyEventType::KeyReleased, + }, + key_event: event.event, + ..Default::default() + }) + .into(), crate::platform::WindowEvent::KeyPressed { text } => self .0 diff --git a/internal/core/input.rs b/internal/core/input.rs index 7da0ab385e9..75452f59212 100644 --- a/internal/core/input.rs +++ b/internal/core/input.rs @@ -538,9 +538,16 @@ pub fn make_keys( modifiers: KeyboardModifiers, ignore_shift: bool, ignore_alt: bool, + is_physical: bool, ) -> Keys { Keys { - inner: KeysInner { key: key.to_lowercase().into(), modifiers, ignore_shift, ignore_alt }, + inner: KeysInner { + key: if is_physical { key } else { key.to_lowercase().into() }, + modifiers, + ignore_shift, + ignore_alt, + is_physical, + }, } } @@ -560,6 +567,7 @@ pub(crate) mod ffi { meta: bool, ignore_shift: bool, ignore_alt: bool, + is_physical: bool, out: &mut Keys, ) { *out = make_keys( @@ -567,6 +575,7 @@ pub(crate) mod ffi { KeyboardModifiers { alt, control, shift, meta }, ignore_shift, ignore_alt, + is_physical, ); } @@ -696,7 +705,9 @@ fn keys_from_parts_inner<'a>( } // 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 } }); + return Ok(Keys { + inner: KeysInner { key, modifiers, ignore_shift, ignore_alt, is_physical: false }, + }); } // Fallback: treat as a string literal (like @keys("€")) @@ -713,7 +724,7 @@ fn keys_from_parts_inner<'a>( } let key = normalize_key(key_name); - Ok(Keys { inner: KeysInner { key, modifiers, ignore_shift, ignore_alt } }) + Ok(Keys { inner: KeysInner { key, modifiers, ignore_shift, ignore_alt, is_physical: false } }) } /// Internal representation of the `Keys` type. @@ -722,8 +733,6 @@ fn keys_from_parts_inner<'a>( #[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, @@ -731,6 +740,8 @@ pub struct KeysInner { pub ignore_shift: bool, /// Whether to ignore alt state when matching the shortcut pub ignore_alt: bool, + /// Whether this shortcut matches the physical key code instead of the logical key text. + pub is_physical: bool, } impl KeysInner { @@ -776,20 +787,30 @@ impl Keys { 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()); + let key_matches = if inner.is_physical { + key_event.physical_key == inner.key + } else { + // 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()) + }; - event_text.eq(inner.key.chars()) && key_event.modifiers == expected_modifiers + key_matches && key_event.modifiers == expected_modifiers } fn format_key_for_display(&self) -> crate::SharedString { let key_str = self.inner.key.as_str(); + + if self.inner.is_physical { + return key_str.into(); + } + let first_char = key_str.chars().next(); if let Some(first_char) = first_char { @@ -912,20 +933,24 @@ impl core::fmt::Debug for Keys { .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}\"") + if inner.is_physical { + write!(f, "{meta}{ctrl}{alt}{shift}{}", inner.key) + } else { + 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}\"") + } } } } @@ -2840,7 +2865,7 @@ mod tests { _expected_linux, ) in test_cases { - let shortcut = make_keys(key.into(), modifiers, ignore_shift, ignore_alt); + let shortcut = make_keys(key.into(), modifiers, ignore_shift, ignore_alt, false); use crate::alloc::string::ToString; let result = shortcut.to_string(); @@ -2959,7 +2984,7 @@ mod tests { 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}"); + assert_eq!(result, make_keys((*expected_key).into(), *mods, *is, *ia, false), "{desc}"); } } diff --git a/internal/core/platform.rs b/internal/core/platform.rs index 2db2e7d637f..9d20873c6c5 100644 --- a/internal/core/platform.rs +++ b/internal/core/platform.rs @@ -10,6 +10,7 @@ The backend is the abstraction for crates that need to do the actual drawing and use crate::SharedString; pub use crate::api::PlatformError; use crate::api::{LogicalPosition, LogicalSize}; +use crate::input::KeyEvent; pub use crate::renderer::Renderer; #[cfg(all(not(feature = "std"), feature = "unsafe-single-threaded"))] use crate::unsafe_single_threaded::OnceCell; @@ -356,6 +357,8 @@ pub enum WindowEvent { /// /// Always reported as [`Accepted`](crate::api::WindowEventDispatchResult::Accepted). PointerExited, + /// A key was pressed or released. + Key(WindowKeyEvent), /// A key was pressed. KeyPressed { /// The unicode representation of the key pressed. @@ -421,6 +424,39 @@ pub enum WindowEvent { WindowActiveChanged(bool), } +/// The kind of key event delivered through [`WindowEvent::Key`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +#[non_exhaustive] +pub enum WindowKeyEventType { + /// A key was pressed. + Pressed, + /// A key was released. + Released, +} + +/// A platform key event delivered through [`WindowEvent::Key`]. +#[derive(Clone, Debug, PartialEq)] +#[repr(C)] +#[non_exhaustive] +pub struct WindowKeyEvent { + /// Whether the key was pressed or released. + pub event_type: WindowKeyEventType, + /// The key event details. + pub event: KeyEvent, +} + +impl WindowKeyEvent { + /// Construct a new WindowKeyEvent with the given type and key event + /// + /// Note: The KeyEvent::modifiers are ignored when processing the event and are overwritten with + /// the current modifier state tracked by the window! + pub fn new(event_type: WindowKeyEventType, event: KeyEvent) -> Self { + // The WindowKeyEvent struct needs a constructor function, as it is #[non_exhaustive]. + Self { event_type, event } + } +} + impl WindowEvent { /// The position of the cursor for this event, if any pub fn position(&self) -> Option { diff --git a/internal/interpreter/eval.rs b/internal/interpreter/eval.rs index b50a235aad2..981bce3999c 100644 --- a/internal/interpreter/eval.rs +++ b/internal/interpreter/eval.rs @@ -530,6 +530,7 @@ pub fn eval_expression(expression: &Expression, local_context: &mut EvalLocalCon modifiers, ks.ignore_shift, ks.ignore_alt, + ks.is_physical, )) } Expression::ReturnStatement(x) => { diff --git a/tests/cases/input/physical_keys.slint b/tests/cases/input/physical_keys.slint new file mode 100644 index 00000000000..befadfd6caa --- /dev/null +++ b/tests/cases/input/physical_keys.slint @@ -0,0 +1,55 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export component TestCase inherits Window { + in-out property matched: ""; + forward-focus: scope; + + init => { + scope.focus(); + } + + scope := FocusScope { + KeyBinding { + keys: @physical-keys(Control + A); + activated => { + matched = "physical-a"; + } + } + + KeyBinding { + keys: @keys(Control + A); + activated => { + matched = "logical-a"; + } + } + } +} + +/* +```rust +let instance = TestCase::new().unwrap(); +use i_slint_core::SharedString; +use i_slint_core::input::key_codes::Key; + +slint_testing::send_key_combo_with_text_and_physical_keys( + &instance, + &[(Key::Control.into(), SharedString::default()), ("z".into(), "A".into())], +); +assert_eq!(instance.get_matched(), "physical-a"); +instance.set_matched("".into()); + +slint_testing::send_key_combo_with_text_and_physical_keys( + &instance, + &[(Key::Control.into(), SharedString::default()), ("a".into(), "Q".into())], +); +assert_eq!(instance.get_matched(), "logical-a"); +instance.set_matched("".into()); + +slint_testing::send_key_combo_with_text_and_physical_keys( + &instance, + &[(Key::Control.into(), SharedString::default()), ("a".into(), SharedString::default())], +); +assert_eq!(instance.get_matched(), "logical-a"); +``` +*/ diff --git a/tools/lsp/language/completion.rs b/tools/lsp/language/completion.rs index a7548a9ceb8..d4e61451171 100644 --- a/tools/lsp/language/completion.rs +++ b/tools/lsp/language/completion.rs @@ -286,8 +286,10 @@ pub(crate) fn completion_at( return with_lookup_ctx(document_cache, node, Some(offset), |ctx| { resolve_expression_scope(ctx, document_cache, snippet_support) })?; - } else if node.kind() == SyntaxKind::AtKeys { - return with_lookup_ctx(document_cache, node, Some(offset), at_keys_completions); + } else if matches!(node.kind(), SyntaxKind::AtKeys | SyntaxKind::AtPhysicalKeys) { + return with_lookup_ctx(document_cache, node.clone(), Some(offset), |ctx| { + at_key_like_completions(ctx, node.kind() == SyntaxKind::AtPhysicalKeys) + }); } else if let Some(q) = syntax_nodes::QualifiedName::new(node.clone()) { match q.parent()?.kind() { SyntaxKind::Element => { @@ -1447,6 +1449,7 @@ fn macro_completions(token: SyntaxToken, snippet_support: bool) -> Option Option Vec { - let keys = i_slint_compiler::lookup::KeysLookup; - +fn at_key_like_completions(ctx: &mut LookupCtx, physical: bool) -> Vec { let mut completions = Vec::new(); - keys.for_each_entry(ctx, &mut |label, _expr| -> Option<()> { + let mut push_key = |label: &str| { completions.push( CompletionItem::new_simple(label.to_string(), "".into()) .with_kind(CompletionItemKind::ENUM_MEMBER), ); - None - }); + }; + if physical { + macro_rules! add_physical_key_completions { + ($($name:ident # $code:ident # $_xkb:literal # $_win:literal # $($_mac:literal)?;)*) => { + $(push_key(stringify!($name));)* + }; + } + i_slint_common::for_each_physical_keys!(add_physical_key_completions); + for modifier in ["Control", "Alt", "Shift", "Meta"] { + push_key(modifier); + } + } else { + let keys = i_slint_compiler::lookup::KeysLookup; + keys.for_each_entry(ctx, &mut |label, _expr| -> Option<()> { + push_key(label); + None + }); + } for modifier in ["Shift", "Alt"] { completions.push( CompletionItem::new_simple( @@ -2260,6 +2277,31 @@ mod tests { assert_completions_found(expected, &res); } + #[test] + fn physical_key_completion() { + let source = r#" + component Foo { + property shortcut: @physical-keys(🔺); + } + "#; + let res = get_completions(source).unwrap(); + for label in [ + "A", + "Digit1", + "BackQuote", + "LeftArrow", + "Shift?", + "Alt?", + "Control", + "Alt", + "Shift", + "Meta", + ] { + res.iter().find(|ci| ci.label == label).unwrap(); + } + assert!(!res.iter().any(|ci| ci.label == "Plus")); + } + #[test] fn changed_completion() { let source1 = "{ property xyz; changed 🔺 => {} } ";