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