git.lucas.co / cce-designer
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 }