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