diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bb0cb06e0..504e84b2d 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,54 @@ 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. +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(_))); + // 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() + .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(|| ends_with_press(j).then_some(j + 1)); + 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 +907,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..6bce0cc6f 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1470,6 +1470,38 @@ 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"); + // 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"); + // 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() + }; + 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.