Skip to main content

plushie_core/
key.rs

1//! Keyboard key types with forgiving string parsing.
2//!
3//! The [`Key`] enum represents keyboard keys with typed variants for
4//! common keys and a [`Named`](Key::Named) fallback for rare/specialized
5//! keys. The [`KeyPress`] struct bundles a key with modifiers.
6//!
7//! # Normalization
8//!
9//! All string-based parsing normalizes the input by:
10//! - Removing whitespace, underscores, and hyphens
11//! - Lowercasing everything
12//!
13//! This means these are all equivalent:
14//! - `"LeftArrow"`, `"left_arrow"`, `"left-arrow"`, `"leftarrow"`
15//! - `"PageUp"`, `"page_up"`, `"Page Up"`, `"pageup"`
16//! - `"Ctrl"`, `"ctrl"`, `"CTRL"`
17//!
18//! # Key aliases
19//!
20//! Common aliases are recognized to reduce doc-checking:
21//! - Arrow keys: `"left"` / `"arrowleft"` / `"leftarrow"`
22//! - Enter: `"enter"` / `"return"`
23//! - Escape: `"esc"` / `"escape"`
24//! - Backspace: `"bs"` / `"backspace"`
25//! - Delete: `"del"` / `"delete"`
26//! - Page navigation: `"pageup"` / `"pgup"`, `"pagedown"` / `"pgdown"` / `"pgdn"`
27//!
28//! # Combo strings
29//!
30//! [`KeyPress`] parses modifier+key combos from strings:
31//! - `"Ctrl+s"`, `"Shift+Enter"`, `"Ctrl+Shift+ArrowUp"`
32//! - `"Ctrl + Left_Arrow"` (whitespace around `+` is fine)
33//!
34//! # Modifier aliases
35//!
36//! - `ctrl` / `control` - physical Ctrl key
37//! - `shift`
38//! - `alt` / `option` / `opt`
39//! - `command` / `cmd` - platform shortcut key (Ctrl on Linux/Windows, Cmd on macOS)
40//! - `logo` / `super` / `win` / `meta` - physical Logo/Super/Command key
41
42use std::{error::Error, fmt, str::FromStr};
43
44use crate::protocol::KeyModifiers;
45
46// ---------------------------------------------------------------------------
47// Key enum
48// ---------------------------------------------------------------------------
49
50/// A keyboard key.
51///
52/// Common keys have dedicated variants for compile-time safety and
53/// IDE autocomplete. Rare keys (media, TV remote, IME) use the
54/// [`Named`](Key::Named) fallback with the iced/winit PascalCase
55/// name string.
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57pub enum Key {
58    // -- Navigation --
59    /// Arrow Up.
60    ArrowUp,
61    /// Arrow Down.
62    ArrowDown,
63    /// Arrow Left.
64    ArrowLeft,
65    /// Arrow Right.
66    ArrowRight,
67    /// Home.
68    Home,
69    /// End.
70    End,
71    /// Page Up.
72    PageUp,
73    /// Page Down.
74    PageDown,
75
76    // -- Editing --
77    /// Enter.
78    Enter,
79    /// Tab.
80    Tab,
81    /// Space.
82    Space,
83    /// Backspace.
84    Backspace,
85    /// Delete.
86    Delete,
87    /// Insert.
88    Insert,
89    /// Escape.
90    Escape,
91
92    // -- Modifiers (as key events, not as modifiers on combos) --
93    /// Shift.
94    Shift,
95    /// Control.
96    Control,
97    /// Alt.
98    Alt,
99    /// Super.
100    Super,
101
102    // -- Function keys --
103    /// F1.
104    F1,
105    /// F2.
106    F2,
107    /// F3.
108    F3,
109    /// F4.
110    F4,
111    /// F5.
112    F5,
113    /// F6.
114    F6,
115    /// F7.
116    F7,
117    /// F8.
118    F8,
119    /// F9.
120    F9,
121    /// F10.
122    F10,
123    /// F11.
124    F11,
125    /// F12.
126    F12,
127
128    // -- Common extras --
129    /// Caps Lock.
130    CapsLock,
131    /// Num Lock.
132    NumLock,
133    /// Scroll Lock.
134    ScrollLock,
135    /// Print Screen.
136    PrintScreen,
137    /// Pause.
138    Pause,
139    /// Context Menu.
140    ContextMenu,
141    /// Copy.
142    Copy,
143    /// Cut.
144    Cut,
145    /// Paste.
146    Paste,
147    /// Undo.
148    Undo,
149    /// Redo.
150    Redo,
151
152    // -- Single character --
153    /// Char.
154    Char(char),
155
156    /// A named key not covered by the common variants above.
157    ///
158    /// Uses the iced/winit PascalCase name (e.g. "MediaPlay",
159    /// "BrowserBack", "LaunchMail"). Forward-compatible: new iced
160    /// key names work without updating this enum.
161    Named(String),
162}
163
164impl Key {
165    /// The canonical wire-format name for this key.
166    ///
167    /// Returns the PascalCase name that the renderer and iced use.
168    pub fn wire_name(&self) -> String {
169        match self {
170            Self::ArrowUp => "ArrowUp".into(),
171            Self::ArrowDown => "ArrowDown".into(),
172            Self::ArrowLeft => "ArrowLeft".into(),
173            Self::ArrowRight => "ArrowRight".into(),
174            Self::Home => "Home".into(),
175            Self::End => "End".into(),
176            Self::PageUp => "PageUp".into(),
177            Self::PageDown => "PageDown".into(),
178            Self::Enter => "Enter".into(),
179            Self::Tab => "Tab".into(),
180            Self::Space => "Space".into(),
181            Self::Backspace => "Backspace".into(),
182            Self::Delete => "Delete".into(),
183            Self::Insert => "Insert".into(),
184            Self::Escape => "Escape".into(),
185            Self::Shift => "Shift".into(),
186            Self::Control => "Control".into(),
187            Self::Alt => "Alt".into(),
188            Self::Super => "Super".into(),
189            Self::F1 => "F1".into(),
190            Self::F2 => "F2".into(),
191            Self::F3 => "F3".into(),
192            Self::F4 => "F4".into(),
193            Self::F5 => "F5".into(),
194            Self::F6 => "F6".into(),
195            Self::F7 => "F7".into(),
196            Self::F8 => "F8".into(),
197            Self::F9 => "F9".into(),
198            Self::F10 => "F10".into(),
199            Self::F11 => "F11".into(),
200            Self::F12 => "F12".into(),
201            Self::CapsLock => "CapsLock".into(),
202            Self::NumLock => "NumLock".into(),
203            Self::ScrollLock => "ScrollLock".into(),
204            Self::PrintScreen => "PrintScreen".into(),
205            Self::Pause => "Pause".into(),
206            Self::ContextMenu => "ContextMenu".into(),
207            Self::Copy => "Copy".into(),
208            Self::Cut => "Cut".into(),
209            Self::Paste => "Paste".into(),
210            Self::Undo => "Undo".into(),
211            Self::Redo => "Redo".into(),
212            Self::Char(c) => c.to_string(),
213            Self::Named(name) => name.clone(),
214        }
215    }
216}
217
218impl fmt::Display for Key {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        write!(f, "{}", self.wire_name())
221    }
222}
223
224/// Parse a key name from a normalized string (lowercase, no
225/// whitespace/underscores/hyphens).
226fn parse_key_normalized(s: &str) -> Key {
227    match s {
228        // Navigation
229        "arrowup" | "up" | "uparrow" => Key::ArrowUp,
230        "arrowdown" | "down" | "downarrow" => Key::ArrowDown,
231        "arrowleft" | "left" | "leftarrow" => Key::ArrowLeft,
232        "arrowright" | "right" | "rightarrow" => Key::ArrowRight,
233        "home" => Key::Home,
234        "end" => Key::End,
235        "pageup" | "pgup" => Key::PageUp,
236        "pagedown" | "pgdown" | "pgdn" => Key::PageDown,
237
238        // Editing
239        "enter" | "return" => Key::Enter,
240        "tab" => Key::Tab,
241        "space" => Key::Space,
242        "backspace" | "bs" => Key::Backspace,
243        "delete" | "del" => Key::Delete,
244        "insert" | "ins" => Key::Insert,
245        "escape" | "esc" => Key::Escape,
246
247        // Modifiers as keys
248        "shift" => Key::Shift,
249        "control" | "ctrl" => Key::Control,
250        "alt" | "option" | "opt" => Key::Alt,
251        "super" | "logo" | "meta" | "command" | "cmd" | "win" => Key::Super,
252
253        // Function keys
254        "f1" => Key::F1,
255        "f2" => Key::F2,
256        "f3" => Key::F3,
257        "f4" => Key::F4,
258        "f5" => Key::F5,
259        "f6" => Key::F6,
260        "f7" => Key::F7,
261        "f8" => Key::F8,
262        "f9" => Key::F9,
263        "f10" => Key::F10,
264        "f11" => Key::F11,
265        "f12" => Key::F12,
266
267        // Common extras
268        "capslock" | "caps" => Key::CapsLock,
269        "numlock" | "num" => Key::NumLock,
270        "scrolllock" => Key::ScrollLock,
271        "printscreen" | "prtsc" | "print" => Key::PrintScreen,
272        "pause" | "break" => Key::Pause,
273        "contextmenu" | "menu" => Key::ContextMenu,
274        "copy" => Key::Copy,
275        "cut" => Key::Cut,
276        "paste" => Key::Paste,
277        "undo" => Key::Undo,
278        "redo" => Key::Redo,
279
280        // Single character: preserve original case since 'a' and 'A'
281        // are different keys (shift state).
282        s if s.len() == 1 => Key::Char(s.chars().next().unwrap()),
283
284        // Also match single uppercase chars that went through normalize
285        // (the caller may have passed a pre-normalized string).
286        // This can't happen because normalize lowercases, but be safe.
287
288        // Fallback: preserve original PascalCase name for iced
289        // (the normalized form doesn't help here, so we accept
290        // that Named keys from strings are lowercased)
291        _ => Key::Named(s.to_string()),
292    }
293}
294
295impl From<&str> for Key {
296    fn from(s: &str) -> Self {
297        let trimmed = s.trim();
298        // Single characters preserve their case (a and A are different keys).
299        if trimmed.len() == 1 {
300            return Key::Char(trimmed.chars().next().unwrap());
301        }
302        parse_key_normalized(&normalize(trimmed))
303    }
304}
305
306impl From<String> for Key {
307    fn from(s: String) -> Self {
308        Key::from(s.as_str())
309    }
310}
311
312impl From<char> for Key {
313    fn from(c: char) -> Self {
314        Key::Char(c)
315    }
316}
317
318// ---------------------------------------------------------------------------
319// KeyPress (key + modifiers)
320// ---------------------------------------------------------------------------
321
322/// A key press event: a key combined with modifier state.
323///
324/// Parses combo strings like `"Ctrl+s"`, `"Shift + Enter"`,
325/// `"Ctrl+Shift+ArrowUp"`. Also converts from a bare [`Key`]
326/// (no modifiers) or a `(Key, KeyModifiers)` tuple.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct KeyPress {
329    /// Key.
330    pub key: Key,
331    /// Active modifier keys.
332    pub modifiers: KeyModifiers,
333}
334
335/// Error returned when a combo string contains an unknown modifier.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct ParseKeyPressError {
338    modifier: String,
339}
340
341impl ParseKeyPressError {
342    /// Unknown modifier segment from the combo string.
343    pub fn modifier(&self) -> &str {
344        &self.modifier
345    }
346}
347
348impl fmt::Display for ParseKeyPressError {
349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350        write!(f, "unknown key modifier {:?}", self.modifier)
351    }
352}
353
354impl Error for ParseKeyPressError {}
355
356impl KeyPress {
357    /// Construct a new value.
358    pub fn new(key: Key, modifiers: KeyModifiers) -> Self {
359        Self { key, modifiers }
360    }
361
362    /// Parse from the wire protocol payload.
363    ///
364    /// Accepts three formats:
365    /// - Combined: `{"combo": "Ctrl+s"}` (preferred)
366    /// - Explicit: `{"key": "s", "modifiers": {"ctrl": true}}`
367    /// - Legacy combined: `{"key": "ctrl+s"}` (key field contains combo)
368    pub fn from_wire(payload: &serde_json::Value) -> Option<Self> {
369        // Try combined format first (preferred).
370        if let Some(combo) = payload.get("combo").and_then(|v| v.as_str()) {
371            return combo.parse().ok();
372        }
373
374        let key_str = payload.get("key").and_then(|v| v.as_str())?;
375
376        // Explicit modifiers take priority.
377        if let Some(mods) = payload.get("modifiers") {
378            let get_bool = |key| mods.get(key).and_then(|v| v.as_bool()).unwrap_or(false);
379            let modifiers = KeyModifiers {
380                shift: get_bool("shift"),
381                ctrl: get_bool("ctrl") || get_bool("command"),
382                alt: get_bool("alt"),
383                logo: get_bool("logo"),
384                command: get_bool("command"),
385            };
386            return Some(Self {
387                key: Key::from(key_str),
388                modifiers,
389            });
390        }
391
392        // No explicit modifiers: parse key field as a combo string
393        // (handles "ctrl+s" in the key field).
394        key_str.parse().ok()
395    }
396}
397
398impl FromStr for KeyPress {
399    type Err = ParseKeyPressError;
400
401    fn from_str(s: &str) -> Result<Self, Self::Err> {
402        // Split on '+' preserving structure, then normalize each part.
403        let parts: Vec<&str> = s.split('+').collect();
404
405        if parts.len() == 1 {
406            // No '+': just a key name.
407            return Ok(Self {
408                key: Key::from(parts[0].trim()),
409                modifiers: KeyModifiers::default(),
410            });
411        }
412
413        let mut modifiers = KeyModifiers::default();
414        for part in &parts[..parts.len() - 1] {
415            let trimmed = part.trim();
416            let normalized = normalize(trimmed);
417            match normalized.as_str() {
418                "ctrl" | "control" => modifiers.ctrl = true,
419                "shift" => modifiers.shift = true,
420                "alt" | "option" | "opt" => modifiers.alt = true,
421                "logo" | "super" | "win" | "meta" => modifiers.logo = true,
422                // "command"/"cmd" sets the platform-aware command
423                // field. The renderer resolves this to the correct
424                // physical modifier at event time: Ctrl on Linux/
425                // Windows, Cmd (Logo) on macOS.
426                "command" | "cmd" => modifiers.command = true,
427                "" => {}
428                _ => {
429                    return Err(ParseKeyPressError {
430                        modifier: trimmed.to_string(),
431                    });
432                }
433            }
434        }
435
436        let key = Key::from(parts.last().unwrap().trim());
437        Ok(Self { key, modifiers })
438    }
439}
440
441impl From<&str> for KeyPress {
442    fn from(s: &str) -> Self {
443        s.parse().unwrap_or_else(|_| Self {
444            key: Key::from(s.trim()),
445            modifiers: KeyModifiers::default(),
446        })
447    }
448}
449
450impl From<String> for KeyPress {
451    fn from(s: String) -> Self {
452        KeyPress::from(s.as_str())
453    }
454}
455
456impl From<Key> for KeyPress {
457    fn from(key: Key) -> Self {
458        Self {
459            key,
460            modifiers: KeyModifiers::default(),
461        }
462    }
463}
464
465impl From<(Key, KeyModifiers)> for KeyPress {
466    fn from((key, modifiers): (Key, KeyModifiers)) -> Self {
467        Self { key, modifiers }
468    }
469}
470
471impl fmt::Display for KeyPress {
472    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473        let mut parts = Vec::new();
474        if self.modifiers.ctrl {
475            parts.push("Ctrl".to_string());
476        }
477        if self.modifiers.shift {
478            parts.push("Shift".to_string());
479        }
480        if self.modifiers.alt {
481            parts.push("Alt".to_string());
482        }
483        if self.modifiers.logo {
484            parts.push("Super".to_string());
485        }
486        parts.push(self.key.wire_name());
487        write!(f, "{}", parts.join("+"))
488    }
489}
490
491// ---------------------------------------------------------------------------
492// MouseButton
493// ---------------------------------------------------------------------------
494
495/// A mouse button for canvas and pointer interactions.
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
497pub enum MouseButton {
498    #[default]
499    /// Left.
500    Left,
501    /// Right.
502    Right,
503    /// Middle.
504    Middle,
505    /// Back.
506    Back,
507    /// Forward.
508    Forward,
509}
510
511impl MouseButton {
512    /// Set or construct `wire_name`.
513    pub fn wire_name(&self) -> &'static str {
514        match self {
515            Self::Left => "left",
516            Self::Right => "right",
517            Self::Middle => "middle",
518            Self::Back => "back",
519            Self::Forward => "forward",
520        }
521    }
522
523    /// Parse from a wire string. Returns `None` for unrecognized values.
524    pub fn from_wire(s: &str) -> Option<Self> {
525        match normalize(s).as_str() {
526            "left" => Some(Self::Left),
527            "right" => Some(Self::Right),
528            "middle" | "center" => Some(Self::Middle),
529            "back" => Some(Self::Back),
530            "forward" => Some(Self::Forward),
531            _ => None,
532        }
533    }
534}
535
536impl From<&str> for MouseButton {
537    fn from(s: &str) -> Self {
538        Self::from_wire(s).unwrap_or(Self::Left)
539    }
540}
541
542impl fmt::Display for MouseButton {
543    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
544        f.write_str(self.wire_name())
545    }
546}
547
548// ---------------------------------------------------------------------------
549// PointerKind
550// ---------------------------------------------------------------------------
551
552/// The type of pointing device that generated an event.
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
554pub enum PointerKind {
555    #[default]
556    /// Mouse.
557    Mouse,
558    /// Touch.
559    Touch,
560    /// Pen.
561    Pen,
562}
563
564impl PointerKind {
565    /// Set or construct `wire_name`.
566    pub fn wire_name(&self) -> &'static str {
567        match self {
568            Self::Mouse => "mouse",
569            Self::Touch => "touch",
570            Self::Pen => "pen",
571        }
572    }
573
574    /// Parse from a wire string. Returns `None` for unrecognized values.
575    pub fn from_wire(s: &str) -> Option<Self> {
576        match normalize(s).as_str() {
577            "mouse" => Some(Self::Mouse),
578            "touch" => Some(Self::Touch),
579            "pen" => Some(Self::Pen),
580            _ => None,
581        }
582    }
583}
584
585impl From<&str> for PointerKind {
586    fn from(s: &str) -> Self {
587        Self::from_wire(s).unwrap_or(Self::Mouse)
588    }
589}
590
591impl fmt::Display for PointerKind {
592    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
593        f.write_str(self.wire_name())
594    }
595}
596
597// ---------------------------------------------------------------------------
598// InteractAction
599// ---------------------------------------------------------------------------
600
601/// An automation interaction action.
602///
603/// These map to the actions the renderer's interact handler supports.
604#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
605pub enum InteractAction {
606    /// Click.
607    Click,
608    /// Type Text.
609    TypeText,
610    /// Submit.
611    Submit,
612    /// Toggle.
613    Toggle,
614    /// Select.
615    Select,
616    /// Slide.
617    Slide,
618    /// Paste.
619    Paste,
620    /// Scroll.
621    Scroll,
622    /// Sort.
623    Sort,
624    /// Pane Focus Cycle.
625    PaneFocusCycle,
626    /// Press.
627    Press,
628    /// Release.
629    Release,
630    /// Type Key.
631    TypeKey,
632    /// Move To.
633    MoveTo,
634    /// Canvas Press.
635    CanvasPress,
636    /// Canvas Release.
637    CanvasRelease,
638    /// Canvas Move.
639    CanvasMove,
640}
641
642impl InteractAction {
643    /// Set or construct `wire_name`.
644    pub fn wire_name(&self) -> &'static str {
645        match self {
646            Self::Click => "click",
647            Self::TypeText => "type_text",
648            Self::Submit => "submit",
649            Self::Toggle => "toggle",
650            Self::Select => "select",
651            Self::Slide => "slide",
652            Self::Paste => "paste",
653            Self::Scroll => "scroll",
654            Self::Sort => "sort",
655            Self::PaneFocusCycle => "pane_focus_cycle",
656            Self::Press => "press",
657            Self::Release => "release",
658            Self::TypeKey => "type_key",
659            Self::MoveTo => "move_to",
660            Self::CanvasPress => "canvas_press",
661            Self::CanvasRelease => "canvas_release",
662            Self::CanvasMove => "canvas_move",
663        }
664    }
665
666    /// Construct from a wire.
667    pub fn from_wire(s: &str) -> Option<Self> {
668        Some(match normalize(s).as_str() {
669            "click" => Self::Click,
670            "typetext" | "type" => Self::TypeText,
671            "submit" => Self::Submit,
672            "toggle" => Self::Toggle,
673            "select" => Self::Select,
674            "slide" => Self::Slide,
675            "paste" => Self::Paste,
676            "scroll" => Self::Scroll,
677            "sort" => Self::Sort,
678            "panefocuscycle" => Self::PaneFocusCycle,
679            "press" => Self::Press,
680            "release" => Self::Release,
681            "typekey" => Self::TypeKey,
682            "moveto" | "move" => Self::MoveTo,
683            "canvaspress" => Self::CanvasPress,
684            "canvasrelease" => Self::CanvasRelease,
685            "canvasmove" => Self::CanvasMove,
686            _ => return None,
687        })
688    }
689}
690
691impl fmt::Display for InteractAction {
692    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
693        f.write_str(self.wire_name())
694    }
695}
696
697// ---------------------------------------------------------------------------
698// EffectKind
699// ---------------------------------------------------------------------------
700
701/// The kind of platform effect, matching [`EffectRequest`](crate::ops::EffectRequest) variants.
702#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
703pub enum EffectKind {
704    /// File Open.
705    FileOpen,
706    /// File Open Multiple.
707    FileOpenMultiple,
708    /// File Save.
709    FileSave,
710    /// Directory Select.
711    DirectorySelect,
712    /// Directory Select Multiple.
713    DirectorySelectMultiple,
714    /// Clipboard Read.
715    ClipboardRead,
716    /// Clipboard Write.
717    ClipboardWrite,
718    /// Clipboard Read Html.
719    ClipboardReadHtml,
720    /// Clipboard Write Html.
721    ClipboardWriteHtml,
722    /// Clipboard Clear.
723    ClipboardClear,
724    /// Clipboard Read Primary.
725    ClipboardReadPrimary,
726    /// Clipboard Write Primary.
727    ClipboardWritePrimary,
728    /// Notification.
729    Notification,
730}
731
732impl EffectKind {
733    /// Set or construct `wire_name`.
734    pub fn wire_name(&self) -> &'static str {
735        match self {
736            Self::FileOpen => "file_open",
737            Self::FileOpenMultiple => "file_open_multiple",
738            Self::FileSave => "file_save",
739            Self::DirectorySelect => "directory_select",
740            Self::DirectorySelectMultiple => "directory_select_multiple",
741            Self::ClipboardRead => "clipboard_read",
742            Self::ClipboardWrite => "clipboard_write",
743            Self::ClipboardReadHtml => "clipboard_read_html",
744            Self::ClipboardWriteHtml => "clipboard_write_html",
745            Self::ClipboardClear => "clipboard_clear",
746            Self::ClipboardReadPrimary => "clipboard_read_primary",
747            Self::ClipboardWritePrimary => "clipboard_write_primary",
748            Self::Notification => "notification",
749        }
750    }
751}
752
753impl EffectKind {
754    /// Parse from a string, returning None for unrecognized kinds.
755    pub fn from_wire(s: &str) -> Option<Self> {
756        Some(match normalize(s).as_str() {
757            "fileopen" => Self::FileOpen,
758            "fileopenmultiple" => Self::FileOpenMultiple,
759            "filesave" => Self::FileSave,
760            "directoryselect" => Self::DirectorySelect,
761            "directoryselectmultiple" => Self::DirectorySelectMultiple,
762            "clipboardread" => Self::ClipboardRead,
763            "clipboardwrite" => Self::ClipboardWrite,
764            "clipboardreadhtml" => Self::ClipboardReadHtml,
765            "clipboardwritehtml" => Self::ClipboardWriteHtml,
766            "clipboardclear" => Self::ClipboardClear,
767            "clipboardreadprimary" => Self::ClipboardReadPrimary,
768            "clipboardwriteprimary" => Self::ClipboardWritePrimary,
769            "notification" => Self::Notification,
770            _ => return None,
771        })
772    }
773}
774
775impl fmt::Display for EffectKind {
776    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
777        f.write_str(self.wire_name())
778    }
779}
780
781// ---------------------------------------------------------------------------
782// Normalization
783// ---------------------------------------------------------------------------
784
785/// Normalize a string for forgiving lookup.
786///
787/// Strips whitespace, underscores, and hyphens, then lowercases.
788/// This makes `"LeftArrow"`, `"left_arrow"`, `"left-arrow"`, and
789/// `"left arrow"` all equivalent.
790pub fn normalize(input: &str) -> String {
791    input
792        .chars()
793        .filter(|c| !c.is_whitespace() && *c != '_' && *c != '-')
794        .flat_map(char::to_lowercase)
795        .collect()
796}
797
798// ---------------------------------------------------------------------------
799// Tests
800// ---------------------------------------------------------------------------
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn normalize_strips_and_lowercases() {
808        assert_eq!(normalize("LeftArrow"), "leftarrow");
809        assert_eq!(normalize("left_arrow"), "leftarrow");
810        assert_eq!(normalize("left-arrow"), "leftarrow");
811        assert_eq!(normalize("Left Arrow"), "leftarrow");
812        assert_eq!(normalize("PAGE_UP"), "pageup");
813        assert_eq!(normalize("Ctrl"), "ctrl");
814    }
815
816    #[test]
817    fn key_from_str_named_keys() {
818        assert_eq!(Key::from("Enter"), Key::Enter);
819        assert_eq!(Key::from("enter"), Key::Enter);
820        assert_eq!(Key::from("return"), Key::Enter);
821        assert_eq!(Key::from("ESCAPE"), Key::Escape);
822        assert_eq!(Key::from("esc"), Key::Escape);
823        assert_eq!(Key::from("Tab"), Key::Tab);
824        assert_eq!(Key::from("Backspace"), Key::Backspace);
825        assert_eq!(Key::from("bs"), Key::Backspace);
826        assert_eq!(Key::from("Delete"), Key::Delete);
827        assert_eq!(Key::from("del"), Key::Delete);
828        assert_eq!(Key::from("Space"), Key::Space);
829    }
830
831    #[test]
832    fn key_from_str_arrows() {
833        assert_eq!(Key::from("ArrowLeft"), Key::ArrowLeft);
834        assert_eq!(Key::from("left_arrow"), Key::ArrowLeft);
835        assert_eq!(Key::from("left"), Key::ArrowLeft);
836        assert_eq!(Key::from("Left"), Key::ArrowLeft);
837        assert_eq!(Key::from("LeftArrow"), Key::ArrowLeft);
838        assert_eq!(Key::from("ArrowUp"), Key::ArrowUp);
839        assert_eq!(Key::from("up"), Key::ArrowUp);
840    }
841
842    #[test]
843    fn key_from_str_page_nav() {
844        assert_eq!(Key::from("PageUp"), Key::PageUp);
845        assert_eq!(Key::from("page_up"), Key::PageUp);
846        assert_eq!(Key::from("pgup"), Key::PageUp);
847        assert_eq!(Key::from("PageDown"), Key::PageDown);
848        assert_eq!(Key::from("pgdn"), Key::PageDown);
849    }
850
851    #[test]
852    fn key_from_str_function_keys() {
853        assert_eq!(Key::from("F1"), Key::F1);
854        assert_eq!(Key::from("f12"), Key::F12);
855    }
856
857    #[test]
858    fn key_from_str_single_char() {
859        assert_eq!(Key::from("a"), Key::Char('a'));
860        assert_eq!(Key::from("1"), Key::Char('1'));
861    }
862
863    #[test]
864    fn key_from_str_unknown_falls_to_named() {
865        assert_eq!(Key::from("MediaPlay"), Key::Named("mediaplay".into()));
866    }
867
868    #[test]
869    fn keypress_from_str_simple() {
870        let kp = KeyPress::from("Enter");
871        assert_eq!(kp.key, Key::Enter);
872        assert_eq!(kp.modifiers, KeyModifiers::default());
873    }
874
875    #[test]
876    fn keypress_from_str_with_modifier() {
877        let kp = KeyPress::from("Ctrl+s");
878        assert_eq!(kp.key, Key::Char('s'));
879        assert!(kp.modifiers.ctrl);
880        assert!(!kp.modifiers.shift);
881    }
882
883    #[test]
884    fn keypress_from_str_multiple_modifiers() {
885        let kp = KeyPress::from("Ctrl+Shift+Enter");
886        assert_eq!(kp.key, Key::Enter);
887        assert!(kp.modifiers.ctrl);
888        assert!(kp.modifiers.shift);
889    }
890
891    #[test]
892    fn keypress_from_str_spaces_around_plus() {
893        let kp = KeyPress::from("Ctrl + Left_Arrow");
894        assert_eq!(kp.key, Key::ArrowLeft);
895        assert!(kp.modifiers.ctrl);
896    }
897
898    #[test]
899    fn keypress_from_str_modifier_aliases() {
900        // "command"/"cmd" sets the platform-aware command field.
901        // The renderer resolves it to ctrl or logo at event time.
902        let kp = KeyPress::from("Command+s");
903        assert!(kp.modifiers.command);
904        assert!(!kp.modifiers.ctrl);
905        assert!(!kp.modifiers.logo);
906
907        let kp = KeyPress::from("Option+a");
908        assert!(kp.modifiers.alt);
909
910        // "super"/"logo"/"win"/"meta" set the physical logo key
911        let kp = KeyPress::from("Win+e");
912        assert!(kp.modifiers.logo);
913
914        let kp = KeyPress::from("Super+e");
915        assert!(kp.modifiers.logo);
916
917        // "ctrl" is always the physical Ctrl key
918        let kp = KeyPress::from("Ctrl+s");
919        assert!(kp.modifiers.ctrl);
920        assert!(!kp.modifiers.command);
921    }
922
923    #[test]
924    fn keypress_from_str_malformed() {
925        // Empty string: no modifiers, falls through to Named("").
926        let kp = KeyPress::from("");
927        assert_eq!(kp.key, Key::Named(String::new()));
928        assert_eq!(kp.modifiers, KeyModifiers::default());
929
930        // Bare '+': both segments empty. No known modifiers set,
931        // key parses as Named("").
932        let kp = KeyPress::from("+");
933        assert_eq!(kp.key, Key::Named(String::new()));
934        assert_eq!(kp.modifiers, KeyModifiers::default());
935
936        // Trailing '+': modifier present but key segment empty.
937        // Modifier is applied, key falls through to Named("").
938        let kp = KeyPress::from("Ctrl+");
939        assert_eq!(kp.key, Key::Named(String::new()));
940        assert!(kp.modifiers.ctrl);
941
942        let err = "Foo+s".parse::<KeyPress>().unwrap_err();
943        assert_eq!(err.modifier(), "Foo");
944
945        // Leading '+': empty modifier segment dropped, key parses
946        // normally.
947        let kp = KeyPress::from("+s");
948        assert_eq!(kp.key, Key::Char('s'));
949        assert_eq!(kp.modifiers, KeyModifiers::default());
950    }
951
952    #[test]
953    fn keypress_from_str_unknown_modifier_is_literal_key() {
954        let kp = KeyPress::from("Crtl+s");
955        assert_eq!(kp.key, Key::Named("crtl+s".to_string()));
956        assert_eq!(kp.modifiers, KeyModifiers::default());
957    }
958
959    #[test]
960    fn keypress_from_wire_rejects_unknown_modifier_combo() {
961        let payload = serde_json::json!({"combo": "Crtl+s"});
962        assert_eq!(KeyPress::from_wire(&payload), None);
963    }
964
965    #[test]
966    fn keypress_from_wire_combo() {
967        let payload = serde_json::json!({"combo": "Shift+Enter"});
968        let kp = KeyPress::from_wire(&payload).unwrap();
969        assert_eq!(kp.key, Key::Enter);
970        assert!(kp.modifiers.shift);
971    }
972
973    #[test]
974    fn keypress_from_wire_explicit() {
975        let payload = serde_json::json!({"key": "s", "modifiers": {"ctrl": true}});
976        let kp = KeyPress::from_wire(&payload).unwrap();
977        assert_eq!(kp.key, Key::Char('s'));
978        assert!(kp.modifiers.ctrl);
979    }
980
981    #[test]
982    fn keypress_from_wire_command_alias() {
983        let payload = serde_json::json!({"key": "s", "modifiers": {"command": true}});
984        let kp = KeyPress::from_wire(&payload).unwrap();
985        assert!(kp.modifiers.ctrl);
986    }
987
988    #[test]
989    fn mouse_button_from_str() {
990        assert_eq!(MouseButton::from("left"), MouseButton::Left);
991        assert_eq!(MouseButton::from("Right"), MouseButton::Right);
992        assert_eq!(MouseButton::from("MIDDLE"), MouseButton::Middle);
993        assert_eq!(MouseButton::from("center"), MouseButton::Middle);
994        assert_eq!(MouseButton::from("unknown"), MouseButton::Left);
995    }
996
997    #[test]
998    fn interact_action_from_wire() {
999        assert_eq!(
1000            InteractAction::from_wire("click"),
1001            Some(InteractAction::Click)
1002        );
1003        assert_eq!(
1004            InteractAction::from_wire("type_text"),
1005            Some(InteractAction::TypeText)
1006        );
1007        assert_eq!(
1008            InteractAction::from_wire("canvas_press"),
1009            Some(InteractAction::CanvasPress)
1010        );
1011        assert_eq!(InteractAction::from_wire("unknown"), None);
1012    }
1013
1014    #[test]
1015    fn effect_kind_from_wire() {
1016        assert_eq!(
1017            EffectKind::from_wire("file_open"),
1018            Some(EffectKind::FileOpen)
1019        );
1020        assert_eq!(
1021            EffectKind::from_wire("clipboard_read"),
1022            Some(EffectKind::ClipboardRead)
1023        );
1024        assert_eq!(
1025            EffectKind::from_wire("FileOpen"),
1026            Some(EffectKind::FileOpen)
1027        );
1028        assert_eq!(EffectKind::from_wire("nonsense"), None);
1029    }
1030}