Another project
1use core::num::NonZeroU32;
2use std::collections::BTreeMap;
3
4use bone_types::StandardView;
5use bone_ui::hotkey::{
6 ActionId, HotkeyBinding, HotkeyScope, HotkeyTable, HotkeyTableError, KeyChord,
7};
8use bone_ui::input::{KeyChar, KeyCode, ModifierMask, NamedKey};
9use bone_ui::strings::StringKey;
10use serde::{Deserialize, Serialize};
11
12use crate::sketch_mode::{ESCAPE_ACTION, REDO_ACTION, UNDO_ACTION};
13use crate::strings as s;
14
15const fn action_id(value: u32) -> ActionId {
16 let Some(nz) = NonZeroU32::new(value) else {
17 panic!("ActionId must be non-zero");
18 };
19 ActionId::new(nz)
20}
21
22pub const ENTER_SKETCH_ACTION: ActionId = action_id(4);
23pub const SMART_DIMENSION_ACTION: ActionId = action_id(5);
24pub const TRIM_ACTION: ActionId = action_id(6);
25pub const EXTEND_ACTION: ActionId = action_id(7);
26pub const MIRROR_ACTION: ActionId = action_id(8);
27pub const TOGGLE_CONSTRUCTION_ACTION: ActionId = action_id(9);
28pub const NEW_DOCUMENT_ACTION: ActionId = action_id(10);
29pub const OPEN_DOCUMENT_ACTION: ActionId = action_id(11);
30pub const SAVE_DOCUMENT_ACTION: ActionId = action_id(12);
31pub const SELECT_ALL_ACTION: ActionId = action_id(13);
32pub const DELETE_SELECTION_ACTION: ActionId = action_id(14);
33pub const ZOOM_FIT_ACTION: ActionId = action_id(15);
34pub const OPEN_SHORTCUT_BAR_ACTION: ActionId = action_id(16);
35pub const QUIT_ACTION: ActionId = action_id(17);
36pub const IMPORT_STEP_ACTION: ActionId = action_id(18);
37pub const EXPORT_STEP_ACTION: ActionId = action_id(19);
38pub const VIEW_FRONT_ACTION: ActionId = action_id(20);
39pub const VIEW_BACK_ACTION: ActionId = action_id(21);
40pub const VIEW_LEFT_ACTION: ActionId = action_id(22);
41pub const VIEW_RIGHT_ACTION: ActionId = action_id(23);
42pub const VIEW_TOP_ACTION: ActionId = action_id(24);
43pub const VIEW_BOTTOM_ACTION: ActionId = action_id(25);
44pub const VIEW_ISOMETRIC_ACTION: ActionId = action_id(26);
45pub const VIEW_NORMAL_TO_ACTION: ActionId = action_id(27);
46pub const VIEW_SELECTOR_ACTION: ActionId = action_id(28);
47pub const VIEW_CUBE_ACTION: ActionId = action_id(29);
48pub const REBUILD_ACTION: ActionId = action_id(30);
49pub const FORCE_REBUILD_ACTION: ActionId = action_id(31);
50
51const fn ch(c: char) -> KeyCode {
52 KeyCode::Char(KeyChar::from_ascii(c))
53}
54
55const fn named(k: NamedKey) -> KeyCode {
56 KeyCode::Named(k)
57}
58
59const ESC: KeyChord = KeyChord::new(named(NamedKey::Escape), ModifierMask::NONE);
60const CTRL_Z: KeyChord = KeyChord::new(ch('z'), ModifierMask::CTRL);
61const CTRL_SHIFT_Z: KeyChord =
62 KeyChord::new(ch('z'), ModifierMask::CTRL.union(ModifierMask::SHIFT));
63const CTRL_Y: KeyChord = KeyChord::new(ch('y'), ModifierMask::CTRL);
64const CTRL_N: KeyChord = KeyChord::new(ch('n'), ModifierMask::CTRL);
65const CTRL_O: KeyChord = KeyChord::new(ch('o'), ModifierMask::CTRL);
66const CTRL_S: KeyChord = KeyChord::new(ch('s'), ModifierMask::CTRL);
67const CTRL_A: KeyChord = KeyChord::new(ch('a'), ModifierMask::CTRL);
68const CTRL_Q: KeyChord = KeyChord::new(ch('q'), ModifierMask::CTRL);
69const CTRL_B: KeyChord = KeyChord::new(ch('b'), ModifierMask::CTRL);
70const CTRL_I: KeyChord = KeyChord::new(ch('i'), ModifierMask::CTRL);
71const CTRL_E: KeyChord = KeyChord::new(ch('e'), ModifierMask::CTRL);
72const CTRL_1: KeyChord = KeyChord::new(ch('1'), ModifierMask::CTRL);
73const CTRL_2: KeyChord = KeyChord::new(ch('2'), ModifierMask::CTRL);
74const CTRL_3: KeyChord = KeyChord::new(ch('3'), ModifierMask::CTRL);
75const CTRL_4: KeyChord = KeyChord::new(ch('4'), ModifierMask::CTRL);
76const CTRL_5: KeyChord = KeyChord::new(ch('5'), ModifierMask::CTRL);
77const CTRL_6: KeyChord = KeyChord::new(ch('6'), ModifierMask::CTRL);
78const CTRL_7: KeyChord = KeyChord::new(ch('7'), ModifierMask::CTRL);
79const CTRL_8: KeyChord = KeyChord::new(ch('8'), ModifierMask::CTRL);
80const SPACE: KeyChord = KeyChord::new(named(NamedKey::Space), ModifierMask::NONE);
81const CTRL_SPACE: KeyChord = KeyChord::new(named(NamedKey::Space), ModifierMask::CTRL);
82const DELETE: KeyChord = KeyChord::new(named(NamedKey::Delete), ModifierMask::NONE);
83const F_KEY: KeyChord = KeyChord::new(ch('f'), ModifierMask::NONE);
84const S_KEY: KeyChord = KeyChord::new(ch('s'), ModifierMask::NONE);
85
86#[derive(Copy, Clone, Debug, PartialEq, Eq)]
87pub enum HotkeyCommand {
88 Undo,
89 Redo,
90 NewDocument,
91 OpenDocument,
92 SaveDocument,
93 ImportStep,
94 ExportStep,
95 SelectAll,
96 DeleteSelection,
97 ZoomFit,
98 OpenShortcutBar,
99 Quit,
100 RebuildChanged,
101 ForceRebuild,
102 EnterSketch,
103 SmartDimension,
104 Trim,
105 Extend,
106 Mirror,
107 ToggleConstruction,
108 StandardView(StandardView),
109 ToggleViewSelector,
110 ToggleViewCube,
111}
112
113#[derive(Copy, Clone, Debug)]
114pub struct Command {
115 pub action: ActionId,
116 pub kind: Option<HotkeyCommand>,
117 pub scope: HotkeyScope,
118 pub label: StringKey,
119 pub defaults: &'static [KeyChord],
120}
121
122const fn view_command(
123 action: ActionId,
124 view: StandardView,
125 label: StringKey,
126 defaults: &'static [KeyChord],
127) -> Command {
128 Command {
129 action,
130 kind: Some(HotkeyCommand::StandardView(view)),
131 scope: HotkeyScope::Global,
132 label,
133 defaults,
134 }
135}
136
137pub const COMMANDS: &[Command] = &[
138 Command {
139 action: ESCAPE_ACTION,
140 kind: None,
141 scope: HotkeyScope::Sketch,
142 label: s::HOTKEY_LABEL_ESCAPE,
143 defaults: &[ESC],
144 },
145 Command {
146 action: ESCAPE_ACTION,
147 kind: None,
148 scope: HotkeyScope::Extrude,
149 label: s::HOTKEY_LABEL_ESCAPE,
150 defaults: &[ESC],
151 },
152 Command {
153 action: UNDO_ACTION,
154 kind: Some(HotkeyCommand::Undo),
155 scope: HotkeyScope::Global,
156 label: s::HOTKEY_LABEL_UNDO,
157 defaults: &[CTRL_Z],
158 },
159 Command {
160 action: REDO_ACTION,
161 kind: Some(HotkeyCommand::Redo),
162 scope: HotkeyScope::Global,
163 label: s::HOTKEY_LABEL_REDO,
164 defaults: &[CTRL_Y, CTRL_SHIFT_Z],
165 },
166 Command {
167 action: NEW_DOCUMENT_ACTION,
168 kind: Some(HotkeyCommand::NewDocument),
169 scope: HotkeyScope::Global,
170 label: s::HOTKEY_LABEL_NEW,
171 defaults: &[CTRL_N],
172 },
173 Command {
174 action: OPEN_DOCUMENT_ACTION,
175 kind: Some(HotkeyCommand::OpenDocument),
176 scope: HotkeyScope::Global,
177 label: s::HOTKEY_LABEL_OPEN,
178 defaults: &[CTRL_O],
179 },
180 Command {
181 action: SAVE_DOCUMENT_ACTION,
182 kind: Some(HotkeyCommand::SaveDocument),
183 scope: HotkeyScope::Global,
184 label: s::HOTKEY_LABEL_SAVE,
185 defaults: &[CTRL_S],
186 },
187 Command {
188 action: IMPORT_STEP_ACTION,
189 kind: Some(HotkeyCommand::ImportStep),
190 scope: HotkeyScope::Global,
191 label: s::HOTKEY_LABEL_IMPORT,
192 defaults: &[CTRL_I],
193 },
194 Command {
195 action: EXPORT_STEP_ACTION,
196 kind: Some(HotkeyCommand::ExportStep),
197 scope: HotkeyScope::Global,
198 label: s::HOTKEY_LABEL_EXPORT,
199 defaults: &[CTRL_E],
200 },
201 Command {
202 action: SELECT_ALL_ACTION,
203 kind: Some(HotkeyCommand::SelectAll),
204 scope: HotkeyScope::Global,
205 label: s::HOTKEY_LABEL_SELECT_ALL,
206 defaults: &[CTRL_A],
207 },
208 Command {
209 action: DELETE_SELECTION_ACTION,
210 kind: Some(HotkeyCommand::DeleteSelection),
211 scope: HotkeyScope::Global,
212 label: s::HOTKEY_LABEL_DELETE_SELECTION,
213 defaults: &[DELETE],
214 },
215 Command {
216 action: ZOOM_FIT_ACTION,
217 kind: Some(HotkeyCommand::ZoomFit),
218 scope: HotkeyScope::Global,
219 label: s::HOTKEY_LABEL_ZOOM_FIT,
220 defaults: &[F_KEY],
221 },
222 Command {
223 action: OPEN_SHORTCUT_BAR_ACTION,
224 kind: Some(HotkeyCommand::OpenShortcutBar),
225 scope: HotkeyScope::Global,
226 label: s::HOTKEY_LABEL_SHORTCUT_BAR,
227 defaults: &[S_KEY],
228 },
229 Command {
230 action: QUIT_ACTION,
231 kind: Some(HotkeyCommand::Quit),
232 scope: HotkeyScope::Global,
233 label: s::HOTKEY_LABEL_QUIT,
234 defaults: &[],
235 },
236 Command {
237 action: REBUILD_ACTION,
238 kind: Some(HotkeyCommand::RebuildChanged),
239 scope: HotkeyScope::Global,
240 label: s::HOTKEY_LABEL_REBUILD,
241 defaults: &[CTRL_B],
242 },
243 Command {
244 action: FORCE_REBUILD_ACTION,
245 kind: Some(HotkeyCommand::ForceRebuild),
246 scope: HotkeyScope::Global,
247 label: s::HOTKEY_LABEL_FORCE_REBUILD,
248 defaults: &[CTRL_Q],
249 },
250 Command {
251 action: ENTER_SKETCH_ACTION,
252 kind: Some(HotkeyCommand::EnterSketch),
253 scope: HotkeyScope::Global,
254 label: s::HOTKEY_LABEL_SKETCH,
255 defaults: &[],
256 },
257 Command {
258 action: SMART_DIMENSION_ACTION,
259 kind: Some(HotkeyCommand::SmartDimension),
260 scope: HotkeyScope::Sketch,
261 label: s::HOTKEY_LABEL_SMART_DIMENSION,
262 defaults: &[],
263 },
264 Command {
265 action: TRIM_ACTION,
266 kind: Some(HotkeyCommand::Trim),
267 scope: HotkeyScope::Sketch,
268 label: s::HOTKEY_LABEL_TRIM,
269 defaults: &[],
270 },
271 Command {
272 action: EXTEND_ACTION,
273 kind: Some(HotkeyCommand::Extend),
274 scope: HotkeyScope::Sketch,
275 label: s::HOTKEY_LABEL_EXTEND,
276 defaults: &[],
277 },
278 Command {
279 action: MIRROR_ACTION,
280 kind: Some(HotkeyCommand::Mirror),
281 scope: HotkeyScope::Sketch,
282 label: s::HOTKEY_LABEL_MIRROR,
283 defaults: &[],
284 },
285 Command {
286 action: TOGGLE_CONSTRUCTION_ACTION,
287 kind: Some(HotkeyCommand::ToggleConstruction),
288 scope: HotkeyScope::Sketch,
289 label: s::HOTKEY_LABEL_CONSTRUCTION_TOGGLE,
290 defaults: &[],
291 },
292 view_command(
293 VIEW_FRONT_ACTION,
294 StandardView::Front,
295 s::VIEW_FRONT,
296 &[CTRL_1],
297 ),
298 view_command(
299 VIEW_BACK_ACTION,
300 StandardView::Back,
301 s::VIEW_BACK,
302 &[CTRL_2],
303 ),
304 view_command(
305 VIEW_LEFT_ACTION,
306 StandardView::Left,
307 s::VIEW_LEFT,
308 &[CTRL_3],
309 ),
310 view_command(
311 VIEW_RIGHT_ACTION,
312 StandardView::Right,
313 s::VIEW_RIGHT,
314 &[CTRL_4],
315 ),
316 view_command(VIEW_TOP_ACTION, StandardView::Top, s::VIEW_TOP, &[CTRL_5]),
317 view_command(
318 VIEW_BOTTOM_ACTION,
319 StandardView::Bottom,
320 s::VIEW_BOTTOM,
321 &[CTRL_6],
322 ),
323 view_command(
324 VIEW_ISOMETRIC_ACTION,
325 StandardView::Isometric,
326 s::VIEW_ISOMETRIC,
327 &[CTRL_7],
328 ),
329 view_command(
330 VIEW_NORMAL_TO_ACTION,
331 StandardView::NormalTo,
332 s::VIEW_NORMAL_TO,
333 &[CTRL_8],
334 ),
335 Command {
336 action: VIEW_SELECTOR_ACTION,
337 kind: Some(HotkeyCommand::ToggleViewSelector),
338 scope: HotkeyScope::Global,
339 label: s::VIEW_SELECTOR,
340 defaults: &[SPACE],
341 },
342 Command {
343 action: VIEW_CUBE_ACTION,
344 kind: Some(HotkeyCommand::ToggleViewCube),
345 scope: HotkeyScope::Global,
346 label: s::VIEW_CUBE,
347 defaults: &[CTRL_SPACE],
348 },
349];
350
351#[must_use]
352pub fn command_for_action(action: ActionId) -> Option<HotkeyCommand> {
353 COMMANDS
354 .iter()
355 .find(|c| c.action == action)
356 .and_then(|c| c.kind)
357}
358
359#[must_use]
360pub fn label_for_command(kind: HotkeyCommand) -> StringKey {
361 COMMANDS
362 .iter()
363 .find(|c| c.kind == Some(kind))
364 .map_or(s::HOTKEY_UNBOUND_LABEL, |c| c.label)
365}
366
367#[must_use]
368pub fn accelerator_label(action: ActionId, overrides: &HotkeyOverrides) -> Option<String> {
369 let from_override = overrides.lookup(action);
370 let from_default = COMMANDS
371 .iter()
372 .find(|c| c.action == action)
373 .and_then(|c| c.defaults.first().copied());
374 from_override
375 .or(from_default)
376 .map(|chord| chord.to_string())
377}
378
379#[cfg(test)]
380#[must_use]
381fn default_bindings() -> Vec<HotkeyBinding> {
382 COMMANDS
383 .iter()
384 .flat_map(|cmd| {
385 cmd.defaults
386 .iter()
387 .copied()
388 .map(move |chord| HotkeyBinding::new(chord, cmd.scope, cmd.action))
389 })
390 .collect()
391}
392
393#[derive(Copy, Clone, Debug, PartialEq, Eq)]
394pub struct RemapEntry {
395 pub action: ActionId,
396 pub scope: HotkeyScope,
397 pub label: StringKey,
398 pub default_chord: Option<KeyChord>,
399}
400
401#[must_use]
402pub fn remap_entries() -> Vec<RemapEntry> {
403 COMMANDS.iter().fold(Vec::new(), |mut acc, cmd| {
404 if !acc
405 .iter()
406 .any(|entry: &RemapEntry| entry.action == cmd.action)
407 {
408 acc.push(RemapEntry {
409 action: cmd.action,
410 scope: cmd.scope,
411 label: cmd.label,
412 default_chord: cmd.defaults.first().copied(),
413 });
414 }
415 acc
416 })
417}
418
419#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
420pub struct HotkeyOverrides {
421 entries: BTreeMap<ActionId, KeyChord>,
422}
423
424impl HotkeyOverrides {
425 pub fn set(&mut self, action: ActionId, chord: KeyChord) {
426 self.entries.insert(action, chord);
427 }
428
429 #[must_use]
430 pub fn lookup(&self, action: ActionId) -> Option<KeyChord> {
431 self.entries.get(&action).copied()
432 }
433}
434
435pub fn compose_table(overrides: &HotkeyOverrides) -> Result<HotkeyTable, HotkeyTableError> {
436 let bindings: Vec<HotkeyBinding> = COMMANDS
437 .iter()
438 .flat_map(|cmd| {
439 let chords: Vec<KeyChord> = overrides
440 .lookup(cmd.action)
441 .map_or_else(|| cmd.defaults.to_vec(), |chord| vec![chord]);
442 chords
443 .into_iter()
444 .map(move |chord| HotkeyBinding::new(chord, cmd.scope, cmd.action))
445 })
446 .collect();
447 reject_cross_scope_shadow(&bindings)?;
448 HotkeyTable::try_from_bindings(bindings)
449}
450
451fn reject_cross_scope_shadow(bindings: &[HotkeyBinding]) -> Result<(), HotkeyTableError> {
452 bindings.iter().try_for_each(|inner| {
453 if !matches!(inner.scope, HotkeyScope::Sketch | HotkeyScope::Extrude) {
454 return Ok(());
455 }
456 let outer = bindings
457 .iter()
458 .find(|other| other.scope == HotkeyScope::Global && other.chord == inner.chord);
459 match outer {
460 None => Ok(()),
461 Some(other) => Err(HotkeyTableError::Conflict {
462 chord: inner.chord,
463 scope: inner.scope,
464 existing: other.action,
465 attempted: inner.action,
466 }),
467 }
468 })
469}
470
471#[cfg(test)]
472mod tests {
473 use super::{
474 DELETE_SELECTION_ACTION, ENTER_SKETCH_ACTION, ESCAPE_ACTION, EXPORT_STEP_ACTION,
475 EXTEND_ACTION, HotkeyOverrides, IMPORT_STEP_ACTION, MIRROR_ACTION, NEW_DOCUMENT_ACTION,
476 OPEN_DOCUMENT_ACTION, OPEN_SHORTCUT_BAR_ACTION, QUIT_ACTION, REDO_ACTION,
477 SAVE_DOCUMENT_ACTION, SELECT_ALL_ACTION, SMART_DIMENSION_ACTION,
478 TOGGLE_CONSTRUCTION_ACTION, TRIM_ACTION, UNDO_ACTION, VIEW_BACK_ACTION, VIEW_BOTTOM_ACTION,
479 VIEW_CUBE_ACTION, VIEW_FRONT_ACTION, VIEW_ISOMETRIC_ACTION, VIEW_LEFT_ACTION,
480 VIEW_NORMAL_TO_ACTION, VIEW_RIGHT_ACTION, VIEW_SELECTOR_ACTION, VIEW_TOP_ACTION,
481 ZOOM_FIT_ACTION, compose_table, default_bindings, remap_entries,
482 };
483 use bone_ui::hotkey::{ActionId, HotkeyScope, HotkeyScopes, KeyChord};
484 use bone_ui::input::{KeyChar, KeyCode, ModifierMask, NamedKey};
485
486 fn ch(c: char) -> KeyCode {
487 KeyCode::Char(KeyChar::from_char(c))
488 }
489
490 fn scopes() -> HotkeyScopes {
491 HotkeyScopes::from_outer_to_inner([HotkeyScope::Global, HotkeyScope::Sketch])
492 }
493
494 #[test]
495 fn default_table_snapshot_pins_action_chord_scope() {
496 let mut rendered = default_bindings()
497 .into_iter()
498 .map(|b| {
499 format!(
500 "action={} chord={} scope={:?}",
501 b.action.get().get(),
502 KeyChord::new(b.chord.key, b.chord.modifiers),
503 b.scope,
504 )
505 })
506 .collect::<Vec<_>>();
507 rendered.sort();
508 insta::assert_snapshot!("default_hotkey_table", rendered.join("\n"));
509 }
510
511 #[test]
512 fn override_replaces_chord_for_action() {
513 let mut overrides = HotkeyOverrides::default();
514 let new_chord = KeyChord::new(ch('q'), ModifierMask::SHIFT);
515 overrides.set(SMART_DIMENSION_ACTION, new_chord);
516 let Ok(table) = compose_table(&overrides) else {
517 panic!("non-conflicting override must compose");
518 };
519 assert_eq!(
520 table.dispatch(new_chord, &scopes()),
521 Some(SMART_DIMENSION_ACTION)
522 );
523 }
524
525 #[test]
526 fn empty_overrides_match_defaults() {
527 let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
528 panic!("defaults must compose");
529 };
530 let chord = KeyChord::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE);
531 assert_eq!(table.dispatch(chord, &scopes()), Some(ESCAPE_ACTION));
532 }
533
534 #[test]
535 fn redo_has_two_default_chords() {
536 let redo = default_bindings()
537 .into_iter()
538 .filter(|b| b.action == REDO_ACTION)
539 .count();
540 assert_eq!(redo, 2, "Ctrl+Y and Ctrl+Shift+Z both bind redo");
541 }
542
543 #[test]
544 fn remap_entries_cover_every_command() {
545 let actions = remap_entries()
546 .into_iter()
547 .map(|e| e.action)
548 .collect::<std::collections::BTreeSet<_>>();
549 [
550 ESCAPE_ACTION,
551 UNDO_ACTION,
552 REDO_ACTION,
553 ENTER_SKETCH_ACTION,
554 SMART_DIMENSION_ACTION,
555 TRIM_ACTION,
556 EXTEND_ACTION,
557 MIRROR_ACTION,
558 TOGGLE_CONSTRUCTION_ACTION,
559 NEW_DOCUMENT_ACTION,
560 OPEN_DOCUMENT_ACTION,
561 SAVE_DOCUMENT_ACTION,
562 SELECT_ALL_ACTION,
563 DELETE_SELECTION_ACTION,
564 ZOOM_FIT_ACTION,
565 OPEN_SHORTCUT_BAR_ACTION,
566 QUIT_ACTION,
567 IMPORT_STEP_ACTION,
568 EXPORT_STEP_ACTION,
569 VIEW_FRONT_ACTION,
570 VIEW_BACK_ACTION,
571 VIEW_LEFT_ACTION,
572 VIEW_RIGHT_ACTION,
573 VIEW_TOP_ACTION,
574 VIEW_BOTTOM_ACTION,
575 VIEW_ISOMETRIC_ACTION,
576 VIEW_NORMAL_TO_ACTION,
577 VIEW_SELECTOR_ACTION,
578 VIEW_CUBE_ACTION,
579 ]
580 .iter()
581 .for_each(|a| assert!(actions.contains(a), "missing remappable: {a:?}"));
582 }
583
584 #[test]
585 fn ctrl_digits_dispatch_standard_views() {
586 let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
587 panic!("defaults must compose");
588 };
589 let expected: [(char, ActionId); 8] = [
590 ('1', VIEW_FRONT_ACTION),
591 ('2', VIEW_BACK_ACTION),
592 ('3', VIEW_LEFT_ACTION),
593 ('4', VIEW_RIGHT_ACTION),
594 ('5', VIEW_TOP_ACTION),
595 ('6', VIEW_BOTTOM_ACTION),
596 ('7', VIEW_ISOMETRIC_ACTION),
597 ('8', VIEW_NORMAL_TO_ACTION),
598 ];
599 expected.iter().for_each(|(digit, action)| {
600 let chord = KeyChord::new(ch(*digit), ModifierMask::CTRL);
601 assert_eq!(table.dispatch(chord, &scopes()), Some(*action));
602 });
603 }
604
605 #[test]
606 fn space_chords_dispatch_view_surfaces() {
607 let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
608 panic!("defaults must compose");
609 };
610 let space = KeyChord::new(KeyCode::Named(NamedKey::Space), ModifierMask::NONE);
611 let ctrl_space = KeyChord::new(KeyCode::Named(NamedKey::Space), ModifierMask::CTRL);
612 assert_eq!(table.dispatch(space, &scopes()), Some(VIEW_SELECTOR_ACTION));
613 assert_eq!(
614 table.dispatch(ctrl_space, &scopes()),
615 Some(VIEW_CUBE_ACTION)
616 );
617 }
618
619 #[test]
620 fn ctrl_e_dispatches_export_inside_sketch_scope() {
621 let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
622 panic!("defaults must compose");
623 };
624 let ctrl_e = KeyChord::new(ch('e'), ModifierMask::CTRL);
625 assert_eq!(table.dispatch(ctrl_e, &scopes()), Some(EXPORT_STEP_ACTION));
626 }
627
628 #[test]
629 fn sketch_tool_actions_unbound_by_default() {
630 let Ok(table) = compose_table(&HotkeyOverrides::default()) else {
631 panic!("defaults compose");
632 };
633 let only_sketch = HotkeyScopes::from_outer_to_inner([HotkeyScope::Sketch]);
634 let all = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global, HotkeyScope::Sketch]);
635 [
636 TRIM_ACTION,
637 EXTEND_ACTION,
638 MIRROR_ACTION,
639 TOGGLE_CONSTRUCTION_ACTION,
640 SMART_DIMENSION_ACTION,
641 ENTER_SKETCH_ACTION,
642 ]
643 .iter()
644 .for_each(|action| {
645 let bound_in_sketch = ['d', 't', 'e', 'm', 'g', 's'].iter().any(|c| {
646 let chord = KeyChord::new(ch(*c), ModifierMask::NONE);
647 table.dispatch(chord, &only_sketch) == Some(*action)
648 || table.dispatch(chord, &all) == Some(*action)
649 });
650 assert!(
651 !bound_in_sketch,
652 "{action:?} must ship unbound with no stock default"
653 );
654 });
655 }
656
657 #[test]
658 fn override_conflicting_with_default_is_rejected() {
659 let mut overrides = HotkeyOverrides::default();
660 let save_chord = KeyChord::new(ch('s'), ModifierMask::CTRL);
661 overrides.set(NEW_DOCUMENT_ACTION, save_chord);
662 assert!(compose_table(&overrides).is_err());
663 }
664
665 #[test]
666 fn override_replaces_all_defaults() {
667 let mut overrides = HotkeyOverrides::default();
668 let new_redo = KeyChord::new(ch('u'), ModifierMask::CTRL);
669 overrides.set(REDO_ACTION, new_redo);
670 let Ok(table) = compose_table(&overrides) else {
671 panic!("non-conflicting override must compose");
672 };
673 let ctrl_y = KeyChord::new(ch('y'), ModifierMask::CTRL);
674 let ctrl_shift_z = KeyChord::new(ch('z'), ModifierMask::CTRL.union(ModifierMask::SHIFT));
675 assert_eq!(table.dispatch(new_redo, &scopes()), Some(REDO_ACTION));
676 assert_eq!(
677 table.dispatch(ctrl_y, &scopes()),
678 None,
679 "default Ctrl+Y must be dropped after override",
680 );
681 assert_eq!(
682 table.dispatch(ctrl_shift_z, &scopes()),
683 None,
684 "default Ctrl+Shift+Z must be dropped after override",
685 );
686 }
687
688 #[test]
689 fn override_equal_to_default_replaces_other_defaults() {
690 let mut overrides = HotkeyOverrides::default();
691 let ctrl_y = KeyChord::new(ch('y'), ModifierMask::CTRL);
692 overrides.set(REDO_ACTION, ctrl_y);
693 let Ok(table) = compose_table(&overrides) else {
694 panic!("override matching a default must compose");
695 };
696 let ctrl_shift_z = KeyChord::new(ch('z'), ModifierMask::CTRL.union(ModifierMask::SHIFT));
697 assert_eq!(table.dispatch(ctrl_y, &scopes()), Some(REDO_ACTION));
698 assert_eq!(
699 table.dispatch(ctrl_shift_z, &scopes()),
700 None,
701 "non-overridden default must also drop when any override is set",
702 );
703 }
704
705 #[test]
706 fn sketch_override_shadowing_global_is_rejected() {
707 let mut overrides = HotkeyOverrides::default();
708 let ctrl_s = KeyChord::new(ch('s'), ModifierMask::CTRL);
709 overrides.set(SMART_DIMENSION_ACTION, ctrl_s);
710 assert!(
711 compose_table(&overrides).is_err(),
712 "sketch-scope override must not shadow a Global-scope default",
713 );
714 }
715}