From c2d07344d989eb6aac572aa581f5dc54e9fcdf5d Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Mon, 9 Sep 2024 15:05:02 -0700 Subject: [PATCH] Keyboard shortcut handling --- CHANGELOG.md | 3 + src/widget.rs | 82 ++++++++++++++ src/widgets.rs | 1 + src/widgets/shortcuts.rs | 224 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 src/widgets/shortcuts.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7b6d498..4bde5465a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `#[cushy::main]` is a new attribute proc-macro that simplifies initializing and running multi-window applications. - `Window::on_open` executes a callback when the window is initially opened. +- `Shortcuts` is a new widget that simplifies attaching logic to keyboard + shortcuts. Any widget can be wrapped with keyboard shortcut handling by using + `MakeWidget::with_shortcut`/`MakeWidget::with_repeating_shortcut`. [139]: https://github.com/khonsulabs/cushy/issues/139 diff --git a/src/widget.rs b/src/widget.rs index f559c7b90..73aa0fdd0 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -13,6 +13,7 @@ use figures::units::{Px, UPx}; use figures::{IntoSigned, IntoUnsigned, Point, Rect, Size, Zero}; use intentional::Assert; use kludgine::app::winit::event::{Ime, MouseButton, MouseScrollDelta, TouchPhase}; +use kludgine::app::winit::keyboard::ModifiersState; use kludgine::app::winit::window::CursorIcon; use kludgine::Color; use parking_lot::{Mutex, MutexGuard}; @@ -40,6 +41,7 @@ use crate::value::{Dynamic, Generation, IntoDynamic, IntoValue, Validation, Valu use crate::widgets::checkbox::{Checkable, CheckboxState}; use crate::widgets::layers::{OverlayLayer, Tooltipped}; use crate::widgets::list::List; +use crate::widgets::shortcuts::{ShortcutKey, Shortcuts}; use crate::widgets::{ Align, Button, Checkbox, Collapse, Container, Disclose, Expand, Layers, Resize, Scroll, Space, Stack, Style, Themed, ThemedMode, Validated, Wrap, @@ -954,6 +956,43 @@ pub trait MakeWidget: Sized { Style::new(Styles::new().with_dynamic(name, dynamic), self) } + /// Invokes `callback` when `key` is pressed while `modifiers` are pressed. + /// + /// This shortcut will only be invoked if focus is within `self` or a child + /// of `self`, or if the returned widget becomes the root widget of a + /// window. + #[must_use] + fn with_shortcut( + self, + key: impl Into, + modifiers: ModifiersState, + callback: F, + ) -> Shortcuts + where + F: FnMut(KeyEvent) -> EventHandling + Send + 'static, + { + Shortcuts::new(self).with_shortcut(key, modifiers, callback) + } + + /// Invokes `callback` when `key` is pressed while `modifiers` are pressed. + /// If the shortcut is held, the callback will be invoked on repeat events. + /// + /// This shortcut will only be invoked if focus is within `self` or a child + /// of `self`, or if the returned widget becomes the root widget of a + /// window. + #[must_use] + fn with_repeating_shortcut( + self, + key: impl Into, + modifiers: ModifiersState, + callback: F, + ) -> Shortcuts + where + F: FnMut(KeyEvent) -> EventHandling + Send + 'static, + { + Shortcuts::new(self).with_repeating_shortcut(key, modifiers, callback) + } + /// Styles `self` with the largest of 6 heading styles. fn h1(self) -> Style { self.xxxx_large() @@ -1677,6 +1716,49 @@ where } } +/// A [`Callback`] that can be cloned. +/// +/// Only one thread can be invoking a shared callback at any given time. +pub struct SharedCallback(Arc>>); + +impl SharedCallback { + /// Returns a new instance that calls `function` each time the callback is + /// invoked. + pub fn new(function: F) -> Self + where + F: FnMut(T) -> R + Send + 'static, + { + Self(Arc::new(Mutex::new(Callback::new(function)))) + } + + /// Invokes the wrapped function and returns the produced value. + pub fn invoke(&self, value: T) -> R { + self.0.lock().invoke(value) + } +} + +impl Debug for SharedCallback { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("SharedCallback") + .field(&Arc::as_ptr(&self.0)) + .finish() + } +} + +impl Eq for SharedCallback {} + +impl PartialEq for SharedCallback { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Clone for SharedCallback { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + /// A function that can be invoked once with a parameter (`T`) and returns `R`. /// /// This type is used by widgets to signal an event that can happen only onceq. diff --git a/src/widgets.rs b/src/widgets.rs index 8c17522b8..91f77e047 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -26,6 +26,7 @@ pub mod radio; mod resize; pub mod scroll; pub mod select; +pub mod shortcuts; pub mod slider; mod space; pub mod stack; diff --git a/src/widgets/shortcuts.rs b/src/widgets/shortcuts.rs new file mode 100644 index 000000000..b431f8fa4 --- /dev/null +++ b/src/widgets/shortcuts.rs @@ -0,0 +1,224 @@ +//! A keyboard shortcut handling widget. + +use ahash::AHashMap; +use kludgine::app::winit::keyboard::{ + Key, KeyCode, ModifiersState, NamedKey, NativeKey, NativeKeyCode, PhysicalKey, SmolStr, +}; + +use crate::widget::{ + EventHandling, MakeWidget, SharedCallback, WidgetRef, WrapperWidget, HANDLED, IGNORED, +}; +use crate::window::KeyEvent; + +/// A widget that handles keyboard shortcuts. +#[derive(Debug)] +pub struct Shortcuts { + shortcuts: AHashMap, + child: WidgetRef, +} + +impl Shortcuts { + /// Wraps `child` with keyboard shortcut handling. + #[must_use] + pub fn new(child: impl MakeWidget) -> Self { + Self { + shortcuts: AHashMap::new(), + child: WidgetRef::new(child), + } + } + + /// Invokes `callback` when `key` is pressed while `modifiers` are pressed. + /// + /// This shortcut will only be invoked if focus is within a child of this + /// widget, or if this widget becomes the root widget of a window. + #[must_use] + pub fn with_shortcut( + mut self, + key: impl Into, + modifiers: ModifiersState, + callback: F, + ) -> Self + where + F: FnMut(KeyEvent) -> EventHandling + Send + 'static, + { + self.insert_shortcut(key.into(), modifiers, false, SharedCallback::new(callback)); + self + } + + /// Invokes `callback` when `key` is pressed while `modifiers` are pressed. + /// If the shortcut is held, the callback will be invoked on repeat events. + /// + /// This shortcut will only be invoked if focus is within a child of this + /// widget, or if this widget becomes the root widget of a window. + #[must_use] + pub fn with_repeating_shortcut( + mut self, + key: impl Into, + modifiers: ModifiersState, + callback: F, + ) -> Self + where + F: FnMut(KeyEvent) -> EventHandling + Send + 'static, + { + self.insert_shortcut(key.into(), modifiers, true, SharedCallback::new(callback)); + self + } + + fn insert_shortcut( + &mut self, + key: ShortcutKey, + modifiers: ModifiersState, + repeat: bool, + callback: SharedCallback, + ) { + let (first, second) = Shortcut { key, modifiers }.into_variations(); + let config = ShortcutConfig { repeat, callback }; + + if let Some(second) = second { + self.shortcuts.insert(second, config.clone()); + } + + self.shortcuts.insert(first, config); + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +struct Shortcut { + pub key: ShortcutKey, + pub modifiers: ModifiersState, +} + +impl Shortcut { + fn into_variations(self) -> (Shortcut, Option) { + let modifiers = self.modifiers; + let extra = match &self.key { + ShortcutKey::Logical(Key::Character(c)) => { + let lowercase = SmolStr::new(c.to_lowercase()); + let uppercase = SmolStr::new(c.to_uppercase()); + if c == &lowercase { + Some(Shortcut { + key: uppercase.into(), + modifiers, + }) + } else { + Some(Shortcut { + key: lowercase.into(), + modifiers, + }) + } + } + _ => None, + }; + (self, extra) + } +} + +impl From for ShortcutKey { + fn from(key: PhysicalKey) -> Self { + ShortcutKey::Physical(key) + } +} + +impl From for ShortcutKey { + fn from(key: Key) -> Self { + ShortcutKey::Logical(key) + } +} + +impl From for ShortcutKey { + fn from(key: NamedKey) -> Self { + Self::from(Key::from(key)) + } +} + +impl From for ShortcutKey { + fn from(key: NativeKey) -> Self { + Self::from(Key::from(key)) + } +} + +impl From for ShortcutKey { + fn from(key: SmolStr) -> Self { + Self::from(Key::Character(key)) + } +} + +impl From<&'_ str> for ShortcutKey { + fn from(key: &'_ str) -> Self { + Self::from(SmolStr::new(key)) + } +} + +impl From for ShortcutKey { + fn from(key: KeyCode) -> Self { + Self::from(PhysicalKey::from(key)) + } +} + +impl From for ShortcutKey { + fn from(key: NativeKeyCode) -> Self { + Self::from(PhysicalKey::from(key)) + } +} + +#[derive(Debug, Clone)] +struct ShortcutConfig { + repeat: bool, + callback: SharedCallback, +} + +/// A key used in a [`Shortcuts`] widget. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub enum ShortcutKey { + /// A logical key. + /// + /// Logical keys are mapped using the operating system configuration. + Logical(Key), + + /// A physical key. + /// + /// Physical keys represent a physical keyboard location and may be + /// different logical keys depending on operating system configurations. + Physical(PhysicalKey), +} + +impl WrapperWidget for Shortcuts { + fn child_mut(&mut self) -> &mut crate::widget::WidgetRef { + &mut self.child + } + + fn keyboard_input( + &mut self, + _device_id: crate::window::DeviceId, + input: KeyEvent, + _is_synthetic: bool, + _context: &mut crate::context::EventContext<'_>, + ) -> EventHandling { + let physical_match = self.shortcuts.get(&Shortcut { + key: ShortcutKey::Physical(input.physical_key), + modifiers: input.modifiers.state(), + }); + let logical_match = self.shortcuts.get(&Shortcut { + key: ShortcutKey::Logical(input.logical_key.clone()), + modifiers: input.modifiers.state(), + }); + match (physical_match, logical_match) { + (Some(physical), Some(logical)) if physical.callback != logical.callback => { + if input.state.is_pressed() && (!input.repeat || physical.repeat) { + physical.callback.invoke(input.clone()); + } + if input.state.is_pressed() && (!input.repeat || logical.repeat) { + logical.callback.invoke(input); + } + HANDLED + } + (Some(callback), _) | (_, Some(callback)) => { + if input.state.is_pressed() && (!input.repeat || callback.repeat) { + callback.callback.invoke(input); + } + HANDLED + } + _ => IGNORED, + } + } +}