graphic design tool
git clone https://git.lucas.co/cce-designer.git
src/shortcut.rs (9.1K)
1 use cce_ui::widget::{Key, NamedKey};
2 use crate::ModifiersState;
3
4 #[derive(Debug, Clone, Copy, PartialEq)]
5 pub enum Action {
6 ToggleGrid,
7 ToggleCube,
8 ToggleSquareViewport,
9 ToggleConfigure,
10 ToggleSpreadsheet,
11 ToggleOrigin,
12 ToggleCameraPivot,
13 ToggleWireframe,
14 /// Flat (faceted) or smooth shading of the scene fill.
15 ToggleSmoothShading,
16 /// The three point overlays on the visible scene — markers, index
17 /// numbers, normal whiskers. Global display settings reached from the
18 /// command palette; they were per-node `meta` child preferences until
19 /// 2026-09-23, which meant a display choice had to be made one node at
20 /// a time, on a hidden child you had to dive in to find.
21 TogglePointMarkers,
22 TogglePointNumbers,
23 TogglePointNormals,
24 /// The path-traced preview, the wireframe's single-colour mode, and the
25 /// point display. All three were toggles on the root meta node's utility
26 /// subnets and reachable ONLY there; with those nodes retired they are
27 /// commands, which is what makes them reachable at all.
28 ToggleRayTracedPreview,
29 ToggleWireSingleColor,
30 ToggleRenderPoints,
31 ToggleCircularPane,
32 DetachCircularWindow,
33 Save,
34 SaveAs,
35 NextContext,
36 PrevContext,
37 PlayPause,
38 PlayPauseReverse,
39 FrameNext,
40 FramePrev,
41 FrameStart,
42 Undo,
43 Redo,
44 /// Open the command palette — a command like any other, so it is
45 /// rebindable and lists itself.
46 CommandPalette,
47 /// Open or close the in-app dialog (`src/dialog.rs`): the same commands,
48 /// plus the viewport/graph settings, without leaving the window.
49 ToggleDialog,
50 /// Snap dragged handles in the active viewer state to a world increment.
51 ToggleSnap,
52 /// Enter or leave the selected node's viewer state.
53 ToggleViewerState,
54 /// Network-pane keyboard navigation, in the plugin's vim-style families:
55 /// bare hjkl moves the grid cursor, shift extends it into a region, alt
56 /// moves the selected nodes, ctrl pans the view. The direction rides the
57 /// variant so one registry row binds one key, which is what a rebindable
58 /// scheme needs.
59 NetworkNav(i32, i32),
60 NetworkExtend(i32, i32),
61 NetworkMove(i32, i32),
62 NetworkPan(i32, i32),
63 FrameCursor,
64 FrameAll,
65 /// Arrange the current level's nodes from their wiring.
66 LayoutNodes,
67 /// Draw the network pane's plate, or let the graph overlay the scene.
68 ToggleNetworkPlate,
69 /// Clear the node selection.
70 Deselect,
71 }
72
73 #[derive(Debug, Clone)]
74 pub struct Shortcut {
75 pub ctrl: bool,
76 pub shift: bool,
77 pub alt: bool,
78 pub logo: bool,
79 pub key: Key,
80 }
81
82 impl Shortcut {
83 pub fn parse(s: &str) -> Result<Self, String> {
84 let parts: Vec<&str> = s.split('+').map(|p| p.trim()).collect();
85 let mut ctrl = false;
86 let mut shift = false;
87 let mut alt = false;
88 let mut logo = false;
89 let mut key_opt = None;
90
91 if parts.is_empty() {
92 return Err("Empty shortcut".to_string());
93 }
94
95 for (i, &part) in parts.iter().enumerate() {
96 let lower = part.to_lowercase();
97 if i < parts.len() - 1 {
98 match lower.as_str() {
99 "ctrl" | "control" => ctrl = true,
100 "shift" => shift = true,
101 "alt" => alt = true,
102 "super" | "win" | "logo" | "cmd" | "command" => logo = true,
103 _ => return Err(format!("Unknown modifier: {}", part)),
104 }
105 } else {
106 let key = match lower.as_str() {
107 "tab" => Key::Named(NamedKey::Tab),
108 "enter" | "return" => Key::Named(NamedKey::Enter),
109 "escape" | "esc" => Key::Named(NamedKey::Escape),
110 "space" => Key::Named(NamedKey::Space),
111 "backspace" => Key::Named(NamedKey::Backspace),
112 "down" | "arrowdown" => Key::Named(NamedKey::ArrowDown),
113 "up" | "arrowup" => Key::Named(NamedKey::ArrowUp),
114 "left" | "arrowleft" => Key::Named(NamedKey::ArrowLeft),
115 "right" | "arrowright" => Key::Named(NamedKey::ArrowRight),
116 "end" => Key::Named(NamedKey::End),
117 "home" => Key::Named(NamedKey::Home),
118 "pagedown" | "pgdown" => Key::Named(NamedKey::PageDown),
119 "pageup" | "pgup" => Key::Named(NamedKey::PageUp),
120 "delete" | "del" => Key::Named(NamedKey::Delete),
121 _ => Key::Character(part.to_string()),
122 };
123 key_opt = Some(key);
124 }
125 }
126
127 let key = key_opt.ok_or_else(|| "Missing key in shortcut".to_string())?;
128 Ok(Shortcut { ctrl, shift, alt, logo, key })
129 }
130
131 /// The chord as a human reads it — the inverse of [`parse`](Self::parse),
132 /// for showing beside a command's label.
133 ///
134 /// Modifier order is fixed (Ctrl, Shift, Alt, Super) rather than however
135 /// the user happened to write it, so two spellings of one chord print the
136 /// same and a palette column stays scannable.
137 pub fn describe(&self) -> String {
138 let mut out = String::new();
139 for (on, name) in
140 [(self.ctrl, "Ctrl"), (self.shift, "Shift"), (self.alt, "Alt"), (self.logo, "Super")]
141 {
142 if on {
143 out.push_str(name);
144 out.push('+');
145 }
146 }
147 match &self.key {
148 Key::Character(c) => {
149 // Single letters read as capitals — "Ctrl+S", not "Ctrl+s" —
150 // which is how every menu in the app already writes them.
151 if c.chars().count() == 1 {
152 out.extend(c.chars().flat_map(|ch| ch.to_uppercase()));
153 } else {
154 out.push_str(c);
155 }
156 }
157 Key::Named(n) => out.push_str(&format!("{n:?}")),
158 }
159 out
160 }
161
162 pub fn matches(&self, mods: &ModifiersState, key: &Key) -> bool {
163 if mods.control_key() != self.ctrl
164 || mods.shift_key() != self.shift
165 || mods.alt_key() != self.alt
166 || mods.super_key() != self.logo
167 {
168 return false;
169 }
170 same_key(key, &self.key)
171 }
172 }
173
174 /// Whether two keys are the same key.
175 ///
176 /// Character keys compare case-insensitively: with Shift held, xkb delivers
177 /// the SHIFTED character ("S"), so an exact match against the chord's stored
178 /// "s" made every Shift+letter chord unmatchable — Ctrl+Shift+Tab never
179 /// noticed because Named keys aren't shifted.
180 fn same_key(a: &Key, b: &Key) -> bool {
181 match (a, b) {
182 (Key::Character(x), Key::Character(y)) => x.eq_ignore_ascii_case(y),
183 (x, y) => x == y,
184 }
185 }
186
187 /// Equality is what the KEYBOARD would call the same chord, which is why it is
188 /// written rather than derived.
189 ///
190 /// The derived version compared character keys byte for byte while `matches`
191 /// compared them case-insensitively, so the two disagreed: `Ctrl+S` and
192 /// `Ctrl+s` are one keypress at the keyboard and were two distinct `Shortcut`s
193 /// in memory. Nothing noticed until `command::conflicts` started comparing
194 /// chords to each other and quietly failed to report a collision between two
195 /// spellings of the same binding — the exact failure it exists to catch. Both
196 /// go through `same_key` now, so they cannot drift again.
197 impl PartialEq for Shortcut {
198 fn eq(&self, other: &Self) -> bool {
199 self.ctrl == other.ctrl
200 && self.shift == other.shift
201 && self.alt == other.alt
202 && self.logo == other.logo
203 && same_key(&self.key, &other.key)
204 }
205 }
206
207 impl Eq for Shortcut {}
208
209 /// Chord -> command id.
210 ///
211 /// Ids rather than [`Action`]s because a chord has to be able to reach a
212 /// command the `Action` enum does not cover — New Project and Open are menu
213 /// labels, and there was no way to bind them at all while this held `Action`.
214 /// What a binding names is a row in [`crate::command::COMMANDS`], and that row
215 /// says how to run it.
216 pub struct ShortcutManager {
217 bindings: Vec<(Shortcut, &'static str)>,
218 }
219
220 impl ShortcutManager {
221 pub fn new() -> Self {
222 ShortcutManager { bindings: Vec::new() }
223 }
224
225 pub fn register(&mut self, shortcut_str: &str, command: &'static str) -> Result<(), String> {
226 let shortcut = Shortcut::parse(shortcut_str)?;
227 self.bindings.push((shortcut, command));
228 Ok(())
229 }
230
231 /// First match wins, in registration order — which is registry order. Two
232 /// commands on one chord therefore make the second unreachable in silence,
233 /// which is why `command::conflicts` exists to say so at startup.
234 pub fn match_command(&self, mods: &ModifiersState, key: &Key) -> Option<&'static str> {
235 for (shortcut, command) in &self.bindings {
236 if shortcut.matches(mods, key) {
237 return Some(command);
238 }
239 }
240 None
241 }
242
243 /// The chord bound to `command`, for showing beside its label.
244 pub fn chord_for(&self, command: &str) -> Option<&Shortcut> {
245 self.bindings.iter().find(|(_, c)| *c == command).map(|(s, _)| s)
246 }
247 }