Another project
0

Configure Feed

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

feat(app,ui): hotkey customization & shortcut bar

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (May 21, 2026, 9:12 AM +0300) commit 31cf26ef parent 11ee550b change-id pwxxtvvw
+2174 -185
+3
Cargo.lock
··· 379 379 "bone-text", 380 380 "bone-types", 381 381 "bone-ui", 382 + "insta", 382 383 "percent-encoding", 383 384 "pollster", 385 + "ron", 386 + "serde", 384 387 "swash", 385 388 "thiserror 2.0.18", 386 389 "tracing",
+5
crates/bone-app/Cargo.toml
··· 15 15 wgpu = { workspace = true } 16 16 17 17 pollster = { workspace = true } 18 + ron = { workspace = true } 19 + serde = { workspace = true } 18 20 thiserror = { workspace = true } 19 21 tracing = { workspace = true } 20 22 tracing-subscriber = { workspace = true } 21 23 uom = { workspace = true } 22 24 winit = { workspace = true } 25 + 26 + [dev-dependencies] 27 + insta = { workspace = true } 23 28 24 29 [target.'cfg(target_os = "linux")'.dependencies] 25 30 ashpd = { workspace = true }
+509
crates/bone-app/src/hotkeys.rs
··· 1 + use core::num::NonZeroU32; 2 + use std::collections::BTreeMap; 3 + 4 + use bone_ui::hotkey::{ 5 + ActionId, HotkeyBinding, HotkeyScope, HotkeyTable, HotkeyTableError, KeyChord, 6 + }; 7 + use bone_ui::input::{KeyChar, KeyCode, ModifierMask, NamedKey}; 8 + use bone_ui::strings::StringKey; 9 + use serde::{Deserialize, Serialize}; 10 + 11 + use crate::sketch_mode::{EXIT_SKETCH_ACTION, REDO_ACTION, UNDO_ACTION}; 12 + use crate::strings as s; 13 + 14 + const fn action_id(value: u32) -> ActionId { 15 + let Some(nz) = NonZeroU32::new(value) else { 16 + panic!("ActionId must be non-zero"); 17 + }; 18 + ActionId::new(nz) 19 + } 20 + 21 + pub const ENTER_SKETCH_ACTION: ActionId = action_id(4); 22 + pub const SMART_DIMENSION_ACTION: ActionId = action_id(5); 23 + pub const TRIM_ACTION: ActionId = action_id(6); 24 + pub const EXTEND_ACTION: ActionId = action_id(7); 25 + pub const MIRROR_ACTION: ActionId = action_id(8); 26 + pub const TOGGLE_CONSTRUCTION_ACTION: ActionId = action_id(9); 27 + pub const NEW_DOCUMENT_ACTION: ActionId = action_id(10); 28 + pub const OPEN_DOCUMENT_ACTION: ActionId = action_id(11); 29 + pub const SAVE_DOCUMENT_ACTION: ActionId = action_id(12); 30 + pub const SELECT_ALL_ACTION: ActionId = action_id(13); 31 + pub const DELETE_SELECTION_ACTION: ActionId = action_id(14); 32 + pub const ZOOM_FIT_ACTION: ActionId = action_id(15); 33 + pub const OPEN_SHORTCUT_BAR_ACTION: ActionId = action_id(16); 34 + pub const QUIT_ACTION: ActionId = action_id(17); 35 + 36 + const fn ch(c: char) -> KeyCode { 37 + KeyCode::Char(KeyChar::from_ascii(c)) 38 + } 39 + 40 + const fn named(k: NamedKey) -> KeyCode { 41 + KeyCode::Named(k) 42 + } 43 + 44 + const ESC: KeyChord = KeyChord::new(named(NamedKey::Escape), ModifierMask::NONE); 45 + const CTRL_Z: KeyChord = KeyChord::new(ch('z'), ModifierMask::CTRL); 46 + const CTRL_SHIFT_Z: KeyChord = 47 + KeyChord::new(ch('z'), ModifierMask::CTRL.union(ModifierMask::SHIFT)); 48 + const CTRL_Y: KeyChord = KeyChord::new(ch('y'), ModifierMask::CTRL); 49 + const CTRL_N: KeyChord = KeyChord::new(ch('n'), ModifierMask::CTRL); 50 + const CTRL_O: KeyChord = KeyChord::new(ch('o'), ModifierMask::CTRL); 51 + const CTRL_S: KeyChord = KeyChord::new(ch('s'), ModifierMask::CTRL); 52 + const CTRL_A: KeyChord = KeyChord::new(ch('a'), ModifierMask::CTRL); 53 + const CTRL_Q: KeyChord = KeyChord::new(ch('q'), ModifierMask::CTRL); 54 + const DELETE: KeyChord = KeyChord::new(named(NamedKey::Delete), ModifierMask::NONE); 55 + const F_KEY: KeyChord = KeyChord::new(ch('f'), ModifierMask::NONE); 56 + const S_KEY: KeyChord = KeyChord::new(ch('s'), ModifierMask::NONE); 57 + 58 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 59 + pub enum HotkeyCommand { 60 + Undo, 61 + Redo, 62 + NewDocument, 63 + OpenDocument, 64 + SaveDocument, 65 + SelectAll, 66 + DeleteSelection, 67 + ZoomFit, 68 + OpenShortcutBar, 69 + Quit, 70 + EnterSketch, 71 + SmartDimension, 72 + Trim, 73 + Extend, 74 + Mirror, 75 + ToggleConstruction, 76 + } 77 + 78 + #[derive(Copy, Clone, Debug)] 79 + pub struct Command { 80 + pub action: ActionId, 81 + pub kind: Option<HotkeyCommand>, 82 + pub scope: HotkeyScope, 83 + pub label: StringKey, 84 + pub defaults: &'static [KeyChord], 85 + } 86 + 87 + pub const COMMANDS: &[Command] = &[ 88 + Command { 89 + action: EXIT_SKETCH_ACTION, 90 + kind: None, 91 + scope: HotkeyScope::Sketch, 92 + label: s::HOTKEY_LABEL_ESCAPE, 93 + defaults: &[ESC], 94 + }, 95 + Command { 96 + action: UNDO_ACTION, 97 + kind: Some(HotkeyCommand::Undo), 98 + scope: HotkeyScope::Global, 99 + label: s::HOTKEY_LABEL_UNDO, 100 + defaults: &[CTRL_Z], 101 + }, 102 + Command { 103 + action: REDO_ACTION, 104 + kind: Some(HotkeyCommand::Redo), 105 + scope: HotkeyScope::Global, 106 + label: s::HOTKEY_LABEL_REDO, 107 + defaults: &[CTRL_Y, CTRL_SHIFT_Z], 108 + }, 109 + Command { 110 + action: NEW_DOCUMENT_ACTION, 111 + kind: Some(HotkeyCommand::NewDocument), 112 + scope: HotkeyScope::Global, 113 + label: s::HOTKEY_LABEL_NEW, 114 + defaults: &[CTRL_N], 115 + }, 116 + Command { 117 + action: OPEN_DOCUMENT_ACTION, 118 + kind: Some(HotkeyCommand::OpenDocument), 119 + scope: HotkeyScope::Global, 120 + label: s::HOTKEY_LABEL_OPEN, 121 + defaults: &[CTRL_O], 122 + }, 123 + Command { 124 + action: SAVE_DOCUMENT_ACTION, 125 + kind: Some(HotkeyCommand::SaveDocument), 126 + scope: HotkeyScope::Global, 127 + label: s::HOTKEY_LABEL_SAVE, 128 + defaults: &[CTRL_S], 129 + }, 130 + Command { 131 + action: SELECT_ALL_ACTION, 132 + kind: Some(HotkeyCommand::SelectAll), 133 + scope: HotkeyScope::Global, 134 + label: s::HOTKEY_LABEL_SELECT_ALL, 135 + defaults: &[CTRL_A], 136 + }, 137 + Command { 138 + action: DELETE_SELECTION_ACTION, 139 + kind: Some(HotkeyCommand::DeleteSelection), 140 + scope: HotkeyScope::Global, 141 + label: s::HOTKEY_LABEL_DELETE_SELECTION, 142 + defaults: &[DELETE], 143 + }, 144 + Command { 145 + action: ZOOM_FIT_ACTION, 146 + kind: Some(HotkeyCommand::ZoomFit), 147 + scope: HotkeyScope::Global, 148 + label: s::HOTKEY_LABEL_ZOOM_FIT, 149 + defaults: &[F_KEY], 150 + }, 151 + Command { 152 + action: OPEN_SHORTCUT_BAR_ACTION, 153 + kind: Some(HotkeyCommand::OpenShortcutBar), 154 + scope: HotkeyScope::Global, 155 + label: s::HOTKEY_LABEL_SHORTCUT_BAR, 156 + defaults: &[S_KEY], 157 + }, 158 + Command { 159 + action: QUIT_ACTION, 160 + kind: Some(HotkeyCommand::Quit), 161 + scope: HotkeyScope::Global, 162 + label: s::HOTKEY_LABEL_QUIT, 163 + defaults: &[CTRL_Q], 164 + }, 165 + Command { 166 + action: ENTER_SKETCH_ACTION, 167 + kind: Some(HotkeyCommand::EnterSketch), 168 + scope: HotkeyScope::Global, 169 + label: s::HOTKEY_LABEL_SKETCH, 170 + defaults: &[], 171 + }, 172 + Command { 173 + action: SMART_DIMENSION_ACTION, 174 + kind: Some(HotkeyCommand::SmartDimension), 175 + scope: HotkeyScope::Sketch, 176 + label: s::HOTKEY_LABEL_SMART_DIMENSION, 177 + defaults: &[], 178 + }, 179 + Command { 180 + action: TRIM_ACTION, 181 + kind: Some(HotkeyCommand::Trim), 182 + scope: HotkeyScope::Sketch, 183 + label: s::HOTKEY_LABEL_TRIM, 184 + defaults: &[], 185 + }, 186 + Command { 187 + action: EXTEND_ACTION, 188 + kind: Some(HotkeyCommand::Extend), 189 + scope: HotkeyScope::Sketch, 190 + label: s::HOTKEY_LABEL_EXTEND, 191 + defaults: &[], 192 + }, 193 + Command { 194 + action: MIRROR_ACTION, 195 + kind: Some(HotkeyCommand::Mirror), 196 + scope: HotkeyScope::Sketch, 197 + label: s::HOTKEY_LABEL_MIRROR, 198 + defaults: &[], 199 + }, 200 + Command { 201 + action: TOGGLE_CONSTRUCTION_ACTION, 202 + kind: Some(HotkeyCommand::ToggleConstruction), 203 + scope: HotkeyScope::Sketch, 204 + label: s::HOTKEY_LABEL_CONSTRUCTION_TOGGLE, 205 + defaults: &[], 206 + }, 207 + ]; 208 + 209 + #[must_use] 210 + pub fn command_for_action(action: ActionId) -> Option<HotkeyCommand> { 211 + COMMANDS 212 + .iter() 213 + .find(|c| c.action == action) 214 + .and_then(|c| c.kind) 215 + } 216 + 217 + #[must_use] 218 + pub fn label_for_command(kind: HotkeyCommand) -> StringKey { 219 + COMMANDS 220 + .iter() 221 + .find(|c| c.kind == Some(kind)) 222 + .map_or(s::HOTKEY_UNBOUND_LABEL, |c| c.label) 223 + } 224 + 225 + #[must_use] 226 + pub fn accelerator_label(action: ActionId, overrides: &HotkeyOverrides) -> Option<String> { 227 + let from_override = overrides.lookup(action); 228 + let from_default = COMMANDS 229 + .iter() 230 + .find(|c| c.action == action) 231 + .and_then(|c| c.defaults.first().copied()); 232 + from_override 233 + .or(from_default) 234 + .map(|chord| chord.to_string()) 235 + } 236 + 237 + #[cfg(test)] 238 + #[must_use] 239 + fn default_bindings() -> Vec<HotkeyBinding> { 240 + COMMANDS 241 + .iter() 242 + .flat_map(|cmd| { 243 + cmd.defaults 244 + .iter() 245 + .copied() 246 + .map(move |chord| HotkeyBinding::new(chord, cmd.scope, cmd.action)) 247 + }) 248 + .collect() 249 + } 250 + 251 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 252 + pub struct RemapEntry { 253 + pub action: ActionId, 254 + pub scope: HotkeyScope, 255 + pub label: StringKey, 256 + pub default_chord: Option<KeyChord>, 257 + } 258 + 259 + #[must_use] 260 + pub fn remap_entries() -> Vec<RemapEntry> { 261 + COMMANDS 262 + .iter() 263 + .map(|cmd| RemapEntry { 264 + action: cmd.action, 265 + scope: cmd.scope, 266 + label: cmd.label, 267 + default_chord: cmd.defaults.first().copied(), 268 + }) 269 + .collect() 270 + } 271 + 272 + #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 273 + pub struct HotkeyOverrides { 274 + entries: BTreeMap<ActionId, KeyChord>, 275 + } 276 + 277 + impl HotkeyOverrides { 278 + pub fn set(&mut self, action: ActionId, chord: KeyChord) { 279 + self.entries.insert(action, chord); 280 + } 281 + 282 + #[must_use] 283 + pub fn lookup(&self, action: ActionId) -> Option<KeyChord> { 284 + self.entries.get(&action).copied() 285 + } 286 + } 287 + 288 + pub fn compose_table(overrides: &HotkeyOverrides) -> Result<HotkeyTable, HotkeyTableError> { 289 + let bindings: Vec<HotkeyBinding> = COMMANDS 290 + .iter() 291 + .flat_map(|cmd| { 292 + let chords: Vec<KeyChord> = overrides 293 + .lookup(cmd.action) 294 + .map_or_else(|| cmd.defaults.to_vec(), |chord| vec![chord]); 295 + chords 296 + .into_iter() 297 + .map(move |chord| HotkeyBinding::new(chord, cmd.scope, cmd.action)) 298 + }) 299 + .collect(); 300 + reject_cross_scope_shadow(&bindings)?; 301 + HotkeyTable::try_from_bindings(bindings) 302 + } 303 + 304 + fn reject_cross_scope_shadow(bindings: &[HotkeyBinding]) -> Result<(), HotkeyTableError> { 305 + bindings.iter().try_for_each(|inner| { 306 + if inner.scope != HotkeyScope::Sketch { 307 + return Ok(()); 308 + } 309 + let outer = bindings.iter().find(|other| { 310 + other.scope == HotkeyScope::Global && other.chord == inner.chord 311 + }); 312 + match outer { 313 + None => Ok(()), 314 + Some(other) => Err(HotkeyTableError::Conflict { 315 + chord: inner.chord, 316 + scope: inner.scope, 317 + existing: other.action, 318 + attempted: inner.action, 319 + }), 320 + } 321 + }) 322 + } 323 + 324 + #[cfg(test)] 325 + mod tests { 326 + use super::{ 327 + DELETE_SELECTION_ACTION, ENTER_SKETCH_ACTION, EXIT_SKETCH_ACTION, EXTEND_ACTION, 328 + HotkeyOverrides, MIRROR_ACTION, NEW_DOCUMENT_ACTION, OPEN_DOCUMENT_ACTION, 329 + OPEN_SHORTCUT_BAR_ACTION, QUIT_ACTION, REDO_ACTION, SAVE_DOCUMENT_ACTION, 330 + SELECT_ALL_ACTION, SMART_DIMENSION_ACTION, TOGGLE_CONSTRUCTION_ACTION, TRIM_ACTION, 331 + UNDO_ACTION, ZOOM_FIT_ACTION, compose_table, default_bindings, remap_entries, 332 + }; 333 + use bone_ui::hotkey::{HotkeyScope, HotkeyScopes, KeyChord}; 334 + use bone_ui::input::{KeyChar, KeyCode, ModifierMask, NamedKey}; 335 + 336 + fn ch(c: char) -> KeyCode { 337 + KeyCode::Char(KeyChar::from_char(c)) 338 + } 339 + 340 + fn scopes() -> HotkeyScopes { 341 + HotkeyScopes::from_outer_to_inner([HotkeyScope::Global, HotkeyScope::Sketch]) 342 + } 343 + 344 + #[test] 345 + fn default_table_snapshot_pins_action_chord_scope() { 346 + let mut rendered = default_bindings() 347 + .into_iter() 348 + .map(|b| { 349 + format!( 350 + "action={} chord={} scope={:?}", 351 + b.action.get().get(), 352 + KeyChord::new(b.chord.key, b.chord.modifiers), 353 + b.scope, 354 + ) 355 + }) 356 + .collect::<Vec<_>>(); 357 + rendered.sort(); 358 + insta::assert_snapshot!("default_hotkey_table", rendered.join("\n")); 359 + } 360 + 361 + #[test] 362 + fn override_replaces_chord_for_action() { 363 + let mut overrides = HotkeyOverrides::default(); 364 + let new_chord = KeyChord::new(ch('q'), ModifierMask::SHIFT); 365 + overrides.set(SMART_DIMENSION_ACTION, new_chord); 366 + let Ok(table) = compose_table(&overrides) else { 367 + panic!("non-conflicting override must compose"); 368 + }; 369 + assert_eq!( 370 + table.dispatch(new_chord, &scopes()), 371 + Some(SMART_DIMENSION_ACTION) 372 + ); 373 + } 374 + 375 + #[test] 376 + fn empty_overrides_match_defaults() { 377 + let Ok(table) = compose_table(&HotkeyOverrides::default()) else { 378 + panic!("defaults must compose"); 379 + }; 380 + let chord = KeyChord::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 381 + assert_eq!(table.dispatch(chord, &scopes()), Some(EXIT_SKETCH_ACTION)); 382 + } 383 + 384 + #[test] 385 + fn redo_has_two_default_chords() { 386 + let redo = default_bindings() 387 + .into_iter() 388 + .filter(|b| b.action == REDO_ACTION) 389 + .count(); 390 + assert_eq!(redo, 2, "Ctrl+Y and Ctrl+Shift+Z both bind redo"); 391 + } 392 + 393 + #[test] 394 + fn remap_entries_cover_every_command() { 395 + let actions = remap_entries() 396 + .into_iter() 397 + .map(|e| e.action) 398 + .collect::<std::collections::BTreeSet<_>>(); 399 + [ 400 + EXIT_SKETCH_ACTION, 401 + UNDO_ACTION, 402 + REDO_ACTION, 403 + ENTER_SKETCH_ACTION, 404 + SMART_DIMENSION_ACTION, 405 + TRIM_ACTION, 406 + EXTEND_ACTION, 407 + MIRROR_ACTION, 408 + TOGGLE_CONSTRUCTION_ACTION, 409 + NEW_DOCUMENT_ACTION, 410 + OPEN_DOCUMENT_ACTION, 411 + SAVE_DOCUMENT_ACTION, 412 + SELECT_ALL_ACTION, 413 + DELETE_SELECTION_ACTION, 414 + ZOOM_FIT_ACTION, 415 + OPEN_SHORTCUT_BAR_ACTION, 416 + QUIT_ACTION, 417 + ] 418 + .iter() 419 + .for_each(|a| assert!(actions.contains(a), "missing remappable: {a:?}")); 420 + } 421 + 422 + #[test] 423 + fn sketch_tool_actions_unbound_by_default() { 424 + let Ok(table) = compose_table(&HotkeyOverrides::default()) else { 425 + panic!("defaults compose"); 426 + }; 427 + let only_sketch = HotkeyScopes::from_outer_to_inner([HotkeyScope::Sketch]); 428 + let all = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global, HotkeyScope::Sketch]); 429 + [ 430 + TRIM_ACTION, 431 + EXTEND_ACTION, 432 + MIRROR_ACTION, 433 + TOGGLE_CONSTRUCTION_ACTION, 434 + SMART_DIMENSION_ACTION, 435 + ENTER_SKETCH_ACTION, 436 + ] 437 + .iter() 438 + .for_each(|action| { 439 + let bound_in_sketch = ['d', 't', 'e', 'm', 'g', 's'].iter().any(|c| { 440 + let chord = KeyChord::new(ch(*c), ModifierMask::NONE); 441 + table.dispatch(chord, &only_sketch) == Some(*action) 442 + || table.dispatch(chord, &all) == Some(*action) 443 + }); 444 + assert!( 445 + !bound_in_sketch, 446 + "{action:?} must ship unbound; SolidWorks stock has no default for it" 447 + ); 448 + }); 449 + } 450 + 451 + #[test] 452 + fn override_conflicting_with_default_is_rejected() { 453 + let mut overrides = HotkeyOverrides::default(); 454 + let save_chord = KeyChord::new(ch('s'), ModifierMask::CTRL); 455 + overrides.set(NEW_DOCUMENT_ACTION, save_chord); 456 + assert!(compose_table(&overrides).is_err()); 457 + } 458 + 459 + #[test] 460 + fn override_replaces_all_defaults() { 461 + let mut overrides = HotkeyOverrides::default(); 462 + let new_redo = KeyChord::new(ch('u'), ModifierMask::CTRL); 463 + overrides.set(REDO_ACTION, new_redo); 464 + let Ok(table) = compose_table(&overrides) else { 465 + panic!("non-conflicting override must compose"); 466 + }; 467 + let ctrl_y = KeyChord::new(ch('y'), ModifierMask::CTRL); 468 + let ctrl_shift_z = KeyChord::new(ch('z'), ModifierMask::CTRL.union(ModifierMask::SHIFT)); 469 + assert_eq!(table.dispatch(new_redo, &scopes()), Some(REDO_ACTION)); 470 + assert_eq!( 471 + table.dispatch(ctrl_y, &scopes()), 472 + None, 473 + "default Ctrl+Y must be dropped after override", 474 + ); 475 + assert_eq!( 476 + table.dispatch(ctrl_shift_z, &scopes()), 477 + None, 478 + "default Ctrl+Shift+Z must be dropped after override", 479 + ); 480 + } 481 + 482 + #[test] 483 + fn override_equal_to_default_replaces_other_defaults() { 484 + let mut overrides = HotkeyOverrides::default(); 485 + let ctrl_y = KeyChord::new(ch('y'), ModifierMask::CTRL); 486 + overrides.set(REDO_ACTION, ctrl_y); 487 + let Ok(table) = compose_table(&overrides) else { 488 + panic!("override matching a default must compose"); 489 + }; 490 + let ctrl_shift_z = KeyChord::new(ch('z'), ModifierMask::CTRL.union(ModifierMask::SHIFT)); 491 + assert_eq!(table.dispatch(ctrl_y, &scopes()), Some(REDO_ACTION)); 492 + assert_eq!( 493 + table.dispatch(ctrl_shift_z, &scopes()), 494 + None, 495 + "non-overridden default must also drop when any override is set", 496 + ); 497 + } 498 + 499 + #[test] 500 + fn sketch_override_shadowing_global_is_rejected() { 501 + let mut overrides = HotkeyOverrides::default(); 502 + let ctrl_s = KeyChord::new(ch('s'), ModifierMask::CTRL); 503 + overrides.set(SMART_DIMENSION_ACTION, ctrl_s); 504 + assert!( 505 + compose_table(&overrides).is_err(), 506 + "sketch-scope override must not shadow a Global-scope default", 507 + ); 508 + } 509 + }
+918 -108
crates/bone-app/src/main.rs
··· 4 4 use std::sync::Arc; 5 5 6 6 use bone_document::{ 7 - DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, Sketch, SketchDimension, 8 - SketchEdit, SketchEntity, SketchRelation, SolverError, UndoStack, 7 + DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, LineData, Sketch, 8 + SketchDimension, SketchEdit, SketchEntity, SketchRelation, SolverError, UndoStack, 9 9 }; 10 10 use bone_render::{ 11 11 Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, PickQuery, PickedItem, ··· 18 18 use bone_ui::frame::FrameCtx; 19 19 use bone_ui::gallery::{GALLERY_CANVAS, GalleryState, render}; 20 20 use bone_ui::hit_test::{HitFrame, HitState, resolve}; 21 - use bone_ui::hotkey::{ActionId, HotkeyBinding, HotkeyScope, HotkeyScopes, HotkeyTable, KeyChord}; 21 + use bone_ui::hotkey::{ActionId, HotkeyScope, HotkeyScopes, HotkeyTable}; 22 22 use bone_ui::input::{ 23 23 FrameInstant, InputSnapshot, KeyChar, KeyCode as UiKeyCode, KeyEvent as UiKeyEvent, 24 24 ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample, ··· 44 44 mod dimension_editor; 45 45 mod event; 46 46 mod file_menu; 47 + mod hotkeys; 47 48 mod native_picker; 48 49 mod redraw; 49 50 mod relation_tools; 50 51 mod selection; 51 52 mod settings; 52 53 mod shell; 54 + mod shortcut_bar; 53 55 mod sketch_mode; 54 56 mod smart_dimension; 55 57 mod snap; ··· 142 144 last_saved: Option<Document>, 143 145 pending_discard: Option<PendingDiscard>, 144 146 notification: Option<Notification>, 147 + shortcut_bar: Option<shortcut_bar::ShortcutBarState>, 145 148 } 146 149 147 150 #[derive(Clone, Debug, PartialEq)] ··· 155 158 || state.native_picker.is_some() 156 159 || state.pending_overwrite.is_some() 157 160 || state.pending_discard.is_some() 161 + || state.shortcut_bar.is_some() 158 162 } 159 163 160 164 #[derive(Copy, Clone, Debug, PartialEq, Eq)] ··· 678 682 Vec2::from_mm(pan_x, pan_y) 679 683 } 680 684 681 - enum KeyAction { 682 - Exit, 683 - Camera(Camera2), 684 - } 685 - 686 - fn keyboard_action(code: KeyCode, input: &InputState, state: &RenderState) -> Option<KeyAction> { 687 - match (code, input.modifiers.control_key()) { 688 - (KeyCode::KeyQ, true) => Some(KeyAction::Exit), 689 - _ => keyboard_camera(code, input, state).map(KeyAction::Camera), 690 - } 691 - } 692 - 693 685 fn keyboard_camera(code: KeyCode, input: &InputState, state: &RenderState) -> Option<Camera2> { 694 686 if input.modifiers.control_key() || input.modifiers.super_key() { 695 687 return None; ··· 702 694 KeyCode::ArrowRight => Some(pan_by_px(camera, -step, 0.0)), 703 695 KeyCode::ArrowUp => Some(pan_by_px(camera, 0.0, step)), 704 696 KeyCode::ArrowDown => Some(pan_by_px(camera, 0.0, -step)), 705 - KeyCode::KeyF => Some(zoom_fit(camera, &state.scene, state.viewport_rect)), 706 697 KeyCode::KeyZ => Some(zoom_about( 707 698 camera, 708 699 input.cursor_px, ··· 719 710 } 720 711 721 712 fn build_hotkey_table() -> HotkeyTable { 722 - let bindings = vec![ 723 - HotkeyBinding::new( 724 - KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE), 725 - HotkeyScope::Sketch, 726 - sketch_mode::EXIT_SKETCH_ACTION, 727 - ), 728 - HotkeyBinding::new( 729 - KeyChord::new(UiKeyCode::Char(KeyChar::from_char('z')), ModifierMask::CTRL), 730 - HotkeyScope::Global, 731 - sketch_mode::UNDO_ACTION, 732 - ), 733 - HotkeyBinding::new( 734 - KeyChord::new( 735 - UiKeyCode::Char(KeyChar::from_char('z')), 736 - ModifierMask::CTRL.union(ModifierMask::SHIFT), 737 - ), 738 - HotkeyScope::Global, 739 - sketch_mode::REDO_ACTION, 740 - ), 741 - HotkeyBinding::new( 742 - KeyChord::new(UiKeyCode::Char(KeyChar::from_char('y')), ModifierMask::CTRL), 743 - HotkeyScope::Global, 744 - sketch_mode::REDO_ACTION, 745 - ), 746 - ]; 747 - let Ok(table) = HotkeyTable::try_from_bindings(bindings) else { 748 - unreachable!("hotkey bindings are conflict-free"); 713 + let Ok(table) = hotkeys::compose_table(&hotkeys::HotkeyOverrides::default()) else { 714 + unreachable!("default hotkey bindings are conflict-free"); 749 715 }; 750 716 table 751 717 } ··· 761 727 fn next_mode( 762 728 mode: Mode, 763 729 frame: &shell::ShellFrame, 764 - actions: &[ActionId], 730 + exit_sketch_requested: bool, 765 731 plane_sketches: &BTreeMap<Plane, SketchId>, 766 732 ) -> Mode { 767 733 let plane_pick = frame ··· 771 737 let sketch_pick = frame.sketch_activated.filter(|_| !mode.is_sketch()); 772 738 let pick = sketch_pick.or(plane_pick); 773 739 let after_pick = pick.map_or(mode, Mode::enter_sketch); 774 - let escape = actions.contains(&sketch_mode::EXIT_SKETCH_ACTION); 775 - let after_escape = if escape { 740 + let after_escape = if exit_sketch_requested { 776 741 cancel_pending_or_exit(after_pick) 777 742 } else { 778 743 after_pick ··· 859 824 let viewport_rect = empty_rect(); 860 825 let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 861 826 unreachable!("UNDO_CAPACITY constant is non-zero"); 827 + }; 828 + let loaded_settings = settings::load(); 829 + let initial_hotkeys = match hotkeys::compose_table(&loaded_settings.hotkey_overrides) { 830 + Ok(table) => table, 831 + Err(e) => { 832 + tracing::warn!(error = %e, "stored hotkey overrides conflict, using defaults"); 833 + build_hotkey_table() 834 + } 862 835 }; 863 836 window.request_redraw(); 864 837 self.redraw = Some(redraw::Scheduler::new(window)); ··· 881 854 mode: Mode::Idle, 882 855 focus: FocusManager::new(), 883 856 hit_state: HitState::new(), 884 - hotkeys: build_hotkey_table(), 857 + hotkeys: initial_hotkeys, 885 858 strings, 886 859 viewport_rect, 887 860 undo: UndoStack::with_capacity(undo_capacity), 888 861 selection: Selection::default(), 889 - settings: settings::Settings::default(), 862 + settings: loaded_settings, 890 863 dim_editor: DimensionEditorState::default(), 891 864 dim_editor_bounds: None, 892 865 pending_exit: false, ··· 898 871 last_saved: Some(last_saved_baseline), 899 872 pending_discard: None, 900 873 notification: None, 874 + shortcut_bar: None, 901 875 }); 902 876 } 903 877 ··· 1006 980 .dim_editor_bounds 1007 981 .is_some_and(|r| self.input.cursor_in(r)); 1008 982 let dim_active = dim_flow_active(&state.mode); 1009 - self.input.left_pan = 1010 - !modal && in_viewport && !over_dim_editor && !dim_active; 983 + self.input.left_pan = !modal && in_viewport && !over_dim_editor && !dim_active; 1011 984 self.input.pending_pressed = 1012 985 self.input.pending_pressed.with(PointerButton::Primary); 1013 986 if !modal ··· 1048 1021 } 1049 1022 MouseButton::Middle => { 1050 1023 if btn_state == ElementState::Pressed { 1051 - self.input.middle_pan = 1052 - !modal && self.input.cursor_in(state.viewport_rect); 1024 + self.input.middle_pan = !modal && self.input.cursor_in(state.viewport_rect); 1053 1025 self.input.pending_pressed = 1054 1026 self.input.pending_pressed.with(PointerButton::Middle); 1055 1027 } else { ··· 1103 1075 if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() { 1104 1076 state.notification = None; 1105 1077 } 1106 - if let Some(code) = physical_code { 1107 - match keyboard_action(code, &self.input, state) { 1108 - Some(KeyAction::Exit) => event_loop.exit(), 1109 - Some(KeyAction::Camera(next)) if !suppress_camera => { 1110 - state.camera = next; 1111 - } 1112 - Some(KeyAction::Camera(_)) | None => {} 1113 - } 1078 + if let Some(code) = physical_code 1079 + && !suppress_camera 1080 + && let Some(next) = keyboard_camera(code, &self.input, state) 1081 + { 1082 + state.camera = next; 1114 1083 } 1084 + let _ = event_loop; 1115 1085 } 1116 1086 1117 1087 fn dispatch_redraw(&mut self, event_loop: &ActiveEventLoop) { ··· 1137 1107 .native_picker 1138 1108 .is_some() 1139 1109 .then(|| now + std::time::Duration::from_millis(40)); 1140 - let rename_deadline = state.shell.state.feature_tree.pending_rename.map(|pending| { 1141 - let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration(); 1142 - let slack = std::time::Duration::from_millis(8); 1143 - input.start + pending.at.duration() + window + slack 1144 - }); 1110 + let rename_deadline = state 1111 + .shell 1112 + .state 1113 + .feature_tree 1114 + .pending_rename 1115 + .map(|pending| { 1116 + let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration(); 1117 + let slack = std::time::Duration::from_millis(8); 1118 + input.start + pending.at.duration() + window + slack 1119 + }); 1145 1120 [native_poll, rename_deadline].into_iter().flatten().min() 1146 1121 } 1147 1122 ··· 1179 1154 overwrite: overwrite_outcome, 1180 1155 discard: discard_outcome, 1181 1156 notification: notification_outcome, 1157 + shortcut_bar: shortcut_bar_outcome, 1182 1158 } = run_frame_ui( 1183 1159 state, 1184 1160 theme, ··· 1197 1173 overwrite_outcome.as_ref(), 1198 1174 discard_outcome.as_ref(), 1199 1175 notification_outcome.as_ref(), 1176 + shortcut_bar_outcome.as_ref(), 1200 1177 ); 1178 + apply_shortcut_bar_outcome(state, shortcut_bar_outcome.as_ref()); 1201 1179 if let Some(cmd) = picker_outcome.and_then(|o| o.command) { 1202 1180 apply_picker_command(state, cmd); 1203 1181 } ··· 1226 1204 (false, true) => {} 1227 1205 } 1228 1206 } 1207 + let exit_sketch_requested = hotkey_actions.contains(&sketch_mode::EXIT_SKETCH_ACTION); 1229 1208 state.mode = next_mode( 1230 1209 core::mem::take(&mut state.mode), 1231 1210 &frame, 1232 - &hotkey_actions, 1211 + exit_sketch_requested, 1233 1212 &state.plane_sketches, 1234 1213 ); 1235 1214 apply_dimension_outcome(state, dim_outcome); ··· 1240 1219 _ => frame.dimension_edit, 1241 1220 }; 1242 1221 apply_dimension_edit(state, dimension_edit); 1243 - apply_undo_actions(state, &hotkey_actions); 1222 + let cursor_layout = input_state.cursor_px.map(physical_to_layout_pos); 1223 + apply_hotkey_actions(state, &hotkey_actions, cursor_layout); 1244 1224 apply_menu_action(state, frame.menu_action); 1245 1225 apply_settings_change(state, frame.settings_change); 1246 1226 apply_relation_action(state, frame.activated_relation); ··· 1388 1368 ChromeLayer { chrome, glyphs } 1389 1369 } 1390 1370 1391 - fn apply_undo_actions(state: &mut RenderState, actions: &[ActionId]) { 1392 - if actions.contains(&sketch_mode::UNDO_ACTION) && state.undo.undo(&mut state.document) { 1393 - refresh_active_scene(state); 1371 + fn apply_hotkey_actions( 1372 + state: &mut RenderState, 1373 + actions: &[ActionId], 1374 + cursor_layout: Option<LayoutPos>, 1375 + ) { 1376 + actions 1377 + .iter() 1378 + .filter_map(|a| hotkeys::command_for_action(*a)) 1379 + .for_each(|cmd| dispatch_hotkey_command(state, cmd, cursor_layout)); 1380 + } 1381 + 1382 + fn dispatch_hotkey_command( 1383 + state: &mut RenderState, 1384 + cmd: hotkeys::HotkeyCommand, 1385 + cursor_layout: Option<LayoutPos>, 1386 + ) { 1387 + use hotkeys::HotkeyCommand as C; 1388 + match cmd { 1389 + C::Undo => { 1390 + if state.undo.undo(&mut state.document) { 1391 + refresh_active_scene(state); 1392 + } 1393 + } 1394 + C::Redo => { 1395 + if state.undo.redo(&mut state.document) { 1396 + refresh_active_scene(state); 1397 + } 1398 + } 1399 + C::NewDocument => apply_menu_action(state, Some(shell::MenuAction::NewDocument)), 1400 + C::OpenDocument => apply_menu_action(state, Some(shell::MenuAction::OpenDocument)), 1401 + C::SaveDocument => apply_menu_action(state, Some(shell::MenuAction::SaveDocument)), 1402 + C::ZoomFit => apply_menu_action(state, Some(shell::MenuAction::ZoomFit)), 1403 + C::Quit => { 1404 + state.pending_exit = true; 1405 + } 1406 + C::OpenShortcutBar => { 1407 + if state.shortcut_bar.is_none() { 1408 + let anchor = cursor_layout.unwrap_or(LayoutPos::ORIGIN); 1409 + state.shortcut_bar = Some(shortcut_bar::ShortcutBarState { anchor }); 1410 + } 1411 + } 1412 + C::ToggleConstruction => apply_construction_toggle(state), 1413 + C::Mirror => apply_mirror(state), 1414 + C::SelectAll 1415 + | C::DeleteSelection 1416 + | C::EnterSketch 1417 + | C::SmartDimension 1418 + | C::Trim 1419 + | C::Extend => notify_stub(state, hotkeys::label_for_command(cmd)), 1420 + } 1421 + } 1422 + 1423 + fn apply_construction_toggle(state: &mut RenderState) { 1424 + let Mode::Sketch { sketch_id, .. } = state.mode else { 1425 + return; 1426 + }; 1427 + let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec(); 1428 + if entity_ids.is_empty() { 1429 + return; 1430 + } 1431 + let Some(sketch) = state.document.sketch(sketch_id) else { 1432 + return; 1433 + }; 1434 + let pivot = entity_ids 1435 + .iter() 1436 + .find_map(|id| match sketch.entities().get(*id)? { 1437 + SketchEntity::Line(l) => Some(l.for_construction()), 1438 + SketchEntity::Arc(a) => Some(a.for_construction()), 1439 + SketchEntity::Circle(c) => Some(c.for_construction()), 1440 + SketchEntity::Point(_) => None, 1441 + }); 1442 + let Some(current) = pivot else { 1443 + return; 1444 + }; 1445 + let target = !current; 1446 + let snapshot = state.document.clone(); 1447 + let result = 1448 + entity_ids 1449 + .iter() 1450 + .try_fold(sketch.clone(), |acc, id| match acc.entities().get(*id) { 1451 + Some(SketchEntity::Point(_)) | None => Ok(acc), 1452 + Some(_) => acc 1453 + .apply(bone_document::SketchEdit::SetConstruction { 1454 + id: *id, 1455 + for_construction: target, 1456 + }) 1457 + .map(|(s, _)| s), 1458 + }); 1459 + match result { 1460 + Ok(next) => { 1461 + state.undo.record(snapshot); 1462 + state.document.replace_sketch(sketch_id, next); 1463 + refresh_active_scene(state); 1464 + } 1465 + Err(e) => tracing::warn!(error = %e, "construction toggle failed"), 1466 + } 1467 + } 1468 + 1469 + fn apply_mirror(state: &mut RenderState) { 1470 + let Mode::Sketch { sketch_id, .. } = state.mode else { 1471 + return; 1472 + }; 1473 + let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec(); 1474 + if entity_ids.is_empty() { 1475 + return; 1476 + } 1477 + let Some(sketch) = state.document.sketch(sketch_id) else { 1478 + return; 1479 + }; 1480 + let axis_lines: Vec<(bone_types::SketchEntityId, LineData)> = entity_ids 1481 + .iter() 1482 + .filter_map(|id| match sketch.entities().get(*id)? { 1483 + SketchEntity::Line(l) => Some((*id, *l)), 1484 + _ => None, 1485 + }) 1486 + .collect(); 1487 + let [(axis_id, axis_line)] = axis_lines.as_slice() else { 1488 + notify_mirror_hint(state); 1489 + return; 1490 + }; 1491 + let axis_id = *axis_id; 1492 + let Some(pa) = lookup_point(sketch, axis_line.a()) else { 1493 + return; 1494 + }; 1495 + let Some(pb) = lookup_point(sketch, axis_line.b()) else { 1496 + return; 1497 + }; 1498 + let axis = MirrorAxis::from_points(pa, pb); 1499 + if axis.is_degenerate() { 1500 + notify_mirror_hint(state); 1501 + return; 1502 + } 1503 + let source_ids: std::collections::BTreeSet<bone_types::SketchEntityId> = entity_ids 1504 + .iter() 1505 + .copied() 1506 + .filter(|id| *id != axis_id) 1507 + .collect(); 1508 + if source_ids.is_empty() { 1509 + notify_mirror_hint(state); 1510 + return; 1511 + } 1512 + let snapshot = state.document.clone(); 1513 + let result = mirror_targets(sketch.clone(), &source_ids, axis_id, &axis); 1514 + match result { 1515 + Ok(next) => { 1516 + state.undo.record(snapshot); 1517 + state.document.replace_sketch(sketch_id, next); 1518 + state.selection = Selection::default(); 1519 + refresh_active_scene(state); 1520 + } 1521 + Err(e) => tracing::warn!(error = %e, "mirror failed"), 1394 1522 } 1395 - if actions.contains(&sketch_mode::REDO_ACTION) && state.undo.redo(&mut state.document) { 1396 - refresh_active_scene(state); 1523 + } 1524 + 1525 + fn notify_mirror_hint(state: &mut RenderState) { 1526 + state.notification = Some(Notification { 1527 + kind: NotificationKind::Info, 1528 + headline: strings::HOTKEY_LABEL_MIRROR, 1529 + detail: Some( 1530 + state 1531 + .strings 1532 + .resolve(strings::NOTIFY_MIRROR_SELECTION_HINT) 1533 + .to_owned(), 1534 + ), 1535 + }); 1536 + } 1537 + 1538 + #[derive(Copy, Clone, Debug)] 1539 + struct MirrorAxis { 1540 + anchor_x: f64, 1541 + anchor_y: f64, 1542 + direction_x: f64, 1543 + direction_y: f64, 1544 + length_sq: f64, 1545 + } 1546 + 1547 + impl MirrorAxis { 1548 + fn from_points(a: Point2, b: Point2) -> Self { 1549 + let (ax, ay) = a.coords_mm(); 1550 + let (bx, by) = b.coords_mm(); 1551 + let dx = bx - ax; 1552 + let dy = by - ay; 1553 + Self { 1554 + anchor_x: ax, 1555 + anchor_y: ay, 1556 + direction_x: dx, 1557 + direction_y: dy, 1558 + length_sq: dx * dx + dy * dy, 1559 + } 1560 + } 1561 + 1562 + fn is_degenerate(self) -> bool { 1563 + !self.length_sq.is_finite() || self.length_sq <= f64::EPSILON 1564 + } 1565 + 1566 + fn reflect(self, p: Point2) -> Point2 { 1567 + let (px, py) = p.coords_mm(); 1568 + let vx = px - self.anchor_x; 1569 + let vy = py - self.anchor_y; 1570 + let t = (vx * self.direction_x + vy * self.direction_y) / self.length_sq; 1571 + let foot_x = self.anchor_x + t * self.direction_x; 1572 + let foot_y = self.anchor_y + t * self.direction_y; 1573 + Point2::from_mm(2.0 * foot_x - px, 2.0 * foot_y - py) 1574 + } 1575 + 1576 + fn is_on_axis(self, p: Point2) -> bool { 1577 + let (px, py) = p.coords_mm(); 1578 + let vx = px - self.anchor_x; 1579 + let vy = py - self.anchor_y; 1580 + let cross = vx * self.direction_y - vy * self.direction_x; 1581 + let perp_dist_sq = cross * cross / self.length_sq; 1582 + perp_dist_sq < ON_AXIS_TOLERANCE_MM_SQ 1583 + } 1584 + } 1585 + 1586 + const ON_AXIS_TOLERANCE_MM_SQ: f64 = 1e-12; 1587 + 1588 + struct MirrorBuilder { 1589 + sketch: Sketch, 1590 + point_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 1591 + entity_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 1592 + } 1593 + 1594 + impl MirrorBuilder { 1595 + fn new(sketch: Sketch) -> Self { 1596 + Self { 1597 + sketch, 1598 + point_map: std::collections::BTreeMap::new(), 1599 + entity_map: std::collections::BTreeMap::new(), 1600 + } 1601 + } 1602 + 1603 + fn mirror_point( 1604 + &mut self, 1605 + id: bone_types::SketchEntityId, 1606 + axis: &MirrorAxis, 1607 + ) -> Result<bone_types::SketchEntityId, bone_document::SketchEditError> { 1608 + if let Some(&existing) = self.point_map.get(&id) { 1609 + return Ok(existing); 1610 + } 1611 + let pos = require_point(&self.sketch, id)?; 1612 + if axis.is_on_axis(pos) { 1613 + self.point_map.insert(id, id); 1614 + self.entity_map.insert(id, id); 1615 + return Ok(id); 1616 + } 1617 + let (next, new_id) = add_point(self.sketch.clone(), axis.reflect(pos))?; 1618 + self.sketch = next; 1619 + self.point_map.insert(id, new_id); 1620 + self.entity_map.insert(id, new_id); 1621 + Ok(new_id) 1622 + } 1623 + 1624 + fn mirror_entity( 1625 + &mut self, 1626 + id: bone_types::SketchEntityId, 1627 + axis: &MirrorAxis, 1628 + ) -> Result<(), bone_document::SketchEditError> { 1629 + if self.entity_map.contains_key(&id) { 1630 + return Ok(()); 1631 + } 1632 + let entity = self 1633 + .sketch 1634 + .entities() 1635 + .get(id) 1636 + .copied() 1637 + .ok_or(bone_document::SketchEditError::EntityNotFound(id))?; 1638 + match entity { 1639 + SketchEntity::Point(_) => { 1640 + self.mirror_point(id, axis)?; 1641 + } 1642 + SketchEntity::Line(l) => { 1643 + let new_a = self.mirror_point(l.a(), axis)?; 1644 + let new_b = self.mirror_point(l.b(), axis)?; 1645 + let (next, outcome) = 1646 + self.sketch 1647 + .clone() 1648 + .apply(bone_document::SketchEdit::AddEntity(SketchEntity::line( 1649 + new_a, 1650 + new_b, 1651 + l.for_construction(), 1652 + )))?; 1653 + let EditOutcome::Entity(new_id) = outcome else { 1654 + unreachable!("AddEntity yields Entity outcome") 1655 + }; 1656 + self.sketch = next; 1657 + self.entity_map.insert(id, new_id); 1658 + } 1659 + SketchEntity::Arc(a) => { 1660 + let new_center = self.mirror_point(a.center(), axis)?; 1661 + let new_start = self.mirror_point(a.start(), axis)?; 1662 + let new_end = self.mirror_point(a.end(), axis)?; 1663 + let (next, outcome) = 1664 + self.sketch 1665 + .clone() 1666 + .apply(bone_document::SketchEdit::AddEntity(SketchEntity::arc( 1667 + new_center, 1668 + new_end, 1669 + new_start, 1670 + a.for_construction(), 1671 + )))?; 1672 + let EditOutcome::Entity(new_id) = outcome else { 1673 + unreachable!("AddEntity yields Entity outcome") 1674 + }; 1675 + self.sketch = next; 1676 + self.entity_map.insert(id, new_id); 1677 + } 1678 + SketchEntity::Circle(c) => { 1679 + let new_center = self.mirror_point(c.center(), axis)?; 1680 + let (next, outcome) = 1681 + self.sketch 1682 + .clone() 1683 + .apply(bone_document::SketchEdit::AddEntity(SketchEntity::circle( 1684 + new_center, 1685 + c.radius(), 1686 + c.for_construction(), 1687 + )))?; 1688 + let EditOutcome::Entity(new_id) = outcome else { 1689 + unreachable!("AddEntity yields Entity outcome") 1690 + }; 1691 + self.sketch = next; 1692 + self.entity_map.insert(id, new_id); 1693 + } 1694 + } 1695 + Ok(()) 1696 + } 1697 + } 1698 + 1699 + fn mirror_targets( 1700 + sketch: Sketch, 1701 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 1702 + axis_id: bone_types::SketchEntityId, 1703 + axis: &MirrorAxis, 1704 + ) -> Result<Sketch, bone_document::SketchEditError> { 1705 + let mut builder = MirrorBuilder::new(sketch); 1706 + source_ids 1707 + .iter() 1708 + .try_for_each(|id| builder.mirror_entity(*id, axis))?; 1709 + builder.entity_map.insert(axis_id, axis_id); 1710 + builder.sketch = symmetric_relations_for_pairs(builder.sketch, &builder.point_map, axis_id)?; 1711 + builder.sketch = 1712 + copy_relations(builder.sketch, source_ids, axis_id, &builder.entity_map)?; 1713 + builder.sketch = 1714 + copy_dimensions(builder.sketch, source_ids, axis_id, &builder.entity_map)?; 1715 + Ok(builder.sketch) 1716 + } 1717 + 1718 + fn symmetric_relations_for_pairs( 1719 + sketch: Sketch, 1720 + point_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 1721 + axis_id: bone_types::SketchEntityId, 1722 + ) -> Result<Sketch, bone_document::SketchEditError> { 1723 + point_map 1724 + .iter() 1725 + .filter(|(source, mirrored)| source != mirrored) 1726 + .try_fold(sketch, |acc, (&source, &mirrored)| { 1727 + let (next, _) = acc.apply(bone_document::SketchEdit::AddRelation( 1728 + SketchRelation::Symmetric { 1729 + a: source, 1730 + b: mirrored, 1731 + axis: axis_id, 1732 + }, 1733 + ))?; 1734 + Ok(next) 1735 + }) 1736 + } 1737 + 1738 + fn copy_relations( 1739 + sketch: Sketch, 1740 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 1741 + axis_id: bone_types::SketchEntityId, 1742 + entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 1743 + ) -> Result<Sketch, bone_document::SketchEditError> { 1744 + let relations: Vec<SketchRelation> = sketch 1745 + .relations() 1746 + .iter() 1747 + .map(|(_, r)| *r) 1748 + .filter(|r| relation_is_mirrorable(r, source_ids, axis_id)) 1749 + .filter_map(|r| remap_relation(r, entity_map)) 1750 + .collect(); 1751 + relations.into_iter().try_fold(sketch, |acc, rel| { 1752 + match acc 1753 + .clone() 1754 + .apply(bone_document::SketchEdit::AddRelation(rel)) 1755 + { 1756 + Ok((next, _)) => Ok(next), 1757 + Err(e) => { 1758 + tracing::warn!(error = %e, relation = ?rel, "mirror: skipped relation"); 1759 + Ok(acc) 1760 + } 1761 + } 1762 + }) 1763 + } 1764 + 1765 + fn relation_is_mirrorable( 1766 + rel: &SketchRelation, 1767 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 1768 + axis_id: bone_types::SketchEntityId, 1769 + ) -> bool { 1770 + let refs: Vec<_> = rel.references().into_iter().collect(); 1771 + let touches_source = refs.iter().any(|id| source_ids.contains(id)); 1772 + let all_known = refs 1773 + .iter() 1774 + .all(|id| source_ids.contains(id) || *id == axis_id); 1775 + touches_source && all_known 1776 + } 1777 + 1778 + fn remap_relation( 1779 + rel: SketchRelation, 1780 + entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 1781 + ) -> Option<SketchRelation> { 1782 + let get = |id| entity_map.get(&id).copied(); 1783 + match rel { 1784 + SketchRelation::Coincident(a, b) => Some(SketchRelation::Coincident(get(a)?, get(b)?)), 1785 + SketchRelation::Horizontal(a) => Some(SketchRelation::Horizontal(get(a)?)), 1786 + SketchRelation::Vertical(a) => Some(SketchRelation::Vertical(get(a)?)), 1787 + SketchRelation::Parallel(a, b) => Some(SketchRelation::Parallel(get(a)?, get(b)?)), 1788 + SketchRelation::Perpendicular(a, b) => { 1789 + Some(SketchRelation::Perpendicular(get(a)?, get(b)?)) 1790 + } 1791 + SketchRelation::Tangent(a, b) => Some(SketchRelation::Tangent(get(a)?, get(b)?)), 1792 + SketchRelation::Equal(a, b) => Some(SketchRelation::Equal(get(a)?, get(b)?)), 1793 + SketchRelation::Concentric(a, b) => Some(SketchRelation::Concentric(get(a)?, get(b)?)), 1794 + SketchRelation::Midpoint { point, line } => Some(SketchRelation::Midpoint { 1795 + point: get(point)?, 1796 + line: get(line)?, 1797 + }), 1798 + SketchRelation::Symmetric { a, b, axis } => Some(SketchRelation::Symmetric { 1799 + a: get(a)?, 1800 + b: get(b)?, 1801 + axis: get(axis)?, 1802 + }), 1803 + SketchRelation::Fix(a) => Some(SketchRelation::Fix(get(a)?)), 1804 + } 1805 + } 1806 + 1807 + fn copy_dimensions( 1808 + sketch: Sketch, 1809 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 1810 + axis_id: bone_types::SketchEntityId, 1811 + entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 1812 + ) -> Result<Sketch, bone_document::SketchEditError> { 1813 + let dims: Vec<SketchDimension> = sketch 1814 + .dimensions() 1815 + .iter() 1816 + .map(|(_, d)| *d) 1817 + .filter(|d| dimension_is_mirrorable(d, source_ids, axis_id)) 1818 + .filter_map(|d| remap_dimension(d, entity_map)) 1819 + .collect(); 1820 + dims.into_iter().try_fold(sketch, |acc, dim| { 1821 + match acc 1822 + .clone() 1823 + .apply(bone_document::SketchEdit::AddDimension(dim)) 1824 + { 1825 + Ok((next, _)) => Ok(next), 1826 + Err(e) => { 1827 + tracing::warn!(error = %e, dimension = ?dim, "mirror: skipped dimension"); 1828 + Ok(acc) 1829 + } 1830 + } 1831 + }) 1832 + } 1833 + 1834 + fn dimension_is_mirrorable( 1835 + dim: &SketchDimension, 1836 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 1837 + axis_id: bone_types::SketchEntityId, 1838 + ) -> bool { 1839 + let refs: Vec<_> = dim.references().into_iter().collect(); 1840 + let touches_source = refs.iter().any(|id| source_ids.contains(id)); 1841 + let all_known = refs 1842 + .iter() 1843 + .all(|id| source_ids.contains(id) || *id == axis_id); 1844 + touches_source && all_known 1845 + } 1846 + 1847 + fn remap_dimension( 1848 + dim: SketchDimension, 1849 + entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 1850 + ) -> Option<SketchDimension> { 1851 + let get = |id| entity_map.get(&id).copied(); 1852 + match dim { 1853 + SketchDimension::Linear { a, b, value, kind } => Some(SketchDimension::Linear { 1854 + a: get(a)?, 1855 + b: get(b)?, 1856 + value, 1857 + kind, 1858 + }), 1859 + SketchDimension::Radius { 1860 + target, 1861 + value, 1862 + kind, 1863 + } => Some(SketchDimension::Radius { 1864 + target: get(target)?, 1865 + value, 1866 + kind, 1867 + }), 1868 + SketchDimension::Diameter { 1869 + target, 1870 + value, 1871 + kind, 1872 + } => Some(SketchDimension::Diameter { 1873 + target: get(target)?, 1874 + value, 1875 + kind, 1876 + }), 1877 + SketchDimension::Angular { a, b, value, kind } => Some(SketchDimension::Angular { 1878 + a: get(a)?, 1879 + b: get(b)?, 1880 + value, 1881 + kind, 1882 + }), 1883 + } 1884 + } 1885 + 1886 + fn add_point( 1887 + sketch: Sketch, 1888 + at: Point2, 1889 + ) -> Result<(Sketch, bone_types::SketchEntityId), bone_document::SketchEditError> { 1890 + let (next, outcome) = sketch.apply(bone_document::SketchEdit::AddEntity( 1891 + SketchEntity::point(at), 1892 + ))?; 1893 + let EditOutcome::Entity(id) = outcome else { 1894 + unreachable!("AddEntity must yield Entity outcome") 1895 + }; 1896 + Ok((next, id)) 1897 + } 1898 + 1899 + fn require_point( 1900 + sketch: &Sketch, 1901 + id: bone_types::SketchEntityId, 1902 + ) -> Result<Point2, bone_document::SketchEditError> { 1903 + match sketch 1904 + .entities() 1905 + .get(id) 1906 + .ok_or(bone_document::SketchEditError::EntityNotFound(id))? 1907 + { 1908 + SketchEntity::Point(p) => Ok(p.at()), 1909 + _ => Err(bone_document::SketchEditError::ExpectedPoint(id)), 1910 + } 1911 + } 1912 + 1913 + fn lookup_point(sketch: &Sketch, id: bone_types::SketchEntityId) -> Option<Point2> { 1914 + match sketch.entities().get(id)? { 1915 + SketchEntity::Point(p) => Some(p.at()), 1916 + _ => None, 1397 1917 } 1398 1918 } 1399 1919 ··· 1428 1948 overwrite_outcome: Option<&OverwriteOutcome>, 1429 1949 discard_outcome: Option<&DiscardOutcome>, 1430 1950 notification_outcome: Option<&NotificationOutcome>, 1951 + shortcut_bar_outcome: Option<&shortcut_bar::ShortcutBarOutcome>, 1431 1952 ) -> Option<LayoutRect> { 1432 1953 let dim_closing = matches!( 1433 1954 dim_outcome.map(|o| &o.action), ··· 1474 1995 if let Some(notification) = notification_outcome { 1475 1996 overlay.extend(notification.paints.iter().cloned()); 1476 1997 } 1998 + let bar_closing = shortcut_bar_outcome.is_some_and(|o| o.dismissed || o.activated.is_some()); 1999 + extend_when_open( 2000 + overlay, 2001 + shortcut_bar_outcome.map(|o| o.paints.as_slice()), 2002 + bar_closing, 2003 + ); 1477 2004 if dim_closing { 1478 2005 None 1479 2006 } else { ··· 1502 2029 overwrite: Option<OverwriteOutcome>, 1503 2030 discard: Option<DiscardOutcome>, 1504 2031 notification: Option<NotificationOutcome>, 2032 + shortcut_bar: Option<shortcut_bar::ShortcutBarOutcome>, 2033 + } 2034 + 2035 + fn strip_plain_letter_chords(input: &mut InputSnapshot) { 2036 + input.keys_pressed.retain(|event| { 2037 + !matches!(event.code, bone_ui::input::KeyCode::Char(_)) 2038 + || event.modifiers != ModifierMask::NONE 2039 + }); 1505 2040 } 1506 2041 1507 2042 #[allow( ··· 1534 2069 &state.document, 1535 2070 &state.mode, 1536 2071 &state.selection, 1537 - state.settings, 2072 + &state.settings, 1538 2073 layout_size, 1539 2074 cursor_world, 1540 2075 ); ··· 1574 2109 .notification 1575 2110 .as_ref() 1576 2111 .map(|notification| render_notification_toast(&mut ctx, layout_size, notification)); 1577 - let any_modal_open = conflict_outcome.is_some() 2112 + let is_sketch = state.mode.is_sketch(); 2113 + let shortcut_bar_outcome = state 2114 + .shortcut_bar 2115 + .map(|bar_state| shortcut_bar::render(&mut ctx, bar_state, layout_size, is_sketch)); 2116 + let any_modal_open = state.shell.state.keyboard_dialog_open 2117 + || conflict_outcome.is_some() 1578 2118 || dim_outcome.is_some() 1579 2119 || picker_outcome.is_some() 1580 2120 || overwrite_outcome.is_some() 1581 - || discard_outcome.is_some(); 1582 - let actions = if any_modal_open { 2121 + || discard_outcome.is_some() 2122 + || shortcut_bar_outcome.is_some(); 2123 + if !any_modal_open && ctx.focus.is_text_input_focused() { 2124 + strip_plain_letter_chords(ctx.input); 2125 + } 2126 + let mut actions = if any_modal_open { 1583 2127 Vec::new() 1584 2128 } else { 1585 2129 ctx.dispatch_hotkeys(scopes) 1586 2130 }; 2131 + if let Some(activated) = shortcut_bar_outcome.as_ref().and_then(|o| o.activated) { 2132 + actions.push(activated); 2133 + } 1587 2134 FrameOutcomes { 1588 2135 frame, 1589 2136 hotkey_actions: actions, ··· 1593 2140 overwrite: overwrite_outcome, 1594 2141 discard: discard_outcome, 1595 2142 notification: notification_outcome, 2143 + shortcut_bar: shortcut_bar_outcome, 1596 2144 } 1597 2145 } 1598 2146 ··· 2103 2651 Some(shell::MenuAction::OpenSettings) => { 2104 2652 state.shell.state.settings_dialog_open = true; 2105 2653 } 2654 + Some(shell::MenuAction::OpenKeyboardCustomize) => { 2655 + state.shell.state.keyboard_dialog_open = true; 2656 + } 2106 2657 Some(shell::MenuAction::NewDocument) => { 2107 2658 request_new_document(state); 2108 2659 } ··· 2374 2925 }); 2375 2926 } 2376 2927 2928 + fn notify_stub(state: &mut RenderState, label: bone_ui::strings::StringKey) { 2929 + let detail = state.strings.resolve(label).to_owned(); 2930 + tracing::info!(label = %detail, "hotkey action stub"); 2931 + state.notification = Some(Notification { 2932 + kind: NotificationKind::Info, 2933 + headline: strings::NOTIFY_COMING_SOON, 2934 + detail: Some(detail), 2935 + }); 2936 + } 2937 + 2938 + fn apply_shortcut_bar_outcome( 2939 + state: &mut RenderState, 2940 + outcome: Option<&shortcut_bar::ShortcutBarOutcome>, 2941 + ) { 2942 + let Some(outcome) = outcome else { return }; 2943 + if outcome.dismissed || outcome.activated.is_some() { 2944 + state.shortcut_bar = None; 2945 + } 2946 + } 2947 + 2377 2948 fn install_loaded_document( 2378 2949 state: &mut RenderState, 2379 2950 document: Document, ··· 2415 2986 .collect() 2416 2987 } 2417 2988 2989 + fn persist_settings(state: &RenderState) { 2990 + settings::save(&state.settings); 2991 + } 2992 + 2418 2993 fn apply_settings_change(state: &mut RenderState, change: Option<settings::Settings>) { 2419 - if let Some(next) = change { 2994 + let Some(next) = change else { return }; 2995 + let overrides_changed = next.hotkey_overrides != state.settings.hotkey_overrides; 2996 + if !overrides_changed { 2420 2997 state.settings = next; 2998 + persist_settings(state); 2999 + return; 3000 + } 3001 + match hotkeys::compose_table(&next.hotkey_overrides) { 3002 + Ok(table) => { 3003 + state.hotkeys = table; 3004 + state.settings = next; 3005 + persist_settings(state); 3006 + } 3007 + Err(error) => { 3008 + tracing::warn!(?error, "hotkey override rejected, retaining prior settings"); 3009 + state.shell.state.hotkey_capture.clear(); 3010 + notify_error(state, strings::NOTIFY_HOTKEY_CONFLICT, format!("{error}")); 3011 + } 2421 3012 } 2422 3013 } 2423 3014 ··· 2604 3195 mod tests { 2605 3196 use super::*; 2606 3197 use crate::sketch_mode::SketchSession; 3198 + use bone_ui::hotkey::KeyChord; 3199 + use bone_ui::input::{KeyChar, KeyCode, KeyEvent, NamedKey}; 3200 + 3201 + #[test] 3202 + fn strip_plain_letter_chords_removes_chars_with_no_modifiers() { 3203 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 3204 + let plain_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::NONE); 3205 + let ctrl_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); 3206 + let esc = KeyEvent::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 3207 + input.keys_pressed = vec![plain_s, ctrl_s, esc]; 3208 + strip_plain_letter_chords(&mut input); 3209 + assert_eq!( 3210 + input.keys_pressed, 3211 + vec![ctrl_s, esc], 3212 + "strip removes plain letters, keeps modified chords and named keys" 3213 + ); 3214 + } 3215 + 3216 + #[test] 3217 + fn strip_plain_letter_chords_is_idempotent_when_no_chars() { 3218 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 3219 + let enter = KeyEvent::new(KeyCode::Named(NamedKey::Enter), ModifierMask::NONE); 3220 + input.keys_pressed = vec![enter]; 3221 + strip_plain_letter_chords(&mut input); 3222 + assert_eq!(input.keys_pressed, vec![enter]); 3223 + } 2607 3224 2608 3225 #[test] 2609 3226 fn cursor_to_world_at_window_center_equals_camera_pan() { ··· 2670 3287 plane_picked: Some(Plane::Xy), 2671 3288 ..empty_frame() 2672 3289 }; 2673 - let next = next_mode(Mode::Idle, &frame, &[], &xy_only()); 3290 + let next = next_mode(Mode::Idle, &frame, false, &xy_only()); 2674 3291 assert_eq!(next, Mode::enter_sketch(SketchId::default())); 2675 3292 } 2676 3293 ··· 2680 3297 plane_picked: Some(Plane::Yz), 2681 3298 ..empty_frame() 2682 3299 }; 2683 - let next = next_mode(Mode::Idle, &frame, &[], &xy_only()); 3300 + let next = next_mode(Mode::Idle, &frame, false, &xy_only()); 2684 3301 assert_eq!(next, Mode::Idle); 2685 3302 } 2686 3303 ··· 2691 3308 plane_picked: Some(Plane::Xy), 2692 3309 ..empty_frame() 2693 3310 }; 2694 - assert_eq!(next_mode(prev.clone(), &frame, &[], &xy_only()), prev); 3311 + assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); 2695 3312 } 2696 3313 2697 3314 #[test] ··· 2701 3318 exit_sketch: true, 2702 3319 ..empty_frame() 2703 3320 }; 2704 - assert_eq!(next_mode(prev, &frame, &[], &xy_only()), Mode::Idle); 3321 + assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); 2705 3322 } 2706 3323 2707 3324 #[test] 2708 3325 fn exit_sketch_action_returns_idle() { 2709 3326 let prev = Mode::enter_sketch(SketchId::default()); 2710 3327 assert_eq!( 2711 - next_mode( 2712 - prev, 2713 - &empty_frame(), 2714 - &[sketch_mode::EXIT_SKETCH_ACTION], 2715 - &xy_only() 2716 - ), 3328 + next_mode(prev, &empty_frame(), true, &xy_only()), 2717 3329 Mode::Idle 2718 3330 ); 2719 3331 } ··· 2730 3342 ..SketchSession::default() 2731 3343 }), 2732 3344 }; 2733 - let next = next_mode( 2734 - prev, 2735 - &empty_frame(), 2736 - &[sketch_mode::EXIT_SKETCH_ACTION], 2737 - &xy_only(), 2738 - ); 3345 + let next = next_mode(prev, &empty_frame(), true, &xy_only()); 2739 3346 let Mode::Sketch { session, .. } = next else { 2740 3347 panic!("escape with pending must keep sketch mode"); 2741 3348 }; ··· 2951 3558 #[test] 2952 3559 fn escape_with_armed_tool_no_pending_disarms_tool() { 2953 3560 let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 2954 - let next = next_mode( 2955 - prev, 2956 - &empty_frame(), 2957 - &[sketch_mode::EXIT_SKETCH_ACTION], 2958 - &xy_only(), 2959 - ); 3561 + let next = next_mode(prev, &empty_frame(), true, &xy_only()); 2960 3562 let Mode::Sketch { session, .. } = next else { 2961 3563 panic!("escape with armed tool must keep sketch mode"); 2962 3564 }; ··· 2971 3573 activated_tool: Some(SketchTool::Line), 2972 3574 ..empty_frame() 2973 3575 }; 2974 - let next = next_mode(prev, &frame, &[], &xy_only()); 3576 + let next = next_mode(prev, &frame, false, &xy_only()); 2975 3577 let Mode::Sketch { session, .. } = next else { 2976 3578 panic!("expected sketch mode"); 2977 3579 }; ··· 2985 3587 activated_tool: Some(SketchTool::Point), 2986 3588 ..empty_frame() 2987 3589 }; 2988 - let next = next_mode(prev, &frame, &[], &xy_only()); 3590 + let next = next_mode(prev, &frame, false, &xy_only()); 2989 3591 let Mode::Sketch { session, .. } = next else { 2990 3592 panic!("expected sketch mode"); 2991 3593 }; ··· 3008 3610 exit_sketch: true, 3009 3611 ..empty_frame() 3010 3612 }; 3011 - assert_eq!(next_mode(prev, &frame, &[], &xy_only()), Mode::Idle); 3613 + assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); 3012 3614 } 3013 3615 3014 3616 #[test] ··· 3017 3619 activated_tool: Some(SketchTool::Line), 3018 3620 ..empty_frame() 3019 3621 }; 3020 - assert_eq!(next_mode(Mode::Idle, &frame, &[], &xy_only()), Mode::Idle); 3622 + assert_eq!(next_mode(Mode::Idle, &frame, false, &xy_only()), Mode::Idle); 3021 3623 } 3022 3624 3023 3625 #[test] ··· 3027 3629 activated_tool: Some(SketchTool::Line), 3028 3630 ..empty_frame() 3029 3631 }; 3030 - let Mode::Sketch { session, .. } = next_mode(prev, &frame, &[], &xy_only()) else { 3632 + let Mode::Sketch { session, .. } = next_mode(prev, &frame, false, &xy_only()) else { 3031 3633 panic!("expected sketch mode"); 3032 3634 }; 3033 3635 assert_eq!(session.tool, Some(SketchTool::Line)); ··· 3040 3642 activated_tool: Some(SketchTool::Line), 3041 3643 ..empty_frame() 3042 3644 }; 3043 - let Mode::Sketch { session, .. } = next_mode(Mode::Idle, &frame, &[], &xy_only()) else { 3645 + let Mode::Sketch { session, .. } = next_mode(Mode::Idle, &frame, false, &xy_only()) else { 3044 3646 panic!("expected sketch mode"); 3045 3647 }; 3046 3648 assert_eq!(session.tool, Some(SketchTool::Line)); ··· 3117 3719 sketch_activated: Some(sketch_id), 3118 3720 ..empty_frame() 3119 3721 }; 3120 - let next = next_mode(Mode::Idle, &frame, &[], &BTreeMap::new()); 3722 + let next = next_mode(Mode::Idle, &frame, false, &BTreeMap::new()); 3121 3723 assert_eq!(next, Mode::enter_sketch(sketch_id)); 3122 3724 } 3123 3725 ··· 3128 3730 sketch_activated: Some(SketchId::default()), 3129 3731 ..empty_frame() 3130 3732 }; 3131 - assert_eq!(next_mode(prev.clone(), &frame, &[], &xy_only()), prev); 3733 + assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); 3132 3734 } 3133 3735 3134 3736 #[test] ··· 3138 3740 exit_sketch: true, 3139 3741 ..empty_frame() 3140 3742 }; 3141 - assert_eq!(next_mode(prev, &frame, &[], &xy_only()), Mode::Idle); 3743 + assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); 3142 3744 } 3143 3745 3144 3746 #[test] ··· 3262 3864 let (ax, ay) = point_at(&next, a).coords_mm(); 3263 3865 assert!(ax.abs() < 1e-9, "fixed a.x stays put: {ax}"); 3264 3866 assert!(ay.abs() < 1e-9, "fixed a.y stays put: {ay}"); 3867 + } 3868 + 3869 + #[test] 3870 + fn mirror_axis_reflects_across_horizontal_x_axis() { 3871 + let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 3872 + let (rx, ry) = axis.reflect(Point2::from_mm(3.0, 4.0)).coords_mm(); 3873 + assert!((rx - 3.0).abs() < 1e-9, "x preserved across x-axis"); 3874 + assert!((ry - -4.0).abs() < 1e-9, "y inverted across x-axis"); 3875 + } 3876 + 3877 + #[test] 3878 + fn mirror_axis_reflects_across_diagonal() { 3879 + let axis = MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 1.0)); 3880 + let (rx, ry) = axis.reflect(Point2::from_mm(2.0, 0.0)).coords_mm(); 3881 + assert!((rx - 0.0).abs() < 1e-9, "x reflects to y on y=x diagonal"); 3882 + assert!((ry - 2.0).abs() < 1e-9, "y reflects to x on y=x diagonal"); 3883 + } 3884 + 3885 + #[test] 3886 + fn mirror_axis_detects_degenerate_zero_length() { 3887 + let axis = MirrorAxis::from_points(Point2::from_mm(1.0, 1.0), Point2::from_mm(1.0, 1.0)); 3888 + assert!( 3889 + axis.is_degenerate(), 3890 + "coincident endpoints must be degenerate" 3891 + ); 3892 + } 3893 + 3894 + #[test] 3895 + fn mirror_targets_creates_reflected_circle_with_symmetric_relations() { 3896 + let (sketch, _, _, axis_line) = horizontal_line_fixture(); 3897 + let (sketch, center) = tools::add_point(sketch, Point2::from_mm(0.0, 3.0)); 3898 + let (sketch, circle_id) = 3899 + tools::add_circle(sketch, center, Length::new::<millimeter>(1.0), false); 3900 + let axis_geom = 3901 + MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 0.0)); 3902 + let source_ids: std::collections::BTreeSet<_> = [center, circle_id].into_iter().collect(); 3903 + let Ok(mirrored) = mirror_targets(sketch.clone(), &source_ids, axis_line, &axis_geom) 3904 + else { 3905 + panic!("circle mirror must succeed"); 3906 + }; 3907 + let new_circles: Vec<_> = mirrored 3908 + .entities() 3909 + .iter() 3910 + .filter_map(|(_, e)| match *e { 3911 + SketchEntity::Circle(c) => Some(c), 3912 + _ => None, 3913 + }) 3914 + .collect(); 3915 + assert_eq!(new_circles.len(), 2, "original + mirrored circle"); 3916 + let new_center_pos = new_circles 3917 + .iter() 3918 + .map(|c| { 3919 + let SketchEntity::Point(p) = mirrored.entities()[c.center()] else { 3920 + panic!("circle center is a point"); 3921 + }; 3922 + p.at().coords_mm() 3923 + }) 3924 + .find(|(_, y)| *y < 0.0); 3925 + let Some((cx, cy)) = new_center_pos else { 3926 + panic!("mirrored circle must lie below x-axis"); 3927 + }; 3928 + assert!(cx.abs() < 1e-9 && (cy + 3.0).abs() < 1e-9, "({cx}, {cy})"); 3929 + let symmetric_count = mirrored 3930 + .relations() 3931 + .iter() 3932 + .filter( 3933 + |(_, r)| matches!(r, SketchRelation::Symmetric { axis, .. } if *axis == axis_line), 3934 + ) 3935 + .count(); 3936 + assert!( 3937 + symmetric_count >= 1, 3938 + "mirror must emit at least one Symmetric relation tied to the axis", 3939 + ); 3940 + } 3941 + 3942 + #[test] 3943 + fn mirror_copies_horizontal_relation_to_mirrored_line() { 3944 + use bone_document::SketchEdit; 3945 + let sketch = Sketch::new(Plane::Xy.basis()); 3946 + let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); 3947 + let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); 3948 + let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); 3949 + let (sketch, source_p0) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); 3950 + let (sketch, source_p1) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); 3951 + let (sketch, source_line) = tools::add_line(sketch, source_p0, source_p1, false); 3952 + let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Horizontal( 3953 + source_line, 3954 + ))) else { 3955 + panic!("seed Horizontal must apply"); 3956 + }; 3957 + let axis_geom = 3958 + MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 3959 + let source_ids: std::collections::BTreeSet<_> = 3960 + [source_p0, source_p1, source_line].into_iter().collect(); 3961 + let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { 3962 + panic!("mirror must succeed"); 3963 + }; 3964 + let horizontal_lines: Vec<_> = mirrored 3965 + .relations() 3966 + .iter() 3967 + .filter_map(|(_, r)| match r { 3968 + SketchRelation::Horizontal(id) => Some(*id), 3969 + _ => None, 3970 + }) 3971 + .collect(); 3972 + assert_eq!( 3973 + horizontal_lines.len(), 3974 + 2, 3975 + "original + mirrored horizontal must both exist" 3976 + ); 3977 + } 3978 + 3979 + #[test] 3980 + fn construction_toggle_flips_line_flag() { 3981 + let (sketch, _, _, line) = horizontal_line_fixture(); 3982 + let before = match sketch.entities()[line] { 3983 + SketchEntity::Line(l) => l.for_construction(), 3984 + _ => panic!("line"), 3985 + }; 3986 + let Ok((next, _)) = sketch.apply(SketchEdit::SetConstruction { 3987 + id: line, 3988 + for_construction: !before, 3989 + }) else { 3990 + panic!("set construction must succeed"); 3991 + }; 3992 + let after = match next.entities()[line] { 3993 + SketchEntity::Line(l) => l.for_construction(), 3994 + _ => panic!("line"), 3995 + }; 3996 + assert_ne!(before, after); 3997 + } 3998 + 3999 + #[test] 4000 + fn mirror_axis_detects_on_axis_point() { 4001 + let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 4002 + assert!(axis.is_on_axis(Point2::from_mm(2.0, 0.0))); 4003 + assert!(axis.is_on_axis(Point2::from_mm(-5.0, 0.0))); 4004 + assert!(!axis.is_on_axis(Point2::from_mm(2.0, 0.5))); 4005 + } 4006 + 4007 + #[test] 4008 + fn mirror_on_axis_source_point_is_identity_no_self_symmetric() { 4009 + let sketch = Sketch::new(Plane::Xy.basis()); 4010 + let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); 4011 + let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); 4012 + let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); 4013 + let (sketch, on_axis_point) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 4014 + let axis_geom = 4015 + MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 4016 + let source_ids: std::collections::BTreeSet<_> = [on_axis_point].into_iter().collect(); 4017 + let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { 4018 + panic!("mirror must succeed"); 4019 + }; 4020 + let point_count = mirrored 4021 + .entities() 4022 + .iter() 4023 + .filter(|(_, e)| matches!(e, SketchEntity::Point(_))) 4024 + .count(); 4025 + assert_eq!( 4026 + point_count, 3, 4027 + "on-axis source must not produce a duplicate point: {point_count}" 4028 + ); 4029 + let symmetric_count = mirrored 4030 + .relations() 4031 + .iter() 4032 + .filter(|(_, r)| matches!(r, SketchRelation::Symmetric { .. })) 4033 + .count(); 4034 + assert_eq!( 4035 + symmetric_count, 0, 4036 + "on-axis source must not emit a self-pair Symmetric relation" 4037 + ); 4038 + } 4039 + 4040 + #[test] 4041 + fn mirror_copies_relation_referencing_axis_line() { 4042 + use bone_document::SketchEdit; 4043 + let sketch = Sketch::new(Plane::Xy.basis()); 4044 + let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); 4045 + let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); 4046 + let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); 4047 + let (sketch, off_a) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); 4048 + let (sketch, off_b) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); 4049 + let (sketch, off_line) = tools::add_line(sketch, off_a, off_b, false); 4050 + let Ok((sketch, _)) = 4051 + sketch.apply(SketchEdit::AddRelation(SketchRelation::Parallel( 4052 + off_line, axis_line, 4053 + ))) 4054 + else { 4055 + panic!("seed Parallel(off_line, axis_line) must apply"); 4056 + }; 4057 + let axis_geom = 4058 + MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 4059 + let source_ids: std::collections::BTreeSet<_> = 4060 + [off_a, off_b, off_line].into_iter().collect(); 4061 + let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { 4062 + panic!("mirror must succeed"); 4063 + }; 4064 + let parallel_count = mirrored 4065 + .relations() 4066 + .iter() 4067 + .filter( 4068 + |(_, r)| matches!(r, SketchRelation::Parallel(_, b) if *b == axis_line), 4069 + ) 4070 + .count(); 4071 + assert_eq!( 4072 + parallel_count, 2, 4073 + "original + mirrored Parallel(line, axis_line) both expected: {parallel_count}", 4074 + ); 3265 4075 } 3266 4076 }
+15 -12
crates/bone-app/src/native_picker.rs
··· 26 26 match self.rx.try_recv() { 27 27 Ok(outcome) => PollState::Ready(outcome), 28 28 Err(TryRecvError::Empty) => PollState::Pending, 29 - Err(TryRecvError::Disconnected) => { 30 - PollState::Ready(NativeOutcome::Error("picker worker disconnected".to_owned())) 31 - } 29 + Err(TryRecvError::Disconnected) => PollState::Ready(NativeOutcome::Error( 30 + "picker worker disconnected".to_owned(), 31 + )), 32 32 } 33 33 } 34 34 } ··· 100 100 } 101 101 } 102 102 103 - async fn run_open( 104 - title: &str, 105 - accept: &str, 106 - folder: Option<PathBuf>, 107 - ) -> NativeOutcome { 103 + async fn run_open(title: &str, accept: &str, folder: Option<PathBuf>) -> NativeOutcome { 108 104 let mut req = OpenFileRequest::default() 109 105 .title(title) 110 106 .accept_label(accept) ··· 125 121 Ok(s) => s, 126 122 Err(e) => return classify_response_error(&e), 127 123 }; 128 - match selected.uris().first().and_then(|u| uri_to_path(u.as_str())) { 124 + match selected 125 + .uris() 126 + .first() 127 + .and_then(|u| uri_to_path(u.as_str())) 128 + { 129 129 Some(path) => NativeOutcome::Path(path), 130 130 None => NativeOutcome::Cancelled, 131 131 } ··· 158 158 Ok(s) => s, 159 159 Err(e) => return classify_response_error(&e), 160 160 }; 161 - match selected.uris().first().and_then(|u| uri_to_path(u.as_str())) { 161 + match selected 162 + .uris() 163 + .first() 164 + .and_then(|u| uri_to_path(u.as_str())) 165 + { 162 166 Some(path) => NativeOutcome::Path(path), 163 167 None => NativeOutcome::Cancelled, 164 168 } ··· 179 183 180 184 fn uri_to_path(uri: &str) -> Option<PathBuf> { 181 185 let encoded = uri.strip_prefix("file://")?; 182 - let decoded = 183 - percent_encoding::percent_decode_str(encoded).decode_utf8_lossy(); 186 + let decoded = percent_encoding::percent_decode_str(encoded).decode_utf8_lossy(); 184 187 Some(PathBuf::from(decoded.as_ref())) 185 188 } 186 189
+90 -1
crates/bone-app/src/settings.rs
··· 1 + use std::path::PathBuf; 2 + 1 3 use bone_render::PickAperture; 4 + use serde::{Deserialize, Serialize}; 2 5 3 - #[derive(Copy, Clone, Debug, PartialEq, Eq)] 6 + use crate::hotkeys::HotkeyOverrides; 7 + 8 + #[derive(Clone, Debug, PartialEq, Eq)] 4 9 pub struct Settings { 5 10 pub pick_aperture: PickAperture, 11 + pub hotkey_overrides: HotkeyOverrides, 6 12 } 7 13 8 14 impl Default for Settings { 9 15 fn default() -> Self { 10 16 Self { 11 17 pick_aperture: PickAperture::DEFAULT, 18 + hotkey_overrides: HotkeyOverrides::default(), 12 19 } 13 20 } 14 21 } 22 + 23 + #[derive(Serialize, Deserialize, Default)] 24 + struct SettingsOnDisk { 25 + aperture_px: u32, 26 + hotkey_overrides: HotkeyOverrides, 27 + } 28 + 29 + impl From<&Settings> for SettingsOnDisk { 30 + fn from(value: &Settings) -> Self { 31 + Self { 32 + aperture_px: value.pick_aperture.radius_px(), 33 + hotkey_overrides: value.hotkey_overrides.clone(), 34 + } 35 + } 36 + } 37 + 38 + impl SettingsOnDisk { 39 + fn into_settings(self) -> Settings { 40 + Settings { 41 + pick_aperture: PickAperture::new(self.aperture_px), 42 + hotkey_overrides: self.hotkey_overrides, 43 + } 44 + } 45 + } 46 + 47 + #[must_use] 48 + pub fn settings_path() -> Option<PathBuf> { 49 + let base = config_base_dir()?; 50 + Some(base.join("bone").join("settings.ron")) 51 + } 52 + 53 + #[cfg(target_os = "windows")] 54 + fn config_base_dir() -> Option<PathBuf> { 55 + std::env::var_os("APPDATA").map(PathBuf::from).or_else(|| { 56 + std::env::var_os("USERPROFILE") 57 + .map(|p| PathBuf::from(p).join("AppData").join("Roaming")) 58 + }) 59 + } 60 + 61 + #[cfg(not(target_os = "windows"))] 62 + fn config_base_dir() -> Option<PathBuf> { 63 + std::env::var_os("XDG_CONFIG_HOME") 64 + .map(PathBuf::from) 65 + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) 66 + } 67 + 68 + #[must_use] 69 + pub fn load() -> Settings { 70 + let Some(path) = settings_path() else { 71 + return Settings::default(); 72 + }; 73 + let Ok(text) = std::fs::read_to_string(&path) else { 74 + return Settings::default(); 75 + }; 76 + match ron::de::from_str::<SettingsOnDisk>(&text) { 77 + Ok(on_disk) => on_disk.into_settings(), 78 + Err(e) => { 79 + tracing::warn!(error = %e, path = %path.display(), "settings load"); 80 + Settings::default() 81 + } 82 + } 83 + } 84 + 85 + pub fn save(settings: &Settings) { 86 + let Some(path) = settings_path() else { return }; 87 + if let Some(parent) = path.parent() 88 + && let Err(e) = std::fs::create_dir_all(parent) 89 + { 90 + tracing::warn!(error = %e, path = %parent.display(), "settings dir create"); 91 + return; 92 + } 93 + let on_disk = SettingsOnDisk::from(settings); 94 + let pretty = ron::ser::PrettyConfig::default(); 95 + match ron::ser::to_string_pretty(&on_disk, pretty) { 96 + Ok(text) => { 97 + if let Err(e) = std::fs::write(&path, text) { 98 + tracing::warn!(error = %e, path = %path.display(), "settings save"); 99 + } 100 + } 101 + Err(e) => tracing::warn!(error = %e, "settings serialize"), 102 + } 103 + }
+323 -53
crates/bone-app/src/shell.rs
··· 18 18 use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 19 19 use bone_ui::widgets::GlyphMark; 20 20 use bone_ui::widgets::{ 21 - AngleEditor, Clipboard, Dialog, DialogButton, LabelText, LengthEditor, MemoryClipboard, 22 - MenuBar, MenuBarEntry, MenuBarState, MenuItem, PropertyCell, PropertyEditor, PropertyGrid, 23 - PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, Slider, SliderRange, 24 - SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Tabs, TabsOrientation, ToolbarItem, 25 - TreeNode, TreeView, TreeViewState, WidgetPaint, show_dialog, show_menu_bar, show_property_grid, 26 - show_ribbon, show_slider, show_status_bar, show_tabs, show_tree_view, 21 + AngleEditor, Clipboard, Dialog, DialogButton, HotkeyCapture, HotkeyCaptureState, LabelText, 22 + LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem, PropertyCell, 23 + PropertyEditor, PropertyGrid, PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, 24 + RibbonTab, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Tabs, 25 + TabsOrientation, ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, show_dialog, 26 + show_hotkey_capture, show_menu_bar, show_property_grid, show_ribbon, show_slider, 27 + show_status_bar, show_tabs, show_tree_view, 27 28 }; 28 29 use bone_ui::{WidgetId, WidgetKey}; 29 30 use uom::si::length::millimeter; ··· 102 103 menu_edit_redo: WidgetId, 103 104 menu_view_zoom_fit: WidgetId, 104 105 menu_tools_options: WidgetId, 106 + menu_tools_keyboard: WidgetId, 105 107 menu_sketch_exit: WidgetId, 106 108 settings_dialog: WidgetId, 107 109 settings_aperture_slider: WidgetId, 108 110 settings_reset: WidgetId, 109 111 settings_close: WidgetId, 112 + keyboard_dialog: WidgetId, 113 + keyboard_dialog_reset: WidgetId, 114 + keyboard_dialog_close: WidgetId, 110 115 } 111 116 112 117 impl ShellIds { ··· 123 128 let menu_tools = menu_bar.child(WidgetKey::new("tools")); 124 129 let menu_sketch = menu_bar.child(WidgetKey::new("sketch")); 125 130 let settings_dialog = root.child(WidgetKey::new("settings.dialog")); 131 + let keyboard_dialog = root.child(WidgetKey::new("keyboard.dialog")); 126 132 let viewport = root.child(WidgetKey::new("viewport")); 127 133 Self { 128 134 dock_host: root.child(WidgetKey::new("dock")), ··· 164 170 menu_edit_redo: menu_edit.child(WidgetKey::new("redo")), 165 171 menu_view_zoom_fit: menu_view.child(WidgetKey::new("zoom_fit")), 166 172 menu_tools_options: menu_tools.child(WidgetKey::new("options")), 173 + menu_tools_keyboard: menu_tools.child(WidgetKey::new("keyboard")), 167 174 menu_sketch_exit: menu_sketch.child(WidgetKey::new("exit")), 168 175 settings_dialog, 169 176 settings_aperture_slider: settings_dialog.child(WidgetKey::new("aperture.slider")), 170 177 settings_reset: settings_dialog.child(WidgetKey::new("button.reset")), 171 178 settings_close: settings_dialog.child(WidgetKey::new("button.close")), 179 + keyboard_dialog, 180 + keyboard_dialog_reset: keyboard_dialog.child(WidgetKey::new("button.reset")), 181 + keyboard_dialog_close: keyboard_dialog.child(WidgetKey::new("button.close")), 172 182 } 173 183 } 174 184 ··· 194 204 (self.menu_edit_redo, MenuAction::Redo), 195 205 (self.menu_view_zoom_fit, MenuAction::ZoomFit), 196 206 (self.menu_tools_options, MenuAction::OpenSettings), 207 + (self.menu_tools_keyboard, MenuAction::OpenKeyboardCustomize), 197 208 (self.menu_sketch_exit, MenuAction::ExitSketch), 198 209 ] 199 210 .iter() ··· 213 224 Redo, 214 225 ZoomFit, 215 226 OpenSettings, 227 + OpenKeyboardCustomize, 216 228 ExitSketch, 217 229 } 218 230 ··· 233 245 pub menu_bar: MenuBarState, 234 246 pub dim_property: Option<DimPropertyEditor>, 235 247 pub settings_dialog_open: bool, 248 + pub keyboard_dialog_open: bool, 249 + pub hotkey_capture: BTreeMap<bone_ui::hotkey::ActionId, HotkeyCaptureState>, 236 250 pub left_pane: LeftPane, 237 251 last_left_pane_interesting: bool, 238 252 } ··· 359 373 document: &Document, 360 374 mode: &Mode, 361 375 selection: &Selection, 362 - settings: Settings, 376 + settings: &Settings, 363 377 viewport_size: LayoutSize, 364 378 cursor_world: Option<Point2>, 365 379 ) -> ShellFrame { ··· 390 404 &mut self.state.menu_bar, 391 405 document, 392 406 mode.is_sketch(), 407 + &settings.hotkey_overrides, 393 408 &mut paints, 394 409 &mut popover_paints, 395 410 ); ··· 490 505 settings, 491 506 &mut dialog_paints, 492 507 ); 508 + let keyboard_change = render_keyboard_dialog( 509 + ctx, 510 + viewport_size, 511 + &self.ids, 512 + &mut self.state, 513 + settings, 514 + &mut dialog_paints, 515 + ); 516 + let settings_change = keyboard_change.or(settings_change); 493 517 let (paints, mut overlay_paints) = partition_overlay(paints, ctx.theme()); 494 518 overlay_paints.extend(popover_paints); 495 519 overlay_paints.extend(dialog_paints); ··· 528 552 viewport_size: LayoutSize, 529 553 ids: &ShellIds, 530 554 state: &mut ShellState, 531 - settings: Settings, 555 + settings: &Settings, 532 556 paints: &mut Vec<WidgetPaint>, 533 557 ) -> Option<Settings> { 534 558 if !state.settings_dialog_open { ··· 577 601 state.settings_dialog_open = false; 578 602 } 579 603 if response.activated == Some(ids.settings_reset) { 580 - return Some(Settings::default()); 604 + return Some(Settings { 605 + pick_aperture: PickAperture::DEFAULT, 606 + hotkey_overrides: settings.hotkey_overrides.clone(), 607 + }); 581 608 } 582 609 slider_change 583 610 } ··· 586 613 ctx: &mut FrameCtx<'_>, 587 614 body_rect: LayoutRect, 588 615 aperture_slider_id: WidgetId, 589 - settings: Settings, 616 + settings: &Settings, 590 617 aperture_label_text: String, 591 618 paint: &mut Vec<WidgetPaint>, 592 619 ) -> Option<Settings> { ··· 634 661 let radius = clamped as u32; 635 662 Settings { 636 663 pick_aperture: PickAperture::new(radius), 664 + hotkey_overrides: settings.hotkey_overrides.clone(), 637 665 } 638 666 }) 639 667 } ··· 689 717 ) 690 718 } 691 719 720 + const KEYBOARD_DIALOG_WIDTH: f32 = 460.0; 721 + const KEYBOARD_DIALOG_HEIGHT: f32 = 420.0; 722 + const KEYBOARD_ROW_HEIGHT: f32 = 32.0; 723 + const KEYBOARD_ROW_GAP: f32 = 6.0; 724 + const KEYBOARD_CAPTURE_WIDTH: f32 = 180.0; 725 + const KEYBOARD_HINT_HEIGHT: f32 = 20.0; 726 + const KEYBOARD_HINT_TO_ROWS_GAP: f32 = 12.0; 727 + 728 + fn render_keyboard_dialog( 729 + ctx: &mut FrameCtx<'_>, 730 + viewport_size: LayoutSize, 731 + ids: &ShellIds, 732 + state: &mut ShellState, 733 + settings: &Settings, 734 + paints: &mut Vec<WidgetPaint>, 735 + ) -> Option<Settings> { 736 + if !state.keyboard_dialog_open { 737 + return None; 738 + } 739 + let viewport = LayoutRect::new( 740 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 741 + viewport_size, 742 + ); 743 + let buttons = [ 744 + DialogButton::secondary(ids.keyboard_dialog_reset, strings::SETTINGS_RESET), 745 + DialogButton::primary(ids.keyboard_dialog_close, strings::SETTINGS_CLOSE), 746 + ]; 747 + let dialog_size = LayoutSize::new( 748 + LayoutPx::new(KEYBOARD_DIALOG_WIDTH), 749 + LayoutPx::new(KEYBOARD_DIALOG_HEIGHT), 750 + ); 751 + let mut next_overrides: Option<crate::hotkeys::HotkeyOverrides> = None; 752 + let (response, _) = show_dialog( 753 + ctx, 754 + Dialog::new( 755 + ids.keyboard_dialog, 756 + viewport, 757 + dialog_size, 758 + strings::KEYBOARD_DIALOG_TITLE, 759 + &buttons, 760 + ), 761 + |ctx, body_rect, paint| { 762 + next_overrides = keyboard_dialog_body( 763 + ctx, 764 + body_rect, 765 + ids.keyboard_dialog, 766 + state, 767 + &settings.hotkey_overrides, 768 + paint, 769 + ); 770 + Some(()) 771 + }, 772 + ); 773 + paints.extend(response.paint); 774 + if response.dismissed || response.activated == Some(ids.keyboard_dialog_close) { 775 + state.keyboard_dialog_open = false; 776 + state.hotkey_capture.clear(); 777 + } 778 + if response.activated == Some(ids.keyboard_dialog_reset) { 779 + return Some(Settings { 780 + pick_aperture: settings.pick_aperture, 781 + hotkey_overrides: crate::hotkeys::HotkeyOverrides::default(), 782 + }); 783 + } 784 + next_overrides.map(|overrides| Settings { 785 + pick_aperture: settings.pick_aperture, 786 + hotkey_overrides: overrides, 787 + }) 788 + } 789 + 790 + fn keyboard_dialog_header_rects(body_rect: LayoutRect) -> (LayoutRect, LayoutRect, f32) { 791 + let gutter = SETTINGS_DIALOG_GUTTER; 792 + let heading_rect = LayoutRect::new( 793 + LayoutPos::new( 794 + LayoutPx::new(body_rect.origin.x.value() + gutter), 795 + LayoutPx::new(body_rect.origin.y.value() + gutter), 796 + ), 797 + LayoutSize::new( 798 + LayoutPx::saturating_nonneg(body_rect.size.width.value() - 2.0 * gutter), 799 + LayoutPx::new(KEYBOARD_HINT_HEIGHT), 800 + ), 801 + ); 802 + let hint_rect = LayoutRect::new( 803 + LayoutPos::new( 804 + LayoutPx::new(body_rect.origin.x.value() + gutter), 805 + LayoutPx::new( 806 + body_rect.origin.y.value() + gutter + KEYBOARD_HINT_HEIGHT + KEYBOARD_ROW_GAP, 807 + ), 808 + ), 809 + LayoutSize::new( 810 + LayoutPx::saturating_nonneg(body_rect.size.width.value() - 2.0 * gutter), 811 + LayoutPx::new(KEYBOARD_HINT_HEIGHT), 812 + ), 813 + ); 814 + let rows_origin_y = body_rect.origin.y.value() 815 + + gutter 816 + + 2.0 * KEYBOARD_HINT_HEIGHT 817 + + KEYBOARD_ROW_GAP 818 + + KEYBOARD_HINT_TO_ROWS_GAP; 819 + (heading_rect, hint_rect, rows_origin_y) 820 + } 821 + 822 + fn keyboard_row_rects(body_rect: LayoutRect, row_y: f32) -> (LayoutRect, LayoutRect) { 823 + let gutter = SETTINGS_DIALOG_GUTTER; 824 + let label_rect = LayoutRect::new( 825 + LayoutPos::new( 826 + LayoutPx::new(body_rect.origin.x.value() + gutter), 827 + LayoutPx::new(row_y + 4.0), 828 + ), 829 + LayoutSize::new( 830 + LayoutPx::saturating_nonneg( 831 + body_rect.size.width.value() - 3.0 * gutter - KEYBOARD_CAPTURE_WIDTH, 832 + ), 833 + LayoutPx::new(KEYBOARD_ROW_HEIGHT), 834 + ), 835 + ); 836 + let capture_rect = LayoutRect::new( 837 + LayoutPos::new( 838 + LayoutPx::new( 839 + body_rect.origin.x.value() + body_rect.size.width.value() 840 + - gutter 841 + - KEYBOARD_CAPTURE_WIDTH, 842 + ), 843 + LayoutPx::new(row_y), 844 + ), 845 + LayoutSize::new( 846 + LayoutPx::new(KEYBOARD_CAPTURE_WIDTH), 847 + LayoutPx::new(KEYBOARD_ROW_HEIGHT), 848 + ), 849 + ); 850 + (label_rect, capture_rect) 851 + } 852 + 853 + fn keyboard_dialog_body( 854 + ctx: &mut FrameCtx<'_>, 855 + body_rect: LayoutRect, 856 + dialog_id: WidgetId, 857 + state: &mut ShellState, 858 + overrides: &crate::hotkeys::HotkeyOverrides, 859 + paint: &mut Vec<WidgetPaint>, 860 + ) -> Option<crate::hotkeys::HotkeyOverrides> { 861 + let (heading_rect, hint_rect, rows_origin_y) = keyboard_dialog_header_rects(body_rect); 862 + paint.push(WidgetPaint::Label { 863 + rect: heading_rect, 864 + text: LabelText::Key(strings::HOTKEY_SECTION_HEADING), 865 + color: ctx.theme().colors.text_primary(), 866 + role: ctx.theme().typography.label, 867 + }); 868 + paint.push(WidgetPaint::Label { 869 + rect: hint_rect, 870 + text: LabelText::Key(strings::HOTKEY_RECORDING_HINT), 871 + color: ctx.theme().colors.text_secondary(), 872 + role: ctx.theme().typography.caption, 873 + }); 874 + let entries = crate::hotkeys::remap_entries(); 875 + let row_advance = KEYBOARD_ROW_HEIGHT + KEYBOARD_ROW_GAP; 876 + let captures_changed = entries 877 + .iter() 878 + .fold( 879 + ( 880 + rows_origin_y, 881 + Vec::<(bone_ui::hotkey::ActionId, bone_ui::hotkey::KeyChord)>::new(), 882 + ), 883 + |(row_y, mut acc), entry| { 884 + let (label_rect, capture_rect) = keyboard_row_rects(body_rect, row_y); 885 + paint.push(WidgetPaint::Label { 886 + rect: label_rect, 887 + text: LabelText::Key(entry.label), 888 + color: ctx.theme().colors.text_primary(), 889 + role: ctx.theme().typography.label, 890 + }); 891 + let chord_now = current_chord(overrides, entry); 892 + let placeholder = chord_now.map_or(strings::HOTKEY_UNBOUND_LABEL, |_| entry.label); 893 + let capture_state = state.hotkey_capture.entry(entry.action).or_insert_with(|| { 894 + HotkeyCaptureState { 895 + recording: false, 896 + chord: chord_now, 897 + } 898 + }); 899 + if capture_state.chord.is_none() { 900 + capture_state.chord = chord_now; 901 + } 902 + let response = show_hotkey_capture( 903 + ctx, 904 + HotkeyCapture::new( 905 + capture_widget_id(dialog_id, entry.action), 906 + capture_rect, 907 + placeholder, 908 + strings::HOTKEY_RECORDING_PROMPT, 909 + capture_state, 910 + ), 911 + ); 912 + paint.extend(response.paint); 913 + if let Some(chord) = response.captured { 914 + acc.push((entry.action, chord)); 915 + } 916 + (row_y + row_advance, acc) 917 + }, 918 + ) 919 + .1; 920 + if captures_changed.is_empty() { 921 + return None; 922 + } 923 + let next = captures_changed 924 + .into_iter() 925 + .fold(overrides.clone(), |mut acc, (action, chord)| { 926 + acc.set(action, chord); 927 + acc 928 + }); 929 + Some(next) 930 + } 931 + 932 + fn current_chord( 933 + overrides: &crate::hotkeys::HotkeyOverrides, 934 + entry: &crate::hotkeys::RemapEntry, 935 + ) -> Option<bone_ui::hotkey::KeyChord> { 936 + overrides.lookup(entry.action).or(entry.default_chord) 937 + } 938 + 939 + fn capture_widget_id(dialog_id: WidgetId, action: bone_ui::hotkey::ActionId) -> WidgetId { 940 + dialog_id.child_indexed(WidgetKey::new("capture"), u64::from(action.get().get())) 941 + } 942 + 692 943 fn partition_overlay( 693 944 paints: Vec<WidgetPaint>, 694 945 theme: &Theme, ··· 791 1042 state: &mut MenuBarState, 792 1043 document: &Document, 793 1044 is_sketch: bool, 1045 + overrides: &crate::hotkeys::HotkeyOverrides, 794 1046 paints: &mut Vec<WidgetPaint>, 795 1047 popover_paints: &mut Vec<WidgetPaint>, 796 1048 ) -> Option<MenuAction> { 797 1049 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 798 1050 return None; 799 1051 } 800 - let entries = build_menu_entries(ids, is_sketch); 1052 + let entries = build_menu_entries(ids, is_sketch, overrides); 801 1053 let response = show_menu_bar( 802 1054 ctx, 803 1055 MenuBar::new(ids.menu_bar, rect, strings::MENU_BAR_LABEL, &entries, state) ··· 812 1064 clippy::too_many_lines, 813 1065 reason = "menu entries are flat data; splitting would scatter related strings" 814 1066 )] 815 - fn build_menu_entries(ids: &ShellIds, is_sketch: bool) -> Vec<MenuBarEntry> { 1067 + fn build_menu_entries( 1068 + ids: &ShellIds, 1069 + is_sketch: bool, 1070 + overrides: &crate::hotkeys::HotkeyOverrides, 1071 + ) -> Vec<MenuBarEntry> { 816 1072 let placeholder = |menu_id: WidgetId, key: &'static str| MenuItem::Action { 817 1073 id: menu_id.child(WidgetKey::new(key)), 818 1074 label: strings::MENU_PLACEHOLDER_COMING_SOON, 819 1075 shortcut: None, 820 1076 disabled: true, 821 1077 }; 822 - let action = |id: WidgetId, label: StringKey, shortcut: Option<StringKey>, disabled: bool| { 823 - MenuItem::Action { 824 - id, 825 - label, 826 - shortcut, 827 - disabled, 828 - } 829 - }; 1078 + let action_with_accel = 1079 + |id: WidgetId, label: StringKey, accel: Option<bone_ui::hotkey::ActionId>| { 1080 + let shortcut = accel 1081 + .and_then(|a| crate::hotkeys::accelerator_label(a, overrides)) 1082 + .map(LabelText::Owned); 1083 + MenuItem::Action { 1084 + id, 1085 + label, 1086 + shortcut, 1087 + disabled: false, 1088 + } 1089 + }; 830 1090 let file = ids.menu_file; 831 1091 let mut entries = vec![ 832 1092 MenuBarEntry { 833 1093 id: file, 834 1094 label: strings::MENU_FILE, 835 1095 items: vec![ 836 - action(ids.menu_file_new, strings::MENU_FILE_NEW, None, false), 837 - action(ids.menu_file_open, strings::MENU_FILE_OPEN, None, false), 838 - action(ids.menu_file_save, strings::MENU_FILE_SAVE, None, false), 839 - action( 840 - ids.menu_file_save_as, 841 - strings::MENU_FILE_SAVE_AS, 842 - None, 843 - false, 1096 + action_with_accel( 1097 + ids.menu_file_new, 1098 + strings::MENU_FILE_NEW, 1099 + Some(crate::hotkeys::NEW_DOCUMENT_ACTION), 1100 + ), 1101 + action_with_accel( 1102 + ids.menu_file_open, 1103 + strings::MENU_FILE_OPEN, 1104 + Some(crate::hotkeys::OPEN_DOCUMENT_ACTION), 1105 + ), 1106 + action_with_accel( 1107 + ids.menu_file_save, 1108 + strings::MENU_FILE_SAVE, 1109 + Some(crate::hotkeys::SAVE_DOCUMENT_ACTION), 844 1110 ), 1111 + action_with_accel(ids.menu_file_save_as, strings::MENU_FILE_SAVE_AS, None), 845 1112 MenuItem::Separator, 846 - action( 1113 + action_with_accel( 847 1114 ids.menu_file_quit, 848 1115 strings::MENU_FILE_QUIT, 849 - Some(strings::SHORTCUT_QUIT), 850 - false, 1116 + Some(crate::hotkeys::QUIT_ACTION), 851 1117 ), 852 1118 ], 853 1119 }, ··· 855 1121 id: ids.menu_edit, 856 1122 label: strings::MENU_EDIT, 857 1123 items: vec![ 858 - action( 1124 + action_with_accel( 859 1125 ids.menu_edit_undo, 860 1126 strings::MENU_EDIT_UNDO, 861 - Some(strings::SHORTCUT_UNDO), 862 - false, 1127 + Some(crate::sketch_mode::UNDO_ACTION), 863 1128 ), 864 - action( 1129 + action_with_accel( 865 1130 ids.menu_edit_redo, 866 1131 strings::MENU_EDIT_REDO, 867 - Some(strings::SHORTCUT_REDO), 868 - false, 1132 + Some(crate::sketch_mode::REDO_ACTION), 869 1133 ), 870 1134 ], 871 1135 }, 872 1136 MenuBarEntry { 873 1137 id: ids.menu_view, 874 1138 label: strings::MENU_VIEW, 875 - items: vec![action( 1139 + items: vec![action_with_accel( 876 1140 ids.menu_view_zoom_fit, 877 1141 strings::MENU_VIEW_ZOOM_FIT, 878 - Some(strings::SHORTCUT_ZOOM_FIT), 879 - false, 1142 + Some(crate::hotkeys::ZOOM_FIT_ACTION), 880 1143 )], 881 1144 }, 882 1145 MenuBarEntry { ··· 887 1150 MenuBarEntry { 888 1151 id: ids.menu_tools, 889 1152 label: strings::MENU_TOOLS, 890 - items: vec![action( 891 - ids.menu_tools_options, 892 - strings::MENU_TOOLS_OPTIONS, 893 - None, 894 - false, 895 - )], 1153 + items: vec![ 1154 + action_with_accel(ids.menu_tools_options, strings::MENU_TOOLS_OPTIONS, None), 1155 + action_with_accel(ids.menu_tools_keyboard, strings::MENU_TOOLS_KEYBOARD, None), 1156 + ], 896 1157 }, 897 1158 ]; 898 1159 if is_sketch { 899 1160 entries.push(MenuBarEntry { 900 1161 id: ids.menu_sketch, 901 1162 label: strings::MENU_SKETCH, 902 - items: vec![action( 1163 + items: vec![action_with_accel( 903 1164 ids.menu_sketch_exit, 904 1165 strings::MENU_SKETCH_EXIT, 905 - None, 906 - false, 1166 + Some(crate::sketch_mode::EXIT_SKETCH_ACTION), 907 1167 )], 908 1168 }); 909 1169 } ··· 1426 1686 specs.push(row_editor(strings::PROPERTY_ROW_POINT, label(point))); 1427 1687 specs.push(row_editor(strings::PROPERTY_ROW_LINE, label(line))); 1428 1688 } 1689 + SketchRelation::Symmetric { a, b, axis } => { 1690 + specs.push(row_editor(strings::PROPERTY_ROW_FIRST, label(a))); 1691 + specs.push(row_editor(strings::PROPERTY_ROW_SECOND, label(b))); 1692 + specs.push(row_editor(strings::PROPERTY_ROW_AXIS, label(axis))); 1693 + } 1429 1694 SketchRelation::Horizontal(a) | SketchRelation::Vertical(a) | SketchRelation::Fix(a) => { 1430 1695 specs.push(row_editor(strings::PROPERTY_ROW_TARGET, label(a))); 1431 1696 } ··· 1453 1718 SketchRelation::Equal(_, _) => strings::TOOL_EQUAL, 1454 1719 SketchRelation::Concentric(_, _) => strings::TOOL_CONCENTRIC, 1455 1720 SketchRelation::Midpoint { .. } => strings::TOOL_MIDPOINT, 1721 + SketchRelation::Symmetric { .. } => strings::TOOL_SYMMETRIC, 1456 1722 SketchRelation::Fix(_) => strings::TOOL_FIX, 1457 1723 } 1458 1724 } ··· 2357 2623 document, 2358 2624 mode, 2359 2625 selection, 2360 - Settings::default(), 2626 + &Settings::default(), 2361 2627 size, 2362 2628 None, 2363 2629 ) ··· 2395 2661 2396 2662 #[test] 2397 2663 fn file_menu_actions_are_enabled() { 2398 - let entries = build_menu_entries(&ShellIds::standard(), false); 2664 + let entries = build_menu_entries( 2665 + &ShellIds::standard(), 2666 + false, 2667 + &crate::hotkeys::HotkeyOverrides::default(), 2668 + ); 2399 2669 let Some(file_menu) = entries.iter().find(|e| e.label == strings::MENU_FILE) else { 2400 2670 panic!("file menu entry missing"); 2401 2671 }; ··· 2768 3038 &sample_document(), 2769 3039 &Mode::enter_sketch(SketchId::default()), 2770 3040 &Selection::default(), 2771 - Settings::default(), 3041 + &Settings::default(), 2772 3042 layout_size(1600.0, 900.0), 2773 3043 None, 2774 3044 ); ··· 3161 3431 document, 3162 3432 mode, 3163 3433 selection, 3164 - Settings::default(), 3434 + &Settings::default(), 3165 3435 layout_size(1280.0, 800.0), 3166 3436 None, 3167 3437 )
+175
crates/bone-app/src/shortcut_bar.rs
··· 1 + use bone_ui::frame::FrameCtx; 2 + use bone_ui::hotkey::{ActionId, HotkeyScope}; 3 + use bone_ui::input::{KeyCode, ModifierMask, NamedKey, PointerButton}; 4 + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 + use bone_ui::strings::StringKey; 6 + use bone_ui::theme::{Border, Step12, StrokeWidth}; 7 + use bone_ui::widgets::{ 8 + Button, ButtonState, ButtonVariant, LabelText, TakeKey, WidgetPaint, show_button, take_key, 9 + }; 10 + use bone_ui::{WidgetId, WidgetKey}; 11 + 12 + use crate::hotkeys::{ 13 + COMMANDS, DELETE_SELECTION_ACTION, ENTER_SKETCH_ACTION, EXTEND_ACTION, MIRROR_ACTION, 14 + SELECT_ALL_ACTION, SMART_DIMENSION_ACTION, TOGGLE_CONSTRUCTION_ACTION, TRIM_ACTION, 15 + ZOOM_FIT_ACTION, 16 + }; 17 + use crate::strings as s; 18 + 19 + const ITEM_HEIGHT_PX: f32 = 28.0; 20 + const ITEM_GAP_PX: f32 = 2.0; 21 + const PANEL_WIDTH_PX: f32 = 220.0; 22 + const PANEL_PAD_PX: f32 = 6.0; 23 + const TITLE_HEIGHT_PX: f32 = 24.0; 24 + 25 + #[derive(Copy, Clone, Debug, PartialEq)] 26 + pub struct ShortcutBarState { 27 + pub anchor: LayoutPos, 28 + } 29 + 30 + #[derive(Debug, Default)] 31 + pub struct ShortcutBarOutcome { 32 + pub paints: Vec<WidgetPaint>, 33 + pub activated: Option<ActionId>, 34 + pub dismissed: bool, 35 + } 36 + 37 + const ITEMS: &[(ActionId, StringKey)] = &[ 38 + (ENTER_SKETCH_ACTION, s::HOTKEY_LABEL_SKETCH), 39 + (SMART_DIMENSION_ACTION, s::HOTKEY_LABEL_SMART_DIMENSION), 40 + (TRIM_ACTION, s::HOTKEY_LABEL_TRIM), 41 + (EXTEND_ACTION, s::HOTKEY_LABEL_EXTEND), 42 + (MIRROR_ACTION, s::HOTKEY_LABEL_MIRROR), 43 + ( 44 + TOGGLE_CONSTRUCTION_ACTION, 45 + s::HOTKEY_LABEL_CONSTRUCTION_TOGGLE, 46 + ), 47 + (ZOOM_FIT_ACTION, s::HOTKEY_LABEL_ZOOM_FIT), 48 + (SELECT_ALL_ACTION, s::HOTKEY_LABEL_SELECT_ALL), 49 + (DELETE_SELECTION_ACTION, s::HOTKEY_LABEL_DELETE_SELECTION), 50 + ]; 51 + 52 + #[must_use] 53 + #[allow( 54 + clippy::cast_precision_loss, 55 + reason = "ITEMS.len() and item indices fit in u8; f32 mantissa is fine" 56 + )] 57 + pub fn panel_rect(anchor: LayoutPos, viewport: LayoutSize) -> LayoutRect { 58 + let height = 59 + TITLE_HEIGHT_PX + PANEL_PAD_PX * 2.0 + ITEMS.len() as f32 * (ITEM_HEIGHT_PX + ITEM_GAP_PX) 60 + - ITEM_GAP_PX; 61 + let width = PANEL_WIDTH_PX; 62 + let max_x = (viewport.width.value() - width).max(0.0); 63 + let max_y = (viewport.height.value() - height).max(0.0); 64 + let x = anchor.x.value().min(max_x).max(0.0); 65 + let y = anchor.y.value().min(max_y).max(0.0); 66 + LayoutRect::new( 67 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 68 + LayoutSize::new(LayoutPx::new(width), LayoutPx::new(height)), 69 + ) 70 + } 71 + 72 + #[must_use] 73 + #[allow( 74 + clippy::cast_precision_loss, 75 + reason = "ITEMS.len() and item indices fit in u8; f32 mantissa is fine" 76 + )] 77 + pub fn render( 78 + ctx: &mut FrameCtx<'_>, 79 + state: ShortcutBarState, 80 + viewport: LayoutSize, 81 + is_sketch: bool, 82 + ) -> ShortcutBarOutcome { 83 + let rect = panel_rect(state.anchor, viewport); 84 + let theme = ctx.theme(); 85 + let radius = theme.radius.sm; 86 + let surface_fill = theme.colors.neutral.step(Step12::APP_BG); 87 + let border = Border { 88 + width: StrokeWidth::HAIRLINE, 89 + color: theme.colors.neutral.step(Step12::BORDER), 90 + }; 91 + let elevation = theme.elevation.level3; 92 + let mut paints = vec![ 93 + WidgetPaint::Surface { 94 + rect, 95 + fill: surface_fill, 96 + border: Some(border), 97 + radius, 98 + elevation: Some(elevation), 99 + }, 100 + WidgetPaint::Label { 101 + rect: LayoutRect::new( 102 + LayoutPos::new( 103 + LayoutPx::new(rect.origin.x.value() + PANEL_PAD_PX), 104 + LayoutPx::new(rect.origin.y.value() + PANEL_PAD_PX), 105 + ), 106 + LayoutSize::new( 107 + LayoutPx::new(rect.size.width.value() - PANEL_PAD_PX * 2.0), 108 + LayoutPx::new(TITLE_HEIGHT_PX), 109 + ), 110 + ), 111 + text: LabelText::Key(s::SHORTCUT_BAR_TITLE), 112 + color: theme.colors.text_primary(), 113 + role: theme.typography.label, 114 + }, 115 + ]; 116 + let root_id = WidgetId::ROOT.child(WidgetKey::new("shortcut_bar")); 117 + let row_origin_y = rect.origin.y.value() + PANEL_PAD_PX + TITLE_HEIGHT_PX; 118 + let activated = ITEMS 119 + .iter() 120 + .enumerate() 121 + .fold(None, |found, (i, (action, label))| { 122 + let y = row_origin_y + i as f32 * (ITEM_HEIGHT_PX + ITEM_GAP_PX); 123 + let row_rect = LayoutRect::new( 124 + LayoutPos::new( 125 + LayoutPx::new(rect.origin.x.value() + PANEL_PAD_PX), 126 + LayoutPx::new(y), 127 + ), 128 + LayoutSize::new( 129 + LayoutPx::new(rect.size.width.value() - PANEL_PAD_PX * 2.0), 130 + LayoutPx::new(ITEM_HEIGHT_PX), 131 + ), 132 + ); 133 + let id = root_id.child_indexed(WidgetKey::new("item"), u64::from(action.get().get())); 134 + let item_state = if item_enabled(*action, is_sketch) { 135 + ButtonState::Idle 136 + } else { 137 + ButtonState::Disabled 138 + }; 139 + let response = show_button( 140 + ctx, 141 + Button::new(id, row_rect, *label, ButtonVariant::Secondary).with_state(item_state), 142 + ); 143 + paints.extend(response.paint); 144 + found.or(response.activated.then_some(*action)) 145 + }); 146 + let esc = TakeKey::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 147 + let esc_dismiss = take_key(ctx.input, &[esc]).is_some(); 148 + let outside_click = ctx.input.buttons_pressed.contains(PointerButton::Primary) 149 + && ctx 150 + .input 151 + .pointer 152 + .is_some_and(|sample| !rect.contains(sample.position)); 153 + if outside_click { 154 + ctx.input.buttons_pressed = ctx.input.buttons_pressed.without(PointerButton::Primary); 155 + } 156 + ShortcutBarOutcome { 157 + paints, 158 + activated, 159 + dismissed: esc_dismiss || outside_click, 160 + } 161 + } 162 + 163 + fn item_enabled(action: ActionId, is_sketch: bool) -> bool { 164 + if action == ENTER_SKETCH_ACTION { 165 + return !is_sketch; 166 + } 167 + let Some(scope) = COMMANDS 168 + .iter() 169 + .find(|c| c.action == action) 170 + .map(|c| c.scope) 171 + else { 172 + return true; 173 + }; 174 + !matches!(scope, HotkeyScope::Sketch) || is_sketch 175 + }
+16
crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap
··· 1 + --- 2 + source: crates/bone-app/src/hotkeys.rs 3 + expression: "rendered.join(\"\\n\")" 4 + --- 5 + action=1 chord=Esc scope=Sketch 6 + action=10 chord=Ctrl+N scope=Global 7 + action=11 chord=Ctrl+O scope=Global 8 + action=12 chord=Ctrl+S scope=Global 9 + action=13 chord=Ctrl+A scope=Global 10 + action=14 chord=Delete scope=Global 11 + action=15 chord=F scope=Global 12 + action=16 chord=S scope=Global 13 + action=17 chord=Ctrl+Q scope=Global 14 + action=2 chord=Ctrl+Z scope=Global 15 + action=3 chord=Ctrl+Shift+Z scope=Global 16 + action=3 chord=Ctrl+Y scope=Global
+45
crates/bone-ui/src/focus.rs
··· 72 72 tab_stops: Vec<(WidgetId, FocusScopeId)>, 73 73 tab_stop_ids: BTreeSet<WidgetId>, 74 74 focusable: BTreeSet<WidgetId>, 75 + text_inputs: BTreeSet<WidgetId>, 75 76 request: Option<FocusRequest>, 76 77 } 77 78 ··· 96 97 tab_stops: Vec::new(), 97 98 tab_stop_ids: BTreeSet::new(), 98 99 focusable: BTreeSet::new(), 100 + text_inputs: BTreeSet::new(), 99 101 request: None, 100 102 } 101 103 } ··· 153 155 self.focusable.insert(id); 154 156 } 155 157 158 + pub fn register_text_input(&mut self, id: WidgetId) { 159 + self.text_inputs.insert(id); 160 + } 161 + 162 + #[must_use] 163 + pub fn is_text_input_focused(&self) -> bool { 164 + self.focused 165 + .is_some_and(|id| self.text_inputs.contains(&id)) 166 + } 167 + 156 168 #[must_use] 157 169 pub fn tab_stops(&self) -> &[(WidgetId, FocusScopeId)] { 158 170 &self.tab_stops ··· 174 186 self.tab_stops.clear(); 175 187 self.tab_stop_ids.clear(); 176 188 self.focusable.clear(); 189 + self.text_inputs.clear(); 177 190 self.scopes.truncate(1); 178 191 self.next_scope = FIRST_CHILD_SCOPE; 179 192 } ··· 568 581 f.request_focus(ghost); 569 582 }); 570 583 assert_eq!(focus.focused(), None); 584 + } 585 + 586 + #[test] 587 + fn is_text_input_focused_distinguishes_text_input_from_button() { 588 + let mut focus = FocusManager::new(); 589 + let button = id("button"); 590 + let input = id("input"); 591 + run_frame(&mut focus, |f| { 592 + f.register_focusable(button); 593 + f.register_focusable(input); 594 + f.register_text_input(input); 595 + f.request_focus(button); 596 + }); 597 + assert_eq!(focus.focused(), Some(button)); 598 + assert!( 599 + !focus.is_text_input_focused(), 600 + "button focus must not match" 601 + ); 602 + run_frame(&mut focus, |f| { 603 + f.register_focusable(button); 604 + f.register_focusable(input); 605 + f.register_text_input(input); 606 + f.request_focus(input); 607 + }); 608 + assert_eq!(focus.focused(), Some(input)); 609 + assert!(focus.is_text_input_focused()); 610 + } 611 + 612 + #[test] 613 + fn is_text_input_focused_false_when_nothing_focused() { 614 + let focus = FocusManager::new(); 615 + assert!(!focus.is_text_input_focused()); 571 616 } 572 617 573 618 #[test]
+8 -1
crates/bone-ui/src/frame.rs
··· 2 2 3 3 use bone_text::Shaper; 4 4 5 - use crate::a11y::{AccessNode, AccessTreeBuilder}; 5 + use crate::a11y::{AccessNode, AccessTreeBuilder, Role}; 6 6 use crate::focus::FocusManager; 7 7 use crate::hit_test::{HitFrame, HitItem, HitState, Interaction, Sense, ZLayer}; 8 8 use crate::hotkey::{ActionId, HotkeyScopes, HotkeyTable, KeyChord}; ··· 142 142 self.focus.register_focusable(declaration.id); 143 143 if declaration.focusable { 144 144 self.focus.register_tab_stop(declaration.id); 145 + } 146 + if declaration 147 + .a11y 148 + .as_ref() 149 + .is_some_and(|node| node.role == Role::TextInput) 150 + { 151 + self.focus.register_text_input(declaration.id); 145 152 } 146 153 } 147 154 self.hits.push(HitItem {
+1
crates/bone-ui/src/gallery.rs
··· 574 574 id("hotkey_capture"), 575 575 rect(0.0, 380.0, 200.0, 24.0), 576 576 GALLERY_LABEL, 577 + GALLERY_LABEL, 577 578 &mut state.hotkey, 578 579 ), 579 580 );
+5
crates/bone-ui/src/input/key.rs
··· 109 109 } 110 110 111 111 #[must_use] 112 + pub const fn from_ascii(c: char) -> Self { 113 + Self(c.to_ascii_lowercase()) 114 + } 115 + 116 + #[must_use] 112 117 pub const fn get(self) -> char { 113 118 self.0 114 119 }
+5
crates/bone-ui/src/input/pointer.rs
··· 52 52 pub const fn with(self, button: PointerButton) -> Self { 53 53 Self(self.0 | button.bit()) 54 54 } 55 + 56 + #[must_use] 57 + pub const fn without(self, button: PointerButton) -> Self { 58 + Self(self.0 & !button.bit()) 59 + } 55 60 } 56 61 57 62 #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+47 -5
crates/bone-ui/src/widgets/hotkey_capture.rs
··· 22 22 pub id: WidgetId, 23 23 pub rect: LayoutRect, 24 24 pub placeholder: StringKey, 25 + pub recording_prompt: StringKey, 25 26 pub state: &'state mut HotkeyCaptureState, 26 27 pub disabled: bool, 27 28 } ··· 32 33 id: WidgetId, 33 34 rect: LayoutRect, 34 35 placeholder: StringKey, 36 + recording_prompt: StringKey, 35 37 state: &'state mut HotkeyCaptureState, 36 38 ) -> Self { 37 39 Self { 38 40 id, 39 41 rect, 40 42 placeholder, 43 + recording_prompt, 41 44 state, 42 45 disabled: false, 43 46 } ··· 65 68 id, 66 69 rect, 67 70 placeholder, 71 + recording_prompt, 68 72 state, 69 73 disabled, 70 74 } = capture; ··· 112 116 let paint = build_paint( 113 117 ctx, 114 118 rect, 115 - label_text(placeholder, state.chord, state.recording), 119 + label_text(placeholder, recording_prompt, state.chord, state.recording), 116 120 state.recording, 117 121 disabled, 118 122 interaction, ··· 125 129 } 126 130 } 127 131 128 - fn label_text(placeholder: StringKey, chord: Option<KeyChord>, recording: bool) -> LabelText { 132 + fn label_text( 133 + placeholder: StringKey, 134 + recording_prompt: StringKey, 135 + chord: Option<KeyChord>, 136 + recording: bool, 137 + ) -> LabelText { 129 138 if recording { 130 - return LabelText::Key(placeholder); 139 + return LabelText::Key(recording_prompt); 131 140 } 132 141 chord.map_or_else( 133 142 || LabelText::Key(placeholder), ··· 210 219 use crate::widget_id::{WidgetId, WidgetKey}; 211 220 212 221 const PLACEHOLDER: StringKey = StringKey::new("hotkey.placeholder"); 222 + const RECORDING_PROMPT: StringKey = StringKey::new("hotkey.recording_prompt"); 213 223 214 224 fn id_widget() -> WidgetId { 215 225 WidgetId::ROOT.child(WidgetKey::new("hotkey")) ··· 231 241 let table = HotkeyTable::new(); 232 242 let mut hits = HitFrame::new(); 233 243 let prev = HitState::new(); 234 - let widget = HotkeyCapture::new(id_widget(), rect(), PLACEHOLDER, state); 244 + let widget = HotkeyCapture::new(id_widget(), rect(), PLACEHOLDER, RECORDING_PROMPT, state); 235 245 let mut shaper = bone_text::Shaper::new(); 236 246 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 237 247 let mut ctx = FrameCtx::new( ··· 439 449 } 440 450 441 451 #[test] 452 + fn recording_state_paints_recording_prompt_not_placeholder() { 453 + let mut state = HotkeyCaptureState { 454 + recording: true, 455 + chord: Some(KeyChord::new( 456 + KeyCode::Char(KeyChar::from_char('s')), 457 + ModifierMask::CTRL, 458 + )), 459 + }; 460 + let mut focus = focused_at_widget(); 461 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 462 + let response = run(&mut state, &mut focus, &mut snap); 463 + let key = response.paint.iter().find_map(|p| match p { 464 + super::WidgetPaint::Label { 465 + text: super::LabelText::Key(k), 466 + .. 467 + } => Some(*k), 468 + _ => None, 469 + }); 470 + assert_eq!( 471 + key, 472 + Some(RECORDING_PROMPT), 473 + "recording state must show the recording prompt, not the placeholder label", 474 + ); 475 + } 476 + 477 + #[test] 442 478 fn first_click_starts_recording_despite_one_frame_focus_delay() { 443 479 use crate::hit_test::resolve; 444 480 use crate::input::{PointerButton, PointerButtonMask, PointerSample}; ··· 470 506 471 507 [press, release, idle].into_iter().for_each(|mut snap| { 472 508 let mut hits = HitFrame::new(); 473 - let widget = HotkeyCapture::new(id_widget(), rect(), PLACEHOLDER, &mut state); 509 + let widget = HotkeyCapture::new( 510 + id_widget(), 511 + rect(), 512 + PLACEHOLDER, 513 + RECORDING_PROMPT, 514 + &mut state, 515 + ); 474 516 { 475 517 let mut shaper = bone_text::Shaper::new(); 476 518 let mut a11y = crate::a11y::AccessTreeBuilder::new();
+5 -3
crates/bone-ui/src/widgets/menu.rs
··· 18 18 Action { 19 19 id: WidgetId, 20 20 label: StringKey, 21 - shortcut: Option<StringKey>, 21 + shortcut: Option<super::paint::LabelText>, 22 22 disabled: bool, 23 23 }, 24 24 Submenu { ··· 323 323 if let Some(sc) = shortcut { 324 324 paint.push(WidgetPaint::AlignedLabel { 325 325 rect: shortcut_rect(args.rect, args.metrics), 326 - text: LabelText::Key(*sc), 326 + text: sc.clone(), 327 327 color: ctx.theme().colors.text_secondary(), 328 328 role: ctx.theme().typography.caption, 329 329 align: HorizontalAlign::End, ··· 955 955 MenuItem::Action { 956 956 id: menu_root().child(WidgetKey::new(name)), 957 957 label: StringKey::new("menu.action"), 958 - shortcut: Some(StringKey::new("menu.shortcut")), 958 + shortcut: Some(super::super::paint::LabelText::Key(StringKey::new( 959 + "menu.shortcut", 960 + ))), 959 961 disabled: false, 960 962 } 961 963 }
+4 -2
crates/bone-ui/tests/key_ordering.rs
··· 246 246 &mut a11y, 247 247 &mut shaper, 248 248 ); 249 - let widget = HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, &mut state); 249 + let widget = 250 + HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, FIELD_LABEL, &mut state); 250 251 let response = show_hotkey_capture(&mut ctx, widget); 251 252 let actions = ctx.dispatch_hotkeys(&scopes); 252 253 (actions, response.captured) ··· 302 303 &mut shaper, 303 304 ); 304 305 let actions = ctx.dispatch_hotkeys(&scopes); 305 - let widget = HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, &mut state); 306 + let widget = 307 + HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, FIELD_LABEL, &mut state); 306 308 let response = show_hotkey_capture(&mut ctx, widget); 307 309 (actions, response.captured) 308 310 };