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+<typed text>, 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 <noreply@anthropic.com>
This commit is contained in:
theironchef 2026-10-06 22:31:30 -07:00 committed by bflatastic
parent 49dc3b90be
commit b6649a3fad
3 changed files with 59 additions and 4 deletions

View File

@ -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 | | 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 | | 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 | | 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 | | — | 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 | | — | 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** | | | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |

View File

@ -189,6 +189,12 @@ pub fn format(sc: &KeyboardShortcut) -> String {
} }
let k = sc.logical_key; let k = sc.logical_key;
let name = match k { 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::CloseBracket => "]",
Key::OpenBracket => "[", Key::OpenBracket => "[",
Key::Semicolon => ";", Key::Semicolon => ";",

View File

@ -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 /// is ignored). `native`: the system menu already handles the chord as written, so only that
/// alias is left to match here. /// alias is left to match here.
pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut, native: bool) -> bool { 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<Key> {
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.) /// 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 upper = text.to_uppercase();
let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() }; 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). // 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!({})); let _ = app.run(id, json!({}));
continue; 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); app.select_tool(t);
} }
} }
@ -442,6 +461,36 @@ mod tests {
assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour"); 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<u64> { 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] #[test]
fn plus_typed_any_way_zooms_in() { fn plus_typed_any_way_zooms_in() {
assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key"); assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key");