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