From b6649a3fad13b1c9549d58eb61f038157f1469bf Mon Sep 17 00:00:00 2001 From: theironchef <6278036+theironchef@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:31:30 -0700 Subject: [PATCH] Fix #280: shortcuts with Shift and a punctuation key fire as typed egui reports the character a key types, so Cmd+Shift+[ arrives as Cmd+Shift+{ (], / likewise as }, ?) and Shift+` as the text ~, and these default shortcuts never matched: Bring to Front, Send to Back, Search Commands and the Curvature tool. Command chords now also take the shifted character, single-key tool and command shortcuts also look up Shift+, and the Keyboard Shortcuts editor records such a chord as its key (Cmd+Shift+], not Cmd+Shift+}), so conflicts show. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/shortcut_editor.rs | 6 +++ crates/ui-egui/src/shortcuts.rs | 55 +++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 73325f4d..e3a7eee4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,7 +159,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | | | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** | diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 4aedb63a..a626e315 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -189,6 +189,12 @@ pub fn format(sc: &KeyboardShortcut) -> String { } let k = sc.logical_key; let name = match k { + // A shifted punctuation key is recorded as the key, not the character it types. + Key::CloseCurlyBracket if m.shift => "]", + Key::OpenCurlyBracket if m.shift => "[", + Key::Questionmark if m.shift => "/", + Key::Colon if m.shift => ";", + Key::Pipe if m.shift => "\\", Key::CloseBracket => "]", Key::OpenBracket => "[", Key::Semicolon => ";", diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bd4edaa9..52e61f3c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -75,7 +75,22 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json /// is ignored). `native`: the system menu already handles the chord as written, so only that /// alias is left to match here. pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut, native: bool) -> bool { - (!native && i.consume_shortcut(sc)) || (sc.logical_key == Key::Equals && i.consume_key(sc.modifiers, Key::Plus)) + (!native && i.consume_shortcut(sc)) + || (sc.logical_key == Key::Equals && i.consume_key(sc.modifiers, Key::Plus)) + || (sc.modifiers.shift && shifted(sc.logical_key).is_some_and(|k| i.consume_key(sc.modifiers, k))) +} + +/// The key a shifted punctuation key arrives as: egui reports the character typed, so Cmd+Shift+[ +/// comes in as Cmd+Shift+`{` (US-style layouts). +fn shifted(k: Key) -> Option { + Some(match k { + Key::OpenBracket => Key::OpenCurlyBracket, + Key::CloseBracket => Key::CloseCurlyBracket, + Key::Slash => Key::Questionmark, + Key::Semicolon => Key::Colon, + Key::Backslash => Key::Pipe, + _ => return None, + }) } /// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.) @@ -371,12 +386,16 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } let upper = text.to_uppercase(); let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() }; + // A shifted punctuation character can also be written with its Shift (the Curvature tool's + // Shift+~ arrives as `~`). + let shifted = m.shift.then(|| format!("Shift+{text}")); + let lookup = |find: fn(&str) -> Option<&'static str>| find(&key).or_else(|| find(&text)).or_else(|| shifted.as_deref().and_then(find)); // Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default). - if let Some(id) = crate::shortcut_editor::command_for_key(&key).or_else(|| crate::shortcut_editor::command_for_key(&text)) { + if let Some(id) = lookup(crate::shortcut_editor::command_for_key) { let _ = app.run(id, json!({})); continue; } - if let Some(t) = crate::shortcut_editor::tool_for_key(&key).or_else(|| crate::shortcut_editor::tool_for_key(&text)) { + if let Some(t) = lookup(crate::shortcut_editor::tool_for_key) { app.select_tool(t); } } @@ -442,6 +461,36 @@ mod tests { assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour"); } + #[test] + fn shifted_punctuation_shortcuts_work_as_typed() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let a = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone(); + let b = app.session.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + let order = + |app: &VectorcraftApp| -> Vec { app.session.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id.0).collect() }; + let (a, b) = (a.as_u64().unwrap(), b.as_u64().unwrap()); + assert_eq!(order(&app), [a, b]); + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + // Recorded in the Keyboard Shortcuts editor, they read as the keys (and so conflict). + assert_eq!(crate::shortcut_editor::chord_from_event(Key::CloseCurlyBracket, cmd_shift).as_deref(), Some("Cmd+Shift+]")); + assert_eq!(crate::shortcut_editor::chord_from_event(Key::Questionmark, cmd_shift).as_deref(), Some("Cmd+Shift+/")); + // Cmd+Shift+] arrives as Cmd+Shift+`}`, Cmd+Shift+[ as Cmd+Shift+`{`. + frame(&mut app, vec![press(Key::CloseCurlyBracket, cmd_shift)]); + assert_eq!(order(&app), [b, a], "Bring to Front"); + frame(&mut app, vec![press(Key::OpenCurlyBracket, cmd_shift)]); + assert_eq!(order(&app), [a, b], "Send to Back"); + // Cmd+Shift+/ arrives as Cmd+Shift+`?`: Search Commands. + frame(&mut app, vec![press(Key::Questionmark, cmd_shift)]); + assert!(app.ui.palette_open, "Search Commands"); + app.ui.palette_open = false; + // The Curvature tool's Shift+~ arrives as the text `~` with Shift. + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), egui::Event::Text("~".into())]); + assert_eq!(app.session.tool_id(), "curvature"); + } + #[test] fn plus_typed_any_way_zooms_in() { assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key");