From 8579aa07e36b8ba1a8290462ec8f1c398498d3f2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:08:52 -0700 Subject: [PATCH 1/4] Shortcuts: in the web build a key-position shortcut uses the key of the same press eframe's web build sends a keystroke's text before its key press. On a layout without Latin letters, the one-key shortcuts paired each text with the last key press before it, so in the web build a lone keystroke had no key and ran no shortcut, and a second keystroke in the same frame used the first one's key. Each text now takes the press right before it, unless an earlier text in the frame took that press, and otherwise the press right after it. The one-key shortcuts and the shortcut names a tool sees while it is busy use the same pairing. The desktop order (key, then text) pairs as before. --- crates/ui-egui/src/shortcuts.rs | 48 ++++++++++++++++++++------- crates/ui-egui/src/shortcuts/tests.rs | 25 ++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bb0cb06e0..086b32bd5 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -358,15 +358,18 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). let events: Vec<(String, Modifiers)> = ctx.input(|i| { - let mut physical = None; + let typed_by = typing_presses(&i.events); i.events .iter() - .filter_map(|e| match e { - egui::Event::Key { physical_key, pressed: true, .. } => { - physical = *physical_key; - None + .zip(typed_by) + .filter_map(|(e, by)| match e { + egui::Event::Text(t) => { + let physical = match by.and_then(|k| i.events.get(k)) { + Some(egui::Event::Key { physical_key, .. }) => *physical_key, + _ => None, + }; + Some((latin(t, physical), i.modifiers)) } - egui::Event::Text(t) => Some((latin(t, physical), i.modifiers)), _ => None, }) .collect() @@ -843,18 +846,39 @@ fn latin(text: &str, physical: Option) -> String { } } +/// For each event, the index of the key press that typed it when it is text: the press right +/// before it (egui-winit) unless an earlier text took that press, or else the press right after it +/// (eframe's web build with no text field focused). Each press types at most one text. +fn typing_presses(events: &[egui::Event]) -> Vec> { + let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { pressed: true, .. })); + let mut taken = None; + events + .iter() + .enumerate() + .map(|(j, e)| { + if !matches!(e, egui::Event::Text(_)) { + return None; + } + let before = j.checked_sub(1).filter(|&k| is_press(k) && taken != Some(k)); + let by = before.or_else(|| Some(j + 1).filter(|&k| is_press(k))); + taken = by.or(taken); + by + }) + .collect() +} + /// Each new press's shortcut names, first match first, and the letter it types, without Cmd, Ctrl /// or Alt. A text-only press counts as well (Shift+~ arrives as the text `~` with Shift), unless -/// the key that typed it repeated. A text's key is the press right before it (egui-winit) or, -/// failing that, right after it (eframe's web build with no text field focused). Text reads its -/// key's modifiers, or the latest modifier change (`held` before any), and tries its key's own -/// name first. +/// the key that typed it repeated. A text's key is the one `typing_presses` pairs it with. Text +/// reads its key's modifiers, or the latest modifier change (`held` before any), and tries its +/// key's own name first. fn shortcut_presses(events: &[egui::Event], held: Modifiers) -> Vec<(Vec, Option)> { let key_name = |key: &Key, m: &Modifiers| if m.shift { format!("Shift+{}", key.name()) } else { key.name().to_string() }; let press_at = |j: Option| match j.and_then(|j| events.get(j)) { Some(egui::Event::Key { key, pressed: true, repeat, modifiers, .. }) => Some((*repeat, *modifiers, key_name(key, modifiers))), _ => None, }; + let typed_by = typing_presses(events); let mut latest = held; events .iter() @@ -868,8 +892,8 @@ fn shortcut_presses(events: &[egui::Event], held: Modifiers) -> Vec<(Vec Some((vec![key_name(key, modifiers)], letter_of(*key))) } egui::Event::Text(t) => { - let typed_by = press_at(j.checked_sub(1)).or_else(|| press_at(Some(j + 1))); - let (repeated, mods, key_name) = typed_by.map_or((false, latest, None), |(r, m, n)| (r, m, Some(n))); + let (repeated, mods, key_name) = + press_at(typed_by.get(j).copied().flatten()).map_or((false, latest, None), |(r, m, n)| (r, m, Some(n))); if repeated || mods.command || mods.alt || mods.ctrl { return None; } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 553c57df4..49c41536e 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1470,6 +1470,31 @@ fn one_key_shortcuts_follow_the_key_on_layouts_without_latin_letters() { assert_eq!(app.session.tool_id(), "directSelection", "AZERTY: the letter typed"); } +/// The web build sends a keystroke's text before its key press: the one-key shortcut on a layout +/// without Latin letters still goes by the key of the same keystroke, and two keystrokes in one +/// frame each use their own key, in either order. +#[test] +fn one_key_shortcuts_use_the_key_of_the_same_keystroke_in_either_order() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let press = |key| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let text = |t: &str| egui::Event::Text(t.into()); + frame(&mut app, vec![text("з"), press(Key::P)]); + assert_eq!(app.session.tool_id(), "pen", "text before its key"); + frame(&mut app, vec![text("м"), press(Key::V), text("з"), press(Key::P)]); + assert_eq!(app.session.tool_id(), "pen", "the second text uses the second key"); + app.select_tool("pen"); + frame(&mut app, vec![press(Key::P), text("з"), press(Key::V), text("м")]); + assert_eq!(app.session.tool_id(), "selection", "key before its text"); + let names = |events: &[egui::Event]| -> Vec { + shortcut_presses(events, Modifiers::NONE).into_iter().filter_map(|(n, _)| n.into_iter().next()).collect() + }; + let web = names(&[text("м"), press(Key::V), text("з"), press(Key::P)]); + assert_eq!(web, ["V", "V", "P", "P"], "each text tries its own key's name first"); + let desktop = names(&[press(Key::V), text("м"), press(Key::P), text("з")]); + assert_eq!(desktop, ["V", "V", "P", "P"]); +} + #[test] fn plus_typed_any_way_zooms_in() { assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aef1c3790..a4207f302 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -617,6 +617,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. + - On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. From 012773959762773fb96a4120fd6937e02d0c43da Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:33:38 -0700 Subject: [PATCH 2/4] Shortcuts: a text with no key press of its own leaves the next keystroke's press to that keystroke's text A text-only event right before a desktop keystroke in the same frame ([text, key, text]) took the key press as the press after it, which left the keystroke's own text with no key. A text now takes the press after it only when every later text in the same run of alternating texts and presses has a press after it too, as in the web build's [text, key] order; otherwise the press goes to the text after it. --- crates/ui-egui/src/shortcuts.rs | 19 +++++++++++++++++-- crates/ui-egui/src/shortcuts/tests.rs | 5 +++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 086b32bd5..39fbc2a2c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -848,9 +848,24 @@ fn latin(text: &str, physical: Option) -> String { /// For each event, the index of the key press that typed it when it is text: the press right /// before it (egui-winit) unless an earlier text took that press, or else the press right after it -/// (eframe's web build with no text field focused). Each press types at most one text. +/// (eframe's web build with no text field focused). Each press types at most one text. A text +/// takes the press after it only when every later text in the same run of alternating texts and +/// presses has a press after it too; otherwise the text after that press takes it as the press +/// before it, and this text has none. fn typing_presses(events: &[egui::Event]) -> Vec> { let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { pressed: true, .. })); + let is_text = |k: usize| matches!(events.get(k), Some(egui::Event::Text(_))); + // Whether the run of alternating texts and presses that starts with the text at `j` ends + // with a press. + let ends_with_press = |mut j: usize| loop { + if !is_press(j + 1) { + break false; + } + if !is_text(j + 2) { + break true; + } + j += 2; + }; let mut taken = None; events .iter() @@ -860,7 +875,7 @@ fn typing_presses(events: &[egui::Event]) -> Vec> { return None; } let before = j.checked_sub(1).filter(|&k| is_press(k) && taken != Some(k)); - let by = before.or_else(|| Some(j + 1).filter(|&k| is_press(k))); + let by = before.or_else(|| ends_with_press(j).then_some(j + 1)); taken = by.or(taken); by }) diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 49c41536e..e07c31a74 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1486,6 +1486,11 @@ fn one_key_shortcuts_use_the_key_of_the_same_keystroke_in_either_order() { app.select_tool("pen"); frame(&mut app, vec![press(Key::P), text("з"), press(Key::V), text("м")]); assert_eq!(app.session.tool_id(), "selection", "key before its text"); + // A text with no key press of its own right before a desktop keystroke leaves that press to + // the keystroke's text. + assert_eq!(typing_presses(&[text("p"), press(Key::V), text("м")]), [None, None, Some(1)]); + frame(&mut app, vec![text("p"), press(Key::V), text("м")]); + assert_eq!(app.session.tool_id(), "selection", "the text after the key uses it"); let names = |events: &[egui::Event]| -> Vec { shortcut_presses(events, Modifiers::NONE).into_iter().filter_map(|(n, _)| n.into_iter().next()).collect() }; From d3ea958f8f8240fb42f5d3030809d7328f9b4a6c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:47:09 -0700 Subject: [PATCH 3/4] Shortcuts: a modifier key press is never the key that typed a text In the web build, pressing Shift and then a letter in one frame sends the Shift press, the letter's text and then the letter's key press. The text paired with the Shift press as the press before it, so a key-position shortcut looked up Shift instead of the letter, and a held Shift's repeated presses dropped the text as a repeat. Modifier key presses now never pair with a text. --- crates/ui-egui/src/shortcuts.rs | 5 +++-- crates/ui-egui/src/shortcuts/tests.rs | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 39fbc2a2c..435758b85 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -848,12 +848,13 @@ fn latin(text: &str, physical: Option) -> String { /// For each event, the index of the key press that typed it when it is text: the press right /// before it (egui-winit) unless an earlier text took that press, or else the press right after it -/// (eframe's web build with no text field focused). Each press types at most one text. A text +/// (eframe's web build with no text field focused). Modifier key presses type no text. Each press +/// types at most one text. A text /// takes the press after it only when every later text in the same run of alternating texts and /// presses has a press after it too; otherwise the text after that press takes it as the press /// before it, and this text has none. fn typing_presses(events: &[egui::Event]) -> Vec> { - let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { pressed: true, .. })); + let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { key, pressed: true, .. }) if !is_modifier(*key)); let is_text = |k: usize| matches!(events.get(k), Some(egui::Event::Text(_))); // Whether the run of alternating texts and presses that starts with the text at `j` ends // with a press. diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index e07c31a74..6bce0cc6f 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1491,6 +1491,8 @@ fn one_key_shortcuts_use_the_key_of_the_same_keystroke_in_either_order() { assert_eq!(typing_presses(&[text("p"), press(Key::V), text("м")]), [None, None, Some(1)]); frame(&mut app, vec![text("p"), press(Key::V), text("м")]); assert_eq!(app.session.tool_id(), "selection", "the text after the key uses it"); + // A modifier key press does not type text, so the web text after Shift uses the key after it. + assert_eq!(typing_presses(&[press(Key::ShiftLeft), text("З"), press(Key::P)]), [None, Some(2), None]); let names = |events: &[egui::Event]| -> Vec { shortcut_presses(events, Modifiers::NONE).into_iter().filter_map(|(n, _)| n.into_iter().next()).collect() }; From 2d9ceb8137b96a434908bfd0b51f131eb3ef7243 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:51:09 -0700 Subject: [PATCH 4/4] Shortcuts: rewrap the typing_presses doc comment --- crates/ui-egui/src/shortcuts.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 435758b85..504e84b2d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -849,10 +849,9 @@ fn latin(text: &str, physical: Option) -> String { /// For each event, the index of the key press that typed it when it is text: the press right /// before it (egui-winit) unless an earlier text took that press, or else the press right after it /// (eframe's web build with no text field focused). Modifier key presses type no text. Each press -/// types at most one text. A text -/// takes the press after it only when every later text in the same run of alternating texts and -/// presses has a press after it too; otherwise the text after that press takes it as the press -/// before it, and this text has none. +/// types at most one text. A text takes the press after it only when every later text in the same +/// run of alternating texts and presses has a press after it too; otherwise the text after that +/// press takes it as the press before it, and this text has none. fn typing_presses(events: &[egui::Event]) -> Vec> { let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { key, pressed: true, .. }) if !is_modifier(*key)); let is_text = |k: usize| matches!(events.get(k), Some(egui::Event::Text(_)));