Skip to main content

iced_cube/
keys.rs

1//! Keyboard shortcuts that apps can rebind.
2//!
3//! iced has no focus model for most widgets, so shortcuts are app-wide: the
4//! app subscribes with [`subscription`], stores each [`Event`] it receives,
5//! and resolves it through a component's [`Keymap`] into that component's
6//! own action. Every interactive component has an `Action` enum and a
7//! `default_keymap()`, and an app can [`bind`](Keymap::bind),
8//! [`unbind`](Keymap::unbind) or [`clear`](Keymap::clear) the defaults.
9//!
10//! ```no_run
11//! # #[cfg(feature = "tabs")]
12//! # mod example {
13//! use iced::Subscription;
14//! use iced::keyboard::key::Named;
15//! use iced_cube::keys::{self, Chord, Keymap};
16//! use iced_cube::navigation::tabs::{self, Action};
17//!
18//! #[derive(Debug, Clone)]
19//! enum Message {
20//!     Key(keys::Event),
21//!     Tabs(tabs::Event<u8>),
22//! }
23//!
24//! struct App {
25//!     tabs: tabs::State<u8>,
26//!     keymap: Keymap<Action>,
27//! }
28//!
29//! impl App {
30//!     fn new() -> Self {
31//!         let keymap = tabs::default_keymap()
32//!             .unbind(&Chord::named(Named::ArrowLeft))
33//!             .unbind(&Chord::named(Named::ArrowRight));
34//!         Self { tabs: tabs::State::new([tabs::tab(1, "One"), tabs::tab(2, "Two")]), keymap }
35//!     }
36//!
37//!     fn update(&mut self, message: Message) {
38//!         let event = match message {
39//!             Message::Key(key) => self
40//!                 .keymap
41//!                 .resolve_event(&key)
42//!                 .and_then(|action| action.event(&self.tabs)),
43//!             Message::Tabs(event) => Some(event),
44//!         };
45//!         if let Some(event) = event {
46//!             let _ = self.tabs.update(event);
47//!         }
48//!     }
49//!
50//!     fn subscription(&self) -> Subscription<Message> {
51//!         keys::subscription().map(Message::Key)
52//!     }
53//! }
54//! # }
55//! ```
56
57use std::fmt;
58use std::str::FromStr;
59
60use iced::Subscription;
61use iced::keyboard::{self, Key, Modifiers, key::Named};
62
63/// A key press delivered by [`subscription`].
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Event {
66    pub key: Key,
67    pub modifiers: Modifiers,
68}
69
70/// Key presses that no widget captured, such as a focused text input.
71pub fn subscription() -> Subscription<Event> {
72    keyboard::listen().filter_map(|event| match event {
73        keyboard::Event::KeyPressed { key, modifiers, .. } => Some(Event { key, modifiers }),
74        _ => None,
75    })
76}
77
78/// Named keys a [`Chord`] understands: the canonical name first, then aliases.
79const NAMED: &[(Named, &[&str])] = &[
80    (Named::Enter, &["Enter", "Return"]),
81    (Named::Tab, &["Tab"]),
82    (Named::Space, &["Space", "Spacebar"]),
83    (Named::Escape, &["Escape", "Esc"]),
84    (Named::Backspace, &["Backspace"]),
85    (Named::Delete, &["Delete", "Del"]),
86    (Named::Insert, &["Insert", "Ins"]),
87    (Named::ArrowUp, &["ArrowUp", "Up"]),
88    (Named::ArrowDown, &["ArrowDown", "Down"]),
89    (Named::ArrowLeft, &["ArrowLeft", "Left"]),
90    (Named::ArrowRight, &["ArrowRight", "Right"]),
91    (Named::Home, &["Home"]),
92    (Named::End, &["End"]),
93    (Named::PageUp, &["PageUp", "PgUp"]),
94    (Named::PageDown, &["PageDown", "PgDn"]),
95    (Named::ContextMenu, &["Menu", "ContextMenu", "Apps"]),
96    (Named::F1, &["F1"]),
97    (Named::F2, &["F2"]),
98    (Named::F3, &["F3"]),
99    (Named::F4, &["F4"]),
100    (Named::F5, &["F5"]),
101    (Named::F6, &["F6"]),
102    (Named::F7, &["F7"]),
103    (Named::F8, &["F8"]),
104    (Named::F9, &["F9"]),
105    (Named::F10, &["F10"]),
106    (Named::F11, &["F11"]),
107    (Named::F12, &["F12"]),
108];
109
110/// Display name of the logo key on this platform.
111const LOGO: &str = if cfg!(target_os = "macos") {
112    "Cmd"
113} else {
114    "Super"
115};
116
117/// A key plus the modifiers held with it, such as `Ctrl+Shift+Tab`.
118///
119/// Parse one from a string: modifiers and the key are joined by `+`, and
120/// case does not matter. Modifiers are `Ctrl`, `Shift`, `Alt` (or
121/// `Option`), `Cmd` (or `Super`, `Meta`) and `Mod`, which is Cmd on macOS
122/// and Ctrl elsewhere. Keys are a single character (`K`, `/`, `+`) or a
123/// name: `Enter`, `Tab`, `Space`, `Escape`, `Backspace`, `Delete`,
124/// `Insert`, `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Home`,
125/// `End`, `PageUp`, `PageDown`, `Menu` (the context menu key) and `F1` to
126/// `F12`.
127///
128/// ```
129/// use iced_cube::keys::Chord;
130///
131/// let chord: Chord = "ctrl+shift+tab".parse().unwrap();
132/// assert_eq!(chord.to_string(), "Ctrl+Shift+Tab");
133/// ```
134#[derive(Debug, Clone, PartialEq, Eq, Hash)]
135pub struct Chord {
136    key: Key,
137    modifiers: Modifiers,
138}
139
140impl Chord {
141    /// A chord for `key`. Characters are stored in lower case.
142    pub fn new(key: Key, modifiers: Modifiers) -> Self {
143        Self {
144            key: normalise(key),
145            modifiers,
146        }
147    }
148
149    /// A named key with no modifiers.
150    pub fn named(key: Named) -> Self {
151        Self::new(Key::Named(key), Modifiers::empty())
152    }
153
154    /// A character key with no modifiers.
155    pub fn character(key: char) -> Self {
156        Self::new(Key::Character(key.to_string().into()), Modifiers::empty())
157    }
158
159    pub fn ctrl(self) -> Self {
160        self.with(Modifiers::CTRL)
161    }
162
163    pub fn shift(self) -> Self {
164        self.with(Modifiers::SHIFT)
165    }
166
167    pub fn alt(self) -> Self {
168        self.with(Modifiers::ALT)
169    }
170
171    /// Adds the logo key: Cmd on macOS, Super or the Windows key elsewhere.
172    pub fn logo(self) -> Self {
173        self.with(Modifiers::LOGO)
174    }
175
176    /// Adds Cmd on macOS and Ctrl elsewhere.
177    pub fn command(self) -> Self {
178        self.with(Modifiers::COMMAND)
179    }
180
181    fn with(mut self, modifiers: Modifiers) -> Self {
182        self.modifiers |= modifiers;
183        self
184    }
185
186    pub fn key(&self) -> &Key {
187        &self.key
188    }
189
190    pub fn modifiers(&self) -> Modifiers {
191        self.modifiers
192    }
193
194    /// Whether a key press with exactly these modifiers triggers the chord.
195    pub fn matches(&self, key: &Key, modifiers: Modifiers) -> bool {
196        self.modifiers == modifiers && same_key(&self.key, key)
197    }
198}
199
200fn normalise(key: Key) -> Key {
201    match key {
202        Key::Character(text) => Key::Character(text.to_lowercase().into()),
203        key => key,
204    }
205}
206
207fn same_key(chord: &Key, pressed: &Key) -> bool {
208    match (chord, pressed) {
209        (Key::Character(a), Key::Character(b)) => a.as_str() == b.to_lowercase(),
210        (a, b) => a == b,
211    }
212}
213
214/// Why a string is not a valid [`Chord`].
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum ParseChordError {
217    Empty,
218    /// A `+` with nothing on one side, such as `Ctrl+`.
219    MissingKey,
220    UnknownModifier(String),
221    UnknownKey(String),
222}
223
224impl fmt::Display for ParseChordError {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        match self {
227            Self::Empty => write!(f, "empty key chord"),
228            Self::MissingKey => write!(f, "key chord has an empty part"),
229            Self::UnknownModifier(name) => write!(f, "unknown modifier \"{name}\""),
230            Self::UnknownKey(name) => write!(f, "unknown key \"{name}\""),
231        }
232    }
233}
234
235impl std::error::Error for ParseChordError {}
236
237impl FromStr for Chord {
238    type Err = ParseChordError;
239
240    fn from_str(value: &str) -> Result<Self, Self::Err> {
241        let value = value.trim();
242        if value.is_empty() {
243            return Err(ParseChordError::Empty);
244        }
245
246        // A trailing "++" means the key itself is "+".
247        let (modifiers, key) = match value.strip_suffix("++") {
248            Some(rest) => (Some(rest), "+"),
249            None if value == "+" => (None, "+"),
250            None => match value.rsplit_once('+') {
251                Some((rest, key)) => (Some(rest), key),
252                None => (None, value),
253            },
254        };
255
256        let mut chord = Chord::new(parse_key(key.trim())?, Modifiers::empty());
257        let Some(modifiers) = modifiers else {
258            return Ok(chord);
259        };
260        for name in modifiers.split('+') {
261            chord = chord.with(parse_modifier(name.trim())?);
262        }
263        Ok(chord)
264    }
265}
266
267impl TryFrom<&str> for Chord {
268    type Error = ParseChordError;
269
270    fn try_from(value: &str) -> Result<Self, Self::Error> {
271        value.parse()
272    }
273}
274
275fn parse_key(name: &str) -> Result<Key, ParseChordError> {
276    if name.is_empty() {
277        return Err(ParseChordError::MissingKey);
278    }
279    if let Some((named, _)) = NAMED
280        .iter()
281        .find(|(_, names)| names.iter().any(|alias| alias.eq_ignore_ascii_case(name)))
282    {
283        return Ok(Key::Named(*named));
284    }
285    let mut chars = name.chars();
286    match (chars.next(), chars.next()) {
287        (Some(_), None) => Ok(Key::Character(name.into())),
288        _ => Err(ParseChordError::UnknownKey(name.to_owned())),
289    }
290}
291
292fn parse_modifier(name: &str) -> Result<Modifiers, ParseChordError> {
293    if name.is_empty() {
294        return Err(ParseChordError::MissingKey);
295    }
296    let modifiers = match name.to_ascii_lowercase().as_str() {
297        "ctrl" | "control" => Modifiers::CTRL,
298        "shift" => Modifiers::SHIFT,
299        "alt" | "option" | "opt" => Modifiers::ALT,
300        "cmd" | "command" | "super" | "meta" | "logo" | "win" => Modifiers::LOGO,
301        "mod" => Modifiers::COMMAND,
302        _ => return Err(ParseChordError::UnknownModifier(name.to_owned())),
303    };
304    Ok(modifiers)
305}
306
307impl fmt::Display for Chord {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        for (modifier, name) in [
310            (Modifiers::CTRL, "Ctrl"),
311            (Modifiers::ALT, "Alt"),
312            (Modifiers::SHIFT, "Shift"),
313            (Modifiers::LOGO, LOGO),
314        ] {
315            if self.modifiers.contains(modifier) {
316                write!(f, "{name}+")?;
317            }
318        }
319        match &self.key {
320            Key::Named(named) => match NAMED.iter().find(|(key, _)| key == named) {
321                Some((_, names)) => write!(f, "{}", names.first().copied().unwrap_or_default()),
322                None => write!(f, "{named:?}"),
323            },
324            Key::Character(text) => write!(f, "{}", text.to_uppercase()),
325            Key::Unidentified => write!(f, "Unidentified"),
326        }
327    }
328}
329
330/// A component action that can be bound to keys.
331pub trait Action: Copy + PartialEq + fmt::Debug + 'static {
332    /// Every action, in the order they are documented.
333    const ALL: &'static [Self];
334
335    /// The component's default shortcuts.
336    fn defaults() -> Keymap<Self>;
337
338    /// A short name, such as `"Next"`.
339    fn name(self) -> &'static str;
340
341    /// One sentence describing what the action does.
342    fn description(self) -> &'static str;
343}
344
345/// Chords bound to actions. Each chord triggers at most one action; an
346/// action may have several chords.
347#[derive(Debug, Clone, PartialEq)]
348pub struct Keymap<A> {
349    bindings: Vec<(Chord, A)>,
350}
351
352impl<A> Default for Keymap<A> {
353    fn default() -> Self {
354        Self {
355            bindings: Vec::new(),
356        }
357    }
358}
359
360impl<A> Keymap<A> {
361    /// An empty keymap.
362    pub fn new() -> Self {
363        Self::default()
364    }
365
366    /// Binds `chord` to `action`, replacing whatever the chord did before.
367    pub fn bind(mut self, chord: Chord, action: A) -> Self {
368        match self.bindings.iter_mut().find(|(bound, _)| *bound == chord) {
369            Some(binding) => binding.1 = action,
370            None => self.bindings.push((chord, action)),
371        }
372        self
373    }
374
375    /// Removes the binding for `chord`, if there is one.
376    pub fn unbind(mut self, chord: &Chord) -> Self {
377        self.bindings.retain(|(bound, _)| bound != chord);
378        self
379    }
380
381    /// Removes every binding.
382    pub fn clear(mut self) -> Self {
383        self.bindings.clear();
384        self
385    }
386
387    /// Every binding, in the order it was added.
388    pub fn bindings(&self) -> impl Iterator<Item = (&Chord, &A)> {
389        self.bindings.iter().map(|(chord, action)| (chord, action))
390    }
391
392    pub fn len(&self) -> usize {
393        self.bindings.len()
394    }
395
396    pub fn is_empty(&self) -> bool {
397        self.bindings.is_empty()
398    }
399}
400
401impl<A: Clone> Keymap<A> {
402    /// The action bound to a key press, if any.
403    pub fn resolve(&self, key: &Key, modifiers: Modifiers) -> Option<A> {
404        self.bindings
405            .iter()
406            .find(|(chord, _)| chord.matches(key, modifiers))
407            .map(|(_, action)| action.clone())
408    }
409
410    /// The action bound to a key press from [`subscription`], if any.
411    pub fn resolve_event(&self, event: &Event) -> Option<A> {
412        self.resolve(&event.key, event.modifiers)
413    }
414}
415
416impl<A: Clone + PartialEq> Keymap<A> {
417    /// The chords bound to `action`.
418    pub fn chords(&self, action: &A) -> Vec<&Chord> {
419        self.bindings
420            .iter()
421            .filter(|(_, bound)| bound == action)
422            .map(|(chord, _)| chord)
423            .collect()
424    }
425
426    /// Removes every chord bound to `action`.
427    pub fn unbind_action(mut self, action: &A) -> Self {
428        self.bindings.retain(|(_, bound)| bound != action);
429        self
430    }
431
432    /// Chords grouped by action, in the order each action first appears.
433    pub fn grouped(&self) -> Vec<(A, Vec<Chord>)> {
434        let mut groups: Vec<(A, Vec<Chord>)> = Vec::new();
435        for (chord, action) in &self.bindings {
436            match groups.iter_mut().find(|(bound, _)| bound == action) {
437                Some((_, chords)) => chords.push(chord.clone()),
438                None => groups.push((action.clone(), vec![chord.clone()])),
439            }
440        }
441        groups
442    }
443}
444
445impl<A: Action> Keymap<A> {
446    /// The default shortcuts of the component that owns `A`.
447    pub fn defaults() -> Self {
448        A::defaults()
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn chord(value: &str) -> Chord {
457        value.parse().unwrap()
458    }
459
460    fn character(value: &str) -> Key {
461        Key::Character(value.into())
462    }
463
464    #[test]
465    fn parses_named_keys_with_modifiers() {
466        let parsed = chord("Ctrl+Shift+Tab");
467        assert_eq!(parsed.key(), &Key::Named(Named::Tab));
468        assert_eq!(parsed.modifiers(), Modifiers::CTRL | Modifiers::SHIFT);
469        assert_eq!(chord("Escape"), Chord::named(Named::Escape));
470        assert_eq!(chord("ArrowRight"), Chord::named(Named::ArrowRight));
471    }
472
473    #[test]
474    fn parsing_ignores_case_and_accepts_aliases() {
475        assert_eq!(chord("ctrl+k"), chord("Ctrl+K"));
476        assert_eq!(chord("CONTROL + K"), chord("Ctrl+K"));
477        assert_eq!(chord("esc"), Chord::named(Named::Escape));
478        assert_eq!(chord("right"), Chord::named(Named::ArrowRight));
479        assert_eq!(chord("Option+PgDn"), Chord::named(Named::PageDown).alt());
480        assert_eq!(chord("Meta+S"), Chord::character('s').logo());
481        assert_eq!(chord("ContextMenu"), Chord::named(Named::ContextMenu));
482        assert_eq!(chord("Menu").to_string(), "Menu");
483    }
484
485    #[test]
486    fn mod_is_the_platform_command_key() {
487        let parsed = chord("Mod+K");
488        assert_eq!(parsed.modifiers(), Modifiers::COMMAND);
489        if cfg!(target_os = "macos") {
490            assert_eq!(parsed, chord("Cmd+K"));
491        } else {
492            assert_eq!(parsed, chord("Ctrl+K"));
493        }
494    }
495
496    #[test]
497    fn plus_can_be_the_key() {
498        assert_eq!(chord("+").key(), &character("+"));
499        let zoom = chord("Ctrl++");
500        assert_eq!(zoom.key(), &character("+"));
501        assert_eq!(zoom.modifiers(), Modifiers::CTRL);
502    }
503
504    #[test]
505    fn rejects_invalid_chords() {
506        assert_eq!("".parse::<Chord>(), Err(ParseChordError::Empty));
507        assert_eq!("  ".parse::<Chord>(), Err(ParseChordError::Empty));
508        assert_eq!("Ctrl+".parse::<Chord>(), Err(ParseChordError::MissingKey));
509        assert_eq!("Ctrl++K".parse::<Chord>(), Err(ParseChordError::MissingKey));
510        assert_eq!(
511            "Hyper+K".parse::<Chord>(),
512            Err(ParseChordError::UnknownModifier("Hyper".into()))
513        );
514        assert_eq!(
515            "Ctrl+Banana".parse::<Chord>(),
516            Err(ParseChordError::UnknownKey("Banana".into()))
517        );
518    }
519
520    #[test]
521    fn displays_in_canonical_form() {
522        assert_eq!(chord("shift+ctrl+tab").to_string(), "Ctrl+Shift+Tab");
523        assert_eq!(chord("ctrl+k").to_string(), "Ctrl+K");
524        assert_eq!(chord("esc").to_string(), "Escape");
525        assert_eq!(chord("alt+up").to_string(), "Alt+ArrowUp");
526        assert_eq!(chord("Ctrl++").to_string(), "Ctrl++");
527    }
528
529    #[test]
530    fn display_round_trips() {
531        for value in ["Ctrl+Alt+Shift+Cmd+F5", "Space", "Mod+/", "Shift+End", "+"] {
532            let parsed = chord(value);
533            assert_eq!(chord(&parsed.to_string()), parsed, "{value}");
534        }
535    }
536
537    #[test]
538    fn matches_exact_modifiers_and_any_case() {
539        let save = chord("Ctrl+S");
540        assert!(save.matches(&character("s"), Modifiers::CTRL));
541        assert!(save.matches(&character("S"), Modifiers::CTRL));
542        assert!(!save.matches(&character("s"), Modifiers::empty()));
543        assert!(!save.matches(&character("s"), Modifiers::CTRL | Modifiers::SHIFT));
544        assert!(!save.matches(&Key::Named(Named::Tab), Modifiers::CTRL));
545        assert!(chord("Tab").matches(&Key::Named(Named::Tab), Modifiers::empty()));
546    }
547
548    #[derive(Debug, Clone, Copy, PartialEq)]
549    enum Test {
550        Open,
551        Close,
552    }
553
554    fn keymap() -> Keymap<Test> {
555        Keymap::new()
556            .bind(chord("Ctrl+O"), Test::Open)
557            .bind(chord("Enter"), Test::Open)
558            .bind(chord("Escape"), Test::Close)
559    }
560
561    #[test]
562    fn resolves_bound_chords_only() {
563        let keymap = keymap();
564        assert_eq!(
565            keymap.resolve(&character("o"), Modifiers::CTRL),
566            Some(Test::Open)
567        );
568        assert_eq!(
569            keymap.resolve(&Key::Named(Named::Escape), Modifiers::empty()),
570            Some(Test::Close)
571        );
572        assert_eq!(keymap.resolve(&character("o"), Modifiers::empty()), None);
573        let event = Event {
574            key: Key::Named(Named::Enter),
575            modifiers: Modifiers::empty(),
576        };
577        assert_eq!(keymap.resolve_event(&event), Some(Test::Open));
578    }
579
580    #[test]
581    fn bind_replaces_an_existing_chord() {
582        let keymap = keymap().bind(chord("Enter"), Test::Close);
583        assert_eq!(keymap.len(), 3);
584        assert_eq!(
585            keymap.resolve(&Key::Named(Named::Enter), Modifiers::empty()),
586            Some(Test::Close)
587        );
588    }
589
590    #[test]
591    fn unbind_and_clear() {
592        let keymap = keymap().unbind(&chord("Escape"));
593        assert_eq!(
594            keymap.resolve(&Key::Named(Named::Escape), Modifiers::empty()),
595            None
596        );
597        assert_eq!(keymap.len(), 2);
598        let unchanged = keymap.clone().unbind(&chord("F1"));
599        assert_eq!(unchanged, keymap);
600        assert!(keymap.clear().is_empty());
601    }
602
603    #[test]
604    fn chords_grouped_and_unbound_by_action() {
605        let keymap = keymap();
606        assert_eq!(keymap.chords(&Test::Open).len(), 2);
607        let grouped = keymap.grouped();
608        assert_eq!(grouped.len(), 2);
609        assert_eq!(grouped[0].0, Test::Open);
610        assert_eq!(grouped[0].1, vec![chord("Ctrl+O"), chord("Enter")]);
611        assert_eq!(grouped[1], (Test::Close, vec![chord("Escape")]));
612        let closed = keymap.unbind_action(&Test::Open);
613        assert_eq!(closed.len(), 1);
614    }
615}