Skip to main content

modde_ui/
shortcuts.rs

1//! Keyboard shortcut definitions for modde.
2//!
3//! Shortcuts are defined as static mappings. The app's subscription
4//! system calls `handle_key_event()` to convert key events into Messages.
5
6use iced::keyboard::{Key, Modifiers, key::Named};
7
8/// A keyboard shortcut definition.
9#[derive(Debug, Clone)]
10pub struct Shortcut {
11    pub key: Key,
12    pub modifiers: Modifiers,
13    pub description: &'static str,
14    pub action: &'static str, // human-readable action name
15}
16
17/// All registered shortcuts.
18#[must_use]
19pub fn all_shortcuts() -> Vec<Shortcut> {
20    vec![
21        Shortcut {
22            key: Key::Character("d".into()),
23            modifiers: Modifiers::CTRL,
24            description: "Deploy mods",
25            action: "deploy",
26        },
27        Shortcut {
28            key: Key::Character("f".into()),
29            modifiers: Modifiers::CTRL,
30            description: "Focus filter",
31            action: "focus_filter",
32        },
33        Shortcut {
34            key: Key::Named(Named::F5),
35            modifiers: Modifiers::empty(),
36            description: "Refresh",
37            action: "refresh",
38        },
39        Shortcut {
40            key: Key::Named(Named::Delete),
41            modifiers: Modifiers::empty(),
42            description: "Remove selected mod",
43            action: "remove_selected",
44        },
45        Shortcut {
46            key: Key::Named(Named::Space),
47            modifiers: Modifiers::empty(),
48            description: "Toggle selected mod",
49            action: "toggle_selected",
50        },
51        Shortcut {
52            key: Key::Character("s".into()),
53            modifiers: Modifiers::CTRL,
54            description: "Save settings",
55            action: "save_settings",
56        },
57        Shortcut {
58            key: Key::Character("i".into()),
59            modifiers: Modifiers::CTRL,
60            description: "Open mod info",
61            action: "open_info",
62        },
63        Shortcut {
64            key: Key::Character("e".into()),
65            modifiers: Modifiers::CTRL,
66            description: "Export mod list",
67            action: "export",
68        },
69        Shortcut {
70            key: Key::Named(Named::Escape),
71            modifiers: Modifiers::empty(),
72            description: "Dismiss modal / cancel",
73            action: "dismiss_modal",
74        },
75    ]
76}
77
78/// Match a key event against registered shortcuts.
79/// Returns the action name if a shortcut matches, None otherwise.
80#[must_use]
81pub fn match_shortcut(key: &Key, modifiers: Modifiers) -> Option<&'static str> {
82    for shortcut in all_shortcuts() {
83        if keys_match(key, &shortcut.key) && modifiers == shortcut.modifiers {
84            return Some(shortcut.action);
85        }
86    }
87    None
88}
89
90fn keys_match(a: &Key, b: &Key) -> bool {
91    match (a, b) {
92        (Key::Character(a), Key::Character(b)) => a == b,
93        (Key::Named(a), Key::Named(b)) => a == b,
94        _ => false,
95    }
96}
97
98/// Format all shortcuts as a human-readable help string.
99#[must_use]
100pub fn help_text() -> String {
101    let mut lines = vec!["Keyboard Shortcuts:".to_string(), String::new()];
102    for s in all_shortcuts() {
103        let mod_str = format_modifiers(s.modifiers);
104        let key_str = format_key(&s.key);
105        let combo = if mod_str.is_empty() {
106            key_str
107        } else {
108            format!("{mod_str}+{key_str}")
109        };
110        lines.push(format!("  {:<20} {}", combo, s.description));
111    }
112    lines.join("\n")
113}
114
115fn format_modifiers(m: Modifiers) -> String {
116    let mut parts = Vec::new();
117    if m.control() {
118        parts.push("Ctrl");
119    }
120    if m.shift() {
121        parts.push("Shift");
122    }
123    if m.alt() {
124        parts.push("Alt");
125    }
126    parts.join("+")
127}
128
129fn format_key(key: &Key) -> String {
130    match key {
131        Key::Character(c) => c.to_uppercase(),
132        Key::Named(n) => format!("{n:?}"),
133        _ => "?".to_string(),
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_match_ctrl_d() {
143        let key = Key::Character("d".into());
144        let result = match_shortcut(&key, Modifiers::CTRL);
145        assert_eq!(result, Some("deploy"));
146    }
147
148    #[test]
149    fn test_match_f5() {
150        let key = Key::Named(Named::F5);
151        let result = match_shortcut(&key, Modifiers::empty());
152        assert_eq!(result, Some("refresh"));
153    }
154
155    #[test]
156    fn test_match_escape() {
157        let key = Key::Named(Named::Escape);
158        let result = match_shortcut(&key, Modifiers::empty());
159        assert_eq!(result, Some("dismiss_modal"));
160    }
161
162    #[test]
163    fn test_no_match() {
164        let key = Key::Character("z".into());
165        let result = match_shortcut(&key, Modifiers::empty());
166        assert_eq!(result, None);
167    }
168
169    #[test]
170    fn test_help_text() {
171        let text = help_text();
172        assert!(!text.is_empty());
173        assert!(text.contains("Ctrl+D"));
174        assert!(text.contains("Deploy mods"));
175        assert!(text.contains("F5"));
176        assert!(text.contains("Refresh"));
177    }
178
179    #[test]
180    fn test_all_shortcuts_unique() {
181        let shortcuts = all_shortcuts();
182        let mut seen = std::collections::HashSet::new();
183        for s in &shortcuts {
184            let key_repr = format!("{:?}+{:?}", s.modifiers, s.key);
185            assert!(
186                seen.insert(key_repr.clone()),
187                "Duplicate shortcut: {key_repr}"
188            );
189        }
190    }
191}