From b3789b280cf9a56b1fe683446aabb145eed47093 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:51 +0000 Subject: [PATCH] refactor(ui-egui): move shortcuts.rs items to shortcuts/{paste,presentation,typing}.rs --- crates/ui-egui/src/shortcuts.rs | 501 +------------------ crates/ui-egui/src/shortcuts/paste.rs | 72 +++ crates/ui-egui/src/shortcuts/presentation.rs | 103 ++++ crates/ui-egui/src/shortcuts/typing.rs | 316 ++++++++++++ 4 files changed, 504 insertions(+), 488 deletions(-) create mode 100644 crates/ui-egui/src/shortcuts/paste.rs create mode 100644 crates/ui-egui/src/shortcuts/presentation.rs create mode 100644 crates/ui-egui/src/shortcuts/typing.rs diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 378590131..251617931 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -11,6 +11,19 @@ use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey}; use crate::VectorcraftApp; +mod paste; +mod presentation; +mod typing; + +use paste::unclip_chords; +pub(crate) use paste::{PasteChord, paste_command}; +pub(crate) use presentation::presentation_step; +use presentation::{escape_pair_id, escape_press, escape_resets_rotation, presentation_keys}; +#[cfg(test)] +use typing::is_shortcut_chord; +pub(crate) use typing::keep_marked_text; +use typing::{drop_typing_presses, type_text}; + /// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere. /// `+` and `=` name one chord key ([`Key::Equals`]): `+` is Shift+`=` on many layouts, so a /// `Cmd+=` chord also answers `+` however it is typed. @@ -91,18 +104,6 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json v } -/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.) -const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste]; - -/// The paste command whose V chord `held` holds (Paste in Place for Cmd+Shift+V by default), else -/// Paste: egui reports every Cmd+V chord as a paste, never as its key. -pub(crate) fn paste_command(held: Modifiers) -> &'static str { - all_shortcuts() - .into_iter() - .find(|(sc, id, _)| sc.logical_key == Key::V && id.starts_with("edit.paste") && held.matches_logically(sc.modifiers)) - .map_or("edit.paste", |(_, id, _)| id) -} - /// Does every press of `key` this frame hold a chord with modifiers that a command shortcut is bound /// to, matched as [`consume`] matches it? `cmd_only`: only chords with Cmd count. False when `key` /// isn't pressed. @@ -140,380 +141,6 @@ pub fn runs_a_shortcut(key: &str, code: &str, modifiers: Modifiers) -> bool { craft_keymap::Layout::default().candidates(&input).iter().any(|c| mirror.keymap.lookup(c, false, &Active::NONE) != craft_keymap::Lookup::None) } -/// Give a copy or cut chord held with more modifiers its key back when it has a shortcut of its own: -/// egui reports every Cmd+C and Cmd+X chord as Copy or Cut, Shift and Alt held too, so Cmd+Shift+C -/// (Align center) copied instead (#1940). -fn unclip_chords(i: &mut egui::InputState) { - if !i.events.iter().any(|e| matches!(e, egui::Event::Copy | egui::Event::Cut)) { - return; - } - let (held, shortcuts) = (i.modifiers, all_shortcuts()); - for e in &mut i.events { - let (key, own) = match e { - egui::Event::Copy => (Key::C, "edit.copy"), - egui::Event::Cut => (Key::X, "edit.cut"), - _ => continue, - }; - if shortcuts.iter().any(|(sc, id, _)| sc.logical_key == key && *id != own && held.matches_exact(sc.modifiers)) { - *e = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: held }; - } - } -} - -/// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox -/// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a -/// Ctrl+Alt+L press and then the text "ł", which the Type tool types. Without the press no command -/// chord runs for it: neither Cmd+Alt+L nor Cmd+L, which egui matches with the extra Alt held. -fn drop_typing_presses(i: &mut egui::InputState) { - let next = i.events.iter().skip(1).map(Some).chain([None]); - let typed: Vec = i - .events - .iter() - .zip(next) - .map(|(e, next)| { - matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.ctrl && modifiers.alt) - && matches!(next, Some(egui::Event::Text(t)) if !t.is_empty()) - }) - .collect(); - let mut typed = typed.into_iter(); - i.events.retain(|_| !typed.next().unwrap_or(false)); -} - -/// Keyboard pastes of something other than text. egui swallows a paste chord's key press and -/// sends a Paste event only when the clipboard holds text, so a paste key released without its -/// press (or a Paste event) reported was a paste of a bitmap or a PDF. -#[derive(Clone, Default)] -pub(crate) struct PasteChord { - /// Paste keys whose press was reported (typed, not pasting). - pressed: Vec, - /// A Paste event came since the last paste key was released. - pasted: bool, -} - -impl PasteChord { - /// Follow one frame's `events` → the modifiers of a paste egui sent no event for (the chord's, - /// as its key is released). - pub(crate) fn textless_paste(&mut self, events: &[egui::Event]) -> Option { - let mut fire = None; - for e in events { - match e { - egui::Event::Paste(_) => self.pasted = true, - // A release that comes while the window is away is never seen. - egui::Event::WindowFocused(false) => *self = Self::default(), - egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) && !self.pressed.contains(key) => self.pressed.push(*key), - egui::Event::Key { key, pressed: false, modifiers, .. } if PASTE_KEYS.contains(key) => { - match self.pressed.iter().position(|k| k == key) { - Some(i) => { - self.pressed.swap_remove(i); - } - // Unless a Paste event came (the guard takes it either way). - None if !std::mem::take(&mut self.pasted) => fire = Some(*modifiers), - None => {} - } - } - _ => {} - } - } - fire - } -} - -/// End an IME composition outside the IME (a click away, the IME going away): the -/// marked text stays as typed, and the system IME is told to drop it. -pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) { - let Some(m) = app.ime_marked.take() else { return }; - app.ime_discard = true; - if app.session.tool_composing() { - // Errors are already reported by the engine's interaction. - let _ = app.session.tool_text(&m, app.view_info()); - } -} - -/// Cmd/Ctrl chords are shortcuts (Create Outlines, Bold, …): their letter is never typed into the -/// text if a host still emits `Text` for them (#2094). Alt is left to type: Option+key on macOS -/// and AltGr (Ctrl+Alt) on Windows keyboards type characters such as é, ø and @. -fn is_shortcut_chord(m: egui::Modifiers) -> bool { - (m.command || m.ctrl) && !m.alt -} - -enum Seq { - Text(String), - Preedit(String, Option>), - Commit(String), - Erase(ToolKey, Mods), - /// An input method replaces text around the composition (upstream's character counts or - /// the fork's exact UTF-8 byte counts), then commits. - DeleteSurrounding(usize, usize, SurroundingUnit), - // The actual pointer/key still belongs to its existing handler. Its position in this - // sequence must nevertheless interrupt an old composition before later terminal input. - InterruptPointer, - InterruptNavigation, - InterruptCommand, - ResetCompleted(egui::ImeResetId), -} - -/// Typed text and IME events, in the order they came, to the Type tool. -fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { - let view = app.view_info(); - // Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes - // on with it when the window comes back (measured with Kotoeri), as the tool does. - let shortcut_mods = ctx.input(|i| is_shortcut_chord(i.modifiers)); - // A frame that began composing keeps its editing keys for the IME: a Backspace that empties the - // marked text must not then delete the committed character before it. - let was_composing = app.session.tool_composing(); - // Text, IME events and plain Backspace/Delete are taken together, in the order they arrived, so - // a keyboard-hook IME (UniKey/OpenKey) whose tone key sends Backspace(s) and then re-injects - // characters in one frame interleaves correctly. Taken as two passes (all text, then the keys) - // the deletes land after the inserts and the diacritics are lost. Each handled event is removed - // so `route_type_input` below does not process the same Backspace/Delete again. - let seq = type_sequence(ctx); - let viewport = ctx.viewport_id(); - let mut old_composition_retired = false; - for e in seq { - // Before the UI, a changed composition or guard is settled `pending`: a token bound here - // could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to - // the stock descriptor. The post-UI finalizer binds the confirmed replacement. - crate::ime::sync_incoming_before_ui(app, ctx); - if let Seq::ResetCompleted(request) = &e { - old_composition_retired |= crate::ime::complete(app, ctx, *request); - continue; - } - if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) { - match &e { - // Accepted old payloads precede the fence even when their terminal arrives. - // None of these events proves the local reset has reached its boundary. - Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue, - _ => {} - } - } - // A pointer/navigation/command notice has not reached its real handler yet. Text and - // erases after it belong to the old caret, not to the destination it will establish. - if crate::ime::deferred(app, ctx) { - match &e { - Seq::Text(_) | Seq::Erase(..) => continue, - _ => {} - } - } - let preedit_empty = match &e { - Seq::Preedit(text, _) => Some(text.is_empty()), - _ => None, - }; - let terminal_commit = matches!(&e, Seq::Commit(_)); - let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..)); - // Errors are already reported by the engine's interaction (a failed preview keeps it). - let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired); - if result.is_ok() && from_ime { - if terminal_commit { - app.ime_compositions.remove(&viewport); - } else { - crate::ime::remember_incoming(app, ctx, preedit_empty); - } - } - } - old_composition_retired -} - -/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived. -fn type_sequence(ctx: &egui::Context) -> Vec { - let mut seq = vec![]; - ctx.input_mut(|i| { - // Move the events out instead of cloning their strings: a keyboard hook that reinjects a - // batch keeps the batch in `seq` only, not in both the input list and a second copy. - let mut kept = Vec::with_capacity(i.events.len()); - // InputState's held flag describes the end of the batch. Recover its initial state from - // the first transition, so a move before a later press cannot cancel an earlier commit. - let mut primary_down = i - .events - .iter() - .find_map(|e| match e { - egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some(!pressed), - _ => None, - }) - .unwrap_or_else(|| i.pointer.primary_down()); - for e in std::mem::take(&mut i.events) { - match e { - egui::Event::Text(t) => seq.push(Seq::Text(t)), - egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { - seq.push(Seq::Preedit(text, active_range_chars)); - } - egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)), - egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { - seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars)); - } - egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { - seq.push(Seq::DeleteSurrounding(before_bytes, after_bytes, SurroundingUnit::Bytes)); - } - egui::Event::ImeResetCompleted { request_id } => seq.push(Seq::ResetCompleted(request_id)), - // X11/Wayland PRIMARY middle-click: text at the pointer, inserted as typed text (never - // routed as a key or shortcut). - egui::Event::MiddleClickPaste { text, .. } => seq.push(Seq::Text(text)), - // Only unmodified Backspace/Delete interleave with injected text; word-delete and other - // modified forms stay with `route_type_input`. - egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. } - if !m.command && !m.alt && !m.ctrl => - { - let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete }; - seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() })); - } - e @ egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => { - primary_down = pressed; - if pressed { - seq.push(Seq::InterruptPointer); - } - kept.push(e); - } - e @ egui::Event::PointerMoved(_) => { - if primary_down { - seq.push(Seq::InterruptPointer); - } - kept.push(e); - } - e @ egui::Event::Key { key, pressed: true, modifiers, .. } - if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End) - || key == Key::A && modifiers.command => - { - seq.push(Seq::InterruptNavigation); - kept.push(e); - } - e @ egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(modifiers) => { - seq.push(Seq::InterruptCommand); - kept.push(e); - } - e @ (egui::Event::Cut | egui::Event::Paste(_)) => { - seq.push(Seq::InterruptCommand); - kept.push(e); - } - other => kept.push(other), - } - } - i.events = kept; - }); - seq -} - -/// Apply one sequence item to the active tool; the caller filters stale/deferred events and -/// turns a successful IME mutation into its receipt. -fn apply_type_event( - app: &mut VectorcraftApp, - ctx: &egui::Context, - view: ViewInfo, - e: Seq, - shortcut_mods: bool, - was_composing: bool, - old_composition_retired: &mut bool, -) -> EngineResult> { - match e { - Seq::InterruptPointer => { - *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); - Ok(vec![]) - } - Seq::InterruptNavigation => { - // Active composition owns its navigation keys. Once cleared, application caret - // navigation interrupts its still-pending terminal event, even in this batch. - if !app.session.tool_composing() { - *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); - } - Ok(vec![]) - } - Seq::InterruptCommand => { - if crate::ime::blocked(app, ctx) { - *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); - } - Ok(vec![]) - } - Seq::ResetCompleted(_) => Ok(vec![]), - // Keys the IME passed through come as text only when nothing is marked. This is an - // application edit (also a hook IME's reinjected characters): `Other` dominates. - Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => { - let target_before = crate::ime::target(&mut app.session, view); - let before = crate::ime::stamp(&mut app.session, view); - let r = app.session.tool_text(&t, view); - if r.is_ok() { - crate::ime::note_application_edit(app, ctx, view, target_before, before); - } - r - } - Seq::Text(_) => Ok(vec![]), - // A pre-edit is not committed text: it changes neither the outgoing snapshot (the - // engine query removes it) nor the cause. - Seq::Preedit(text, active_range_chars) => { - app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty()); - app.session.tool_preedit(&text, active_range_chars, view) - } - // A bare line break confirms the composition (Enter): the marked text, not a newline. - // It is an accepted IME commit of that marked text when the tool inserts the marked - // text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an - // application edit instead. - Seq::Commit(t) if t == "\n" || t == "\r" => { - let target_before = crate::ime::target(&mut app.session, view); - let before = crate::ime::stamp(&mut app.session, view); - let composing = app.session.tool_composing(); - let verbatim = app.session.tool_ime_session_matches(view) - && match app.ime_marked.as_deref() { - Some(m) => app.session.tool_text_is_verbatim(m, view), - None => true, - }; - keep_marked_text(app); - if composing { - if verbatim { - crate::ime::note_ime_apply(app, ctx, view, target_before, before); - } else { - crate::ime::note_application_edit(app, ctx, view, target_before, before); - } - } - Ok(vec![]) - } - // An accepted IME commit: stamp the bounded committed context immediately before and - // after, so only a real mutation (not a no-op, rejection or failure) earns the receipt. - // A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an - // application edit and must never earn `InputMethod`. - Seq::Commit(t) => { - app.ime_marked = None; - let target_before = crate::ime::target(&mut app.session, view); - let before = crate::ime::stamp(&mut app.session, view); - // A document edit since the composition means this commit starts a fresh typing - // session: that reset is application-side, so it cannot earn `InputMethod`. - let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view); - let r = app.session.tool_text(&t, view); - if r.is_ok() { - if verbatim { - crate::ime::note_ime_apply(app, ctx, view, target_before, before); - } else { - crate::ime::note_application_edit(app, ctx, view, target_before, before); - } - } - r - } - // While composing (or in a frame that began composing), editing keys belong to the IME. - Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => { - // Mirror `route_type_input`/`text_key`: surface an erase error in the status bar - // instead of discarding it, and report the successful edit as application-origin. - let target_before = crate::ime::target(&mut app.session, view); - let before = crate::ime::stamp(&mut app.session, view); - let r = app.session.tool_key(tk, m, view); - match &r { - Err(e) => app.status(e.to_string()), - Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before), - } - r - } - Seq::Erase(..) => Ok(vec![]), - // Deleting around the composition starts the same typing session as the commit that - // follows, so the replacement is one undo step; an error reaches the status bar. The - // receipt covers only this accepted deletion. - Seq::DeleteSurrounding(before, after, unit) => { - let target_before = crate::ime::target(&mut app.session, view); - let stamp_before = crate::ime::stamp(&mut app.session, view); - let r = app.session.tool_ime_delete_surrounding(before, after, unit, view); - match &r { - Err(e) => app.status(e.to_string()), - Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before), - } - r - } - } -} - /// Process one frame's events against the keymap at most once per context, viewport and frame, so /// reruns cannot fire a shortcut or tool key twice. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { @@ -1036,108 +663,6 @@ fn settings_on_a_mac(app: &mut VectorcraftApp, ctx: &egui::Context, mac: bool) - pressed } -/// The second press of Escape pressed twice comes less than this many seconds after the first. -const ESCAPE_PAIR_SECONDS: f64 = 1.0; - -/// The key of the last counted Escape's time in egui's temporary data. -fn escape_pair_id() -> egui::Id { - egui::Id::unique("escape-pair") -} - -/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or -/// has another key or pointer button press as well (a key repeat counts as another press, and the -/// modifier keys do not count). Otherwise, whether the last counted Escape came less than -/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the -/// last counted Escape's time. -fn escape_press(ctx: &egui::Context) -> Option { - let (escape, other, now) = ctx.input(|i| { - let (mut escape, mut other) = (false, false); - for e in &i.events { - match e { - egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true, - egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true, - egui::Event::PointerButton { pressed: true, .. } => other = true, - _ => {} - } - } - (escape, other, i.time) - }); - if !escape && !other { - return None; - } - let counted = ctx.data_mut(|d| d.remove_temp::(escape_pair_id())); - (escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS)) -} - -/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open, -/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the -/// Rotate View tool is chosen. -fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool { - app.view().is_some_and(|v| v.rotation != 0.0) - && (second || app.session.tool_id() == "rotateView") - && !egui::Popup::is_any_open(ctx) - && !crate::canvas::ruler_origin_dragging(ctx) -} - -/// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to -/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it -/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on -/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]). -fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option) { - let (mut leave, mut steps) = (false, vec![]); - ctx.input_mut(|i| { - i.events.retain(|e| match e { - egui::Event::Key { key: Key::Escape, pressed: true, .. } => { - leave = true; - false - } - egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => { - steps.push(false); - false - } - egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => { - steps.push(true); - false - } - egui::Event::Key { pressed: true, .. } - | egui::Event::Text(_) - | egui::Event::Ime(_) - | egui::Event::Copy - | egui::Event::Cut - | egui::Event::Paste(_) - | egui::Event::PasteImage(_) - | egui::Event::MiddleClickPaste { .. } => false, - _ => true, - }) - }); - for next in steps { - presentation_step(app, next); - } - if leave { - let _ = app.run("view.presentation", json!({})); - // The Escape that left the mode can start a pair, as in `handle`. - if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { - let now = ctx.input(|i| i.time); - ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); - } - } -} - -/// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar -/// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode. -/// On the last artboard a step forward leaves the view as it is, and so does a step back on the -/// first. -pub(crate) fn presentation_step(app: &mut VectorcraftApp, next: bool) { - let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); - let at = crate::panels::artboards::selected(app, n); - let to = if next { (at + 1 < n).then_some("next") } else { (at > 0).then_some("previous") }; - if let Some(to) = to - && let Err(e) = app.run("view.goToArtboard", json!({ "index": to })) - { - app.status(e); - } -} - /// What a single-key shortcut reads for `text`, typed by the key at `physical`: the text itself, /// or, when it holds no ASCII character (a layout without Latin letters), what that key types on a /// US layout, so V selects the Selection tool whatever the V key types. diff --git a/crates/ui-egui/src/shortcuts/paste.rs b/crates/ui-egui/src/shortcuts/paste.rs new file mode 100644 index 000000000..9f43e76c5 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/paste.rs @@ -0,0 +1,72 @@ +use super::*; + +/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.) +const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste]; + +/// The paste command whose V chord `held` holds (Paste in Place for Cmd+Shift+V by default), else +/// Paste: egui reports every Cmd+V chord as a paste, never as its key. +pub(crate) fn paste_command(held: Modifiers) -> &'static str { + all_shortcuts() + .into_iter() + .find(|(sc, id, _)| sc.logical_key == Key::V && id.starts_with("edit.paste") && held.matches_logically(sc.modifiers)) + .map_or("edit.paste", |(_, id, _)| id) +} + +/// Give a copy or cut chord held with more modifiers its key back when it has a shortcut of its own: +/// egui reports every Cmd+C and Cmd+X chord as Copy or Cut, Shift and Alt held too, so Cmd+Shift+C +/// (Align center) copied instead (#1940). +pub(super) fn unclip_chords(i: &mut egui::InputState) { + if !i.events.iter().any(|e| matches!(e, egui::Event::Copy | egui::Event::Cut)) { + return; + } + let (held, shortcuts) = (i.modifiers, all_shortcuts()); + for e in &mut i.events { + let (key, own) = match e { + egui::Event::Copy => (Key::C, "edit.copy"), + egui::Event::Cut => (Key::X, "edit.cut"), + _ => continue, + }; + if shortcuts.iter().any(|(sc, id, _)| sc.logical_key == key && *id != own && held.matches_exact(sc.modifiers)) { + *e = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: held }; + } + } +} + +/// Keyboard pastes of something other than text. egui swallows a paste chord's key press and +/// sends a Paste event only when the clipboard holds text, so a paste key released without its +/// press (or a Paste event) reported was a paste of a bitmap or a PDF. +#[derive(Clone, Default)] +pub(crate) struct PasteChord { + /// Paste keys whose press was reported (typed, not pasting). + pressed: Vec, + /// A Paste event came since the last paste key was released. + pasted: bool, +} + +impl PasteChord { + /// Follow one frame's `events` → the modifiers of a paste egui sent no event for (the chord's, + /// as its key is released). + pub(crate) fn textless_paste(&mut self, events: &[egui::Event]) -> Option { + let mut fire = None; + for e in events { + match e { + egui::Event::Paste(_) => self.pasted = true, + // A release that comes while the window is away is never seen. + egui::Event::WindowFocused(false) => *self = Self::default(), + egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) && !self.pressed.contains(key) => self.pressed.push(*key), + egui::Event::Key { key, pressed: false, modifiers, .. } if PASTE_KEYS.contains(key) => { + match self.pressed.iter().position(|k| k == key) { + Some(i) => { + self.pressed.swap_remove(i); + } + // Unless a Paste event came (the guard takes it either way). + None if !std::mem::take(&mut self.pasted) => fire = Some(*modifiers), + None => {} + } + } + _ => {} + } + } + fire + } +} diff --git a/crates/ui-egui/src/shortcuts/presentation.rs b/crates/ui-egui/src/shortcuts/presentation.rs new file mode 100644 index 000000000..691922ab7 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/presentation.rs @@ -0,0 +1,103 @@ +use super::*; + +/// The second press of Escape pressed twice comes less than this many seconds after the first. +const ESCAPE_PAIR_SECONDS: f64 = 1.0; + +/// The key of the last counted Escape's time in egui's temporary data. +pub(super) fn escape_pair_id() -> egui::Id { + egui::Id::unique("escape-pair") +} + +/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or +/// has another key or pointer button press as well (a key repeat counts as another press, and the +/// modifier keys do not count). Otherwise, whether the last counted Escape came less than +/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the +/// last counted Escape's time. +pub(super) fn escape_press(ctx: &egui::Context) -> Option { + let (escape, other, now) = ctx.input(|i| { + let (mut escape, mut other) = (false, false); + for e in &i.events { + match e { + egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true, + egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true, + egui::Event::PointerButton { pressed: true, .. } => other = true, + _ => {} + } + } + (escape, other, i.time) + }); + if !escape && !other { + return None; + } + let counted = ctx.data_mut(|d| d.remove_temp::(escape_pair_id())); + (escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS)) +} + +/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open, +/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the +/// Rotate View tool is chosen. +pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool { + app.view().is_some_and(|v| v.rotation != 0.0) + && (second || app.session.tool_id() == "rotateView") + && !egui::Popup::is_any_open(ctx) + && !crate::canvas::ruler_origin_dragging(ctx) +} + +/// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to +/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it +/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on +/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]). +pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option) { + let (mut leave, mut steps) = (false, vec![]); + ctx.input_mut(|i| { + i.events.retain(|e| match e { + egui::Event::Key { key: Key::Escape, pressed: true, .. } => { + leave = true; + false + } + egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => { + steps.push(false); + false + } + egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => { + steps.push(true); + false + } + egui::Event::Key { pressed: true, .. } + | egui::Event::Text(_) + | egui::Event::Ime(_) + | egui::Event::Copy + | egui::Event::Cut + | egui::Event::Paste(_) + | egui::Event::PasteImage(_) + | egui::Event::MiddleClickPaste { .. } => false, + _ => true, + }) + }); + for next in steps { + presentation_step(app, next); + } + if leave { + let _ = app.run("view.presentation", json!({})); + // The Escape that left the mode can start a pair, as in `handle`. + if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { + let now = ctx.input(|i| i.time); + ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); + } + } +} + +/// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar +/// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode. +/// On the last artboard a step forward leaves the view as it is, and so does a step back on the +/// first. +pub(crate) fn presentation_step(app: &mut VectorcraftApp, next: bool) { + let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); + let at = crate::panels::artboards::selected(app, n); + let to = if next { (at + 1 < n).then_some("next") } else { (at > 0).then_some("previous") }; + if let Some(to) = to + && let Err(e) = app.run("view.goToArtboard", json!({ "index": to })) + { + app.status(e); + } +} diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs new file mode 100644 index 000000000..c42e5d57d --- /dev/null +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -0,0 +1,316 @@ +use super::*; + +/// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox +/// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a +/// Ctrl+Alt+L press and then the text "ł", which the Type tool types. Without the press no command +/// chord runs for it: neither Cmd+Alt+L nor Cmd+L, which egui matches with the extra Alt held. +pub(super) fn drop_typing_presses(i: &mut egui::InputState) { + let next = i.events.iter().skip(1).map(Some).chain([None]); + let typed: Vec = i + .events + .iter() + .zip(next) + .map(|(e, next)| { + matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.ctrl && modifiers.alt) + && matches!(next, Some(egui::Event::Text(t)) if !t.is_empty()) + }) + .collect(); + let mut typed = typed.into_iter(); + i.events.retain(|_| !typed.next().unwrap_or(false)); +} + +/// End an IME composition outside the IME (a click away, the IME going away): the +/// marked text stays as typed, and the system IME is told to drop it. +pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) { + let Some(m) = app.ime_marked.take() else { return }; + app.ime_discard = true; + if app.session.tool_composing() { + // Errors are already reported by the engine's interaction. + let _ = app.session.tool_text(&m, app.view_info()); + } +} + +/// Cmd/Ctrl chords are shortcuts (Create Outlines, Bold, …): their letter is never typed into the +/// text if a host still emits `Text` for them (#2094). Alt is left to type: Option+key on macOS +/// and AltGr (Ctrl+Alt) on Windows keyboards type characters such as é, ø and @. +pub(super) fn is_shortcut_chord(m: egui::Modifiers) -> bool { + (m.command || m.ctrl) && !m.alt +} + +enum Seq { + Text(String), + Preedit(String, Option>), + Commit(String), + Erase(ToolKey, Mods), + /// An input method replaces text around the composition (upstream's character counts or + /// the fork's exact UTF-8 byte counts), then commits. + DeleteSurrounding(usize, usize, SurroundingUnit), + // The actual pointer/key still belongs to its existing handler. Its position in this + // sequence must nevertheless interrupt an old composition before later terminal input. + InterruptPointer, + InterruptNavigation, + InterruptCommand, + ResetCompleted(egui::ImeResetId), +} + +/// Typed text and IME events, in the order they came, to the Type tool. +pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { + let view = app.view_info(); + // Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes + // on with it when the window comes back (measured with Kotoeri), as the tool does. + let shortcut_mods = ctx.input(|i| is_shortcut_chord(i.modifiers)); + // A frame that began composing keeps its editing keys for the IME: a Backspace that empties the + // marked text must not then delete the committed character before it. + let was_composing = app.session.tool_composing(); + // Text, IME events and plain Backspace/Delete are taken together, in the order they arrived, so + // a keyboard-hook IME (UniKey/OpenKey) whose tone key sends Backspace(s) and then re-injects + // characters in one frame interleaves correctly. Taken as two passes (all text, then the keys) + // the deletes land after the inserts and the diacritics are lost. Each handled event is removed + // so `route_type_input` below does not process the same Backspace/Delete again. + let seq = type_sequence(ctx); + let viewport = ctx.viewport_id(); + let mut old_composition_retired = false; + for e in seq { + // Before the UI, a changed composition or guard is settled `pending`: a token bound here + // could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to + // the stock descriptor. The post-UI finalizer binds the confirmed replacement. + crate::ime::sync_incoming_before_ui(app, ctx); + if let Seq::ResetCompleted(request) = &e { + old_composition_retired |= crate::ime::complete(app, ctx, *request); + continue; + } + if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) { + match &e { + // Accepted old payloads precede the fence even when their terminal arrives. + // None of these events proves the local reset has reached its boundary. + Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue, + _ => {} + } + } + // A pointer/navigation/command notice has not reached its real handler yet. Text and + // erases after it belong to the old caret, not to the destination it will establish. + if crate::ime::deferred(app, ctx) { + match &e { + Seq::Text(_) | Seq::Erase(..) => continue, + _ => {} + } + } + let preedit_empty = match &e { + Seq::Preedit(text, _) => Some(text.is_empty()), + _ => None, + }; + let terminal_commit = matches!(&e, Seq::Commit(_)); + let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..)); + // Errors are already reported by the engine's interaction (a failed preview keeps it). + let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired); + if result.is_ok() && from_ime { + if terminal_commit { + app.ime_compositions.remove(&viewport); + } else { + crate::ime::remember_incoming(app, ctx, preedit_empty); + } + } + } + old_composition_retired +} + +/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived. +fn type_sequence(ctx: &egui::Context) -> Vec { + let mut seq = vec![]; + ctx.input_mut(|i| { + // Move the events out instead of cloning their strings: a keyboard hook that reinjects a + // batch keeps the batch in `seq` only, not in both the input list and a second copy. + let mut kept = Vec::with_capacity(i.events.len()); + // InputState's held flag describes the end of the batch. Recover its initial state from + // the first transition, so a move before a later press cannot cancel an earlier commit. + let mut primary_down = i + .events + .iter() + .find_map(|e| match e { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some(!pressed), + _ => None, + }) + .unwrap_or_else(|| i.pointer.primary_down()); + for e in std::mem::take(&mut i.events) { + match e { + egui::Event::Text(t) => seq.push(Seq::Text(t)), + egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { + seq.push(Seq::Preedit(text, active_range_chars)); + } + egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)), + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { + seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars)); + } + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { + seq.push(Seq::DeleteSurrounding(before_bytes, after_bytes, SurroundingUnit::Bytes)); + } + egui::Event::ImeResetCompleted { request_id } => seq.push(Seq::ResetCompleted(request_id)), + // X11/Wayland PRIMARY middle-click: text at the pointer, inserted as typed text (never + // routed as a key or shortcut). + egui::Event::MiddleClickPaste { text, .. } => seq.push(Seq::Text(text)), + // Only unmodified Backspace/Delete interleave with injected text; word-delete and other + // modified forms stay with `route_type_input`. + egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. } + if !m.command && !m.alt && !m.ctrl => + { + let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete }; + seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() })); + } + e @ egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => { + primary_down = pressed; + if pressed { + seq.push(Seq::InterruptPointer); + } + kept.push(e); + } + e @ egui::Event::PointerMoved(_) => { + if primary_down { + seq.push(Seq::InterruptPointer); + } + kept.push(e); + } + e @ egui::Event::Key { key, pressed: true, modifiers, .. } + if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End) + || key == Key::A && modifiers.command => + { + seq.push(Seq::InterruptNavigation); + kept.push(e); + } + e @ egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(modifiers) => { + seq.push(Seq::InterruptCommand); + kept.push(e); + } + e @ (egui::Event::Cut | egui::Event::Paste(_)) => { + seq.push(Seq::InterruptCommand); + kept.push(e); + } + other => kept.push(other), + } + } + i.events = kept; + }); + seq +} + +/// Apply one sequence item to the active tool; the caller filters stale/deferred events and +/// turns a successful IME mutation into its receipt. +fn apply_type_event( + app: &mut VectorcraftApp, + ctx: &egui::Context, + view: ViewInfo, + e: Seq, + shortcut_mods: bool, + was_composing: bool, + old_composition_retired: &mut bool, +) -> EngineResult> { + match e { + Seq::InterruptPointer => { + *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); + Ok(vec![]) + } + Seq::InterruptNavigation => { + // Active composition owns its navigation keys. Once cleared, application caret + // navigation interrupts its still-pending terminal event, even in this batch. + if !app.session.tool_composing() { + *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); + } + Ok(vec![]) + } + Seq::InterruptCommand => { + if crate::ime::blocked(app, ctx) { + *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); + } + Ok(vec![]) + } + Seq::ResetCompleted(_) => Ok(vec![]), + // Keys the IME passed through come as text only when nothing is marked. This is an + // application edit (also a hook IME's reinjected characters): `Other` dominates. + Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => { + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + let r = app.session.tool_text(&t, view); + if r.is_ok() { + crate::ime::note_application_edit(app, ctx, view, target_before, before); + } + r + } + Seq::Text(_) => Ok(vec![]), + // A pre-edit is not committed text: it changes neither the outgoing snapshot (the + // engine query removes it) nor the cause. + Seq::Preedit(text, active_range_chars) => { + app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty()); + app.session.tool_preedit(&text, active_range_chars, view) + } + // A bare line break confirms the composition (Enter): the marked text, not a newline. + // It is an accepted IME commit of that marked text when the tool inserts the marked + // text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an + // application edit instead. + Seq::Commit(t) if t == "\n" || t == "\r" => { + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + let composing = app.session.tool_composing(); + let verbatim = app.session.tool_ime_session_matches(view) + && match app.ime_marked.as_deref() { + Some(m) => app.session.tool_text_is_verbatim(m, view), + None => true, + }; + keep_marked_text(app); + if composing { + if verbatim { + crate::ime::note_ime_apply(app, ctx, view, target_before, before); + } else { + crate::ime::note_application_edit(app, ctx, view, target_before, before); + } + } + Ok(vec![]) + } + // An accepted IME commit: stamp the bounded committed context immediately before and + // after, so only a real mutation (not a no-op, rejection or failure) earns the receipt. + // A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an + // application edit and must never earn `InputMethod`. + Seq::Commit(t) => { + app.ime_marked = None; + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + // A document edit since the composition means this commit starts a fresh typing + // session: that reset is application-side, so it cannot earn `InputMethod`. + let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view); + let r = app.session.tool_text(&t, view); + if r.is_ok() { + if verbatim { + crate::ime::note_ime_apply(app, ctx, view, target_before, before); + } else { + crate::ime::note_application_edit(app, ctx, view, target_before, before); + } + } + r + } + // While composing (or in a frame that began composing), editing keys belong to the IME. + Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => { + // Mirror `route_type_input`/`text_key`: surface an erase error in the status bar + // instead of discarding it, and report the successful edit as application-origin. + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + let r = app.session.tool_key(tk, m, view); + match &r { + Err(e) => app.status(e.to_string()), + Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before), + } + r + } + Seq::Erase(..) => Ok(vec![]), + // Deleting around the composition starts the same typing session as the commit that + // follows, so the replacement is one undo step; an error reaches the status bar. The + // receipt covers only this accepted deletion. + Seq::DeleteSurrounding(before, after, unit) => { + let target_before = crate::ime::target(&mut app.session, view); + let stamp_before = crate::ime::stamp(&mut app.session, view); + let r = app.session.tool_ime_delete_surrounding(before, after, unit, view); + match &r { + Err(e) => app.status(e.to_string()), + Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before), + } + r + } + } +}