Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 24 kB View raw
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}