Skip to content

Commit

Permalink
Keyboard shortcut handling
Browse files Browse the repository at this point in the history
  • Loading branch information
ecton committed Sep 9, 2024
1 parent 448482e commit c2d0734
Show file tree
Hide file tree
Showing 4 changed files with 310 additions and 0 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions src/widget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<F>(
self,
key: impl Into<ShortcutKey>,
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<F>(
self,
key: impl Into<ShortcutKey>,
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()
Expand Down Expand Up @@ -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<T = (), R = ()>(Arc<Mutex<Callback<T, R>>>);

impl<T, R> SharedCallback<T, R> {
/// Returns a new instance that calls `function` each time the callback is
/// invoked.
pub fn new<F>(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<T, R> Debug for SharedCallback<T, R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SharedCallback")
.field(&Arc::as_ptr(&self.0))
.finish()
}
}

impl<T, R> Eq for SharedCallback<T, R> {}

impl<T, R> PartialEq for SharedCallback<T, R> {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}

impl<T, R> Clone for SharedCallback<T, R> {
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.
Expand Down
1 change: 1 addition & 0 deletions src/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
224 changes: 224 additions & 0 deletions src/widgets/shortcuts.rs
Original file line number Diff line number Diff line change
@@ -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<Shortcut, ShortcutConfig>,
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<F>(
mut self,
key: impl Into<ShortcutKey>,
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<F>(
mut self,
key: impl Into<ShortcutKey>,
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<KeyEvent, EventHandling>,
) {
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<Shortcut>) {
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<PhysicalKey> for ShortcutKey {
fn from(key: PhysicalKey) -> Self {
ShortcutKey::Physical(key)
}
}

impl From<Key> for ShortcutKey {
fn from(key: Key) -> Self {
ShortcutKey::Logical(key)
}
}

impl From<NamedKey> for ShortcutKey {
fn from(key: NamedKey) -> Self {
Self::from(Key::from(key))
}
}

impl From<NativeKey> for ShortcutKey {
fn from(key: NativeKey) -> Self {
Self::from(Key::from(key))
}
}

impl From<SmolStr> 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<KeyCode> for ShortcutKey {
fn from(key: KeyCode) -> Self {
Self::from(PhysicalKey::from(key))
}
}

impl From<NativeKeyCode> for ShortcutKey {
fn from(key: NativeKeyCode) -> Self {
Self::from(PhysicalKey::from(key))
}
}

#[derive(Debug, Clone)]
struct ShortcutConfig {
repeat: bool,
callback: SharedCallback<KeyEvent, EventHandling>,
}

/// 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,
}
}
}

0 comments on commit c2d0734

Please sign in to comment.