From e0ff4b0c3d20641a94d4ec90f7471e2cf2e6f92b Mon Sep 17 00:00:00 2001 From: Juan Camilo Agudelo Date: Mon, 19 Oct 2026 02:04:54 -0500 Subject: [PATCH] Paste: Ctrl+V pastes a clipboard picture even when Ctrl is released before V (#3049) (#3058) * Paste: Ctrl+V pastes a clipboard picture even when Ctrl is released before V (#3049) egui-winit keeps the Mod+V press, so a clipboard picture is pasted on V's release, which required Mod still held. Letting Ctrl up first (the natural one-hand Left Ctrl+V) pasted nothing. A V release with no V press seen was Mod+V: use the modifiers held with Mod then, so Mod+Shift+V and Mod+Alt+V stay apart. Fixes #3049 * Paste: record the modifiers held with Mod in event order (#3049) Mod can go down and up within one frame, so the frame's modifiers may no longer have it when the canvas reads them. Take them from each ModifiersChanged and key press as they arrive. Review feedback on #3058. * Paste: document the Mod+V release helpers and tests (#3049) --- crates/ui-egui/src/canvas.rs | 7 ++ crates/ui-egui/src/keys.rs | 131 ++++++++++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4feeee056..fabe1d43d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -148,6 +148,11 @@ pub struct CanvasState { pub balloon_rects: Vec<(u32, Rect)>, /// A paste event arrived since the last Mod+V release (see `keys::canvas_events`). pub(crate) pasted: bool, + /// A V (or Paste key) press reached the canvas since its last release. egui-winit keeps Mod+V + /// presses, so a release without one was Mod+V even when Mod went up first. + pub(crate) v_pressed: bool, + /// The modifiers last held with Mod, for a Mod+V whose Mod is released before V. + pub(crate) paste_mods: Option, /// Draw Table or Eraser is on (`table_pen`): presses draw instead of moving the caret. pub table_tool: Option, /// The Draw Table stroke (or eraser press) in progress. @@ -258,6 +263,8 @@ impl Default for CanvasState { mini_anchor: None, mini_dismissed: None, pasted: false, + v_pressed: false, + paste_mods: None, swallow_text: false, ime_join_text: None, ime_reset: false, diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 358f7cc8f..865059a3e 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -200,6 +200,20 @@ fn is_paste_release(key: Key, modifiers: egui::Modifiers) -> bool { (modifiers.command || key == Key::Paste) && !modifiers.alt && !modifiers.shift } +/// The modifiers a V release stands for: its own, or, when Mod went up before V and no V press +/// came (egui-winit keeps Mod+V presses), those held with Mod. +fn paste_release_modifiers(released: Modifiers, press_seen: bool, held: Option) -> Modifiers { + if released.command || press_seen { released } else { held.unwrap_or(released) } +} + +/// Whether `key` is a Shift, Ctrl, Alt or Super key, whose own presses don't end a Mod+V. +fn is_modifier_key(key: Key) -> bool { + matches!( + key, + Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight + ) +} + /// Whether releasing `key` with `modifiers` is Mod+Alt+V (Paste Special). fn is_paste_special_release(key: Key, modifiers: egui::Modifiers) -> bool { key == Key::V && modifiers.command && modifiers.alt && !modifiers.shift @@ -241,7 +255,26 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { let alt_codes = alt_codes_supported(ctx); // `DeleteSurrounding` and the text replacing what it erased arrive in the same frame. app.canvas.ime_join_text = None; + // Mod held since an earlier frame (its ModifiersChanged went by then). + let held = ctx.input(|i| i.modifiers); + if held.command { + app.canvas.paste_mods = Some(held); + } for e in events { + // Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod + // can go down and up within one frame, leaving the frame's modifiers without it. + match &e { + egui::Event::ModifiersChanged(m) if m.command => app.canvas.paste_mods = Some(*m), + egui::Event::Key { key: Key::V | Key::Paste, pressed: true, .. } => app.canvas.v_pressed = true, + egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => { + app.canvas.paste_mods = modifiers.command.then_some(*modifiers); + } + egui::Event::WindowFocused(_) => { + app.canvas.v_pressed = false; + app.canvas.paste_mods = None; + } + _ => {} + } // An Alt shortcut (Alt+Shift+D, Alt+F7…) may also send its key's text; it isn't typed. match &e { egui::Event::Key { pressed: true, .. } => { @@ -273,8 +306,11 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } // Desktop: Mod+V with no text on the clipboard sends no paste event, only the key's // release; paste the clipboard's picture then (#45). A paste event before the release - // means it was text. (The web reads pasted pictures itself.) + // means it was text. (The web reads pasted pictures itself.) Mod released before V still + // pastes: a release with no V press seen was Mod+V. if let egui::Event::Key { key: key @ (Key::V | Key::Paste), pressed: false, modifiers, .. } = &e { + let press_seen = std::mem::take(&mut app.canvas.v_pressed); + let modifiers = &paste_release_modifiers(*modifiers, press_seen, app.canvas.paste_mods.take()); let paste_keys = is_paste_release(*key, *modifiers); let pasted = std::mem::take(&mut app.canvas.pasted); if !pasted && paste_keys && app.session.math.is_none() && app.services.clipboard_picture.is_some() { @@ -711,6 +747,99 @@ mod tests { assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false))); } + /// An empty document whose clipboard holds a 4 × 4 picture. + fn picture_app() -> WordApp { + use crate::paste_picture::ClipboardPicture; + let services = crate::Services { + clipboard_picture: Some(Box::new(|| Some(ClipboardPicture::Pixels { width: 4, height: 4, rgba: vec![200; 4 * 4 * 4] }))), + ..Default::default() + }; + WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services) + } + + /// How many pictures the document holds (the Selection Pane's list). + fn pictures(a: &mut WordApp) -> usize { + a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0) + } + + /// One canvas frame with `modifiers` held, then `events`. + fn mods_frame(ctx: &egui::Context, a: &mut WordApp, modifiers: Modifiers, events: Vec) { + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| canvas_events(a, ui.ctx())).drop_without_applying_deltas(); + } + + /// A press or release of `key` with `modifiers`. + fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers } + } + + /// Left Ctrl+V with one hand often lets Ctrl up a moment before V. egui-winit keeps the + /// Mod+V press, so the canvas sees only Ctrl's release, then V's without Mod: still a paste. + #[test] + fn mod_v_released_mod_first_pastes_the_clipboard_picture() { + let ctx = egui::Context::default(); + let mut a = picture_app(); + let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; + mods_frame(&ctx, &mut a, ctrl, vec![key_event(Key::ControlLeft, true, ctrl)]); + // The Mod+V press: egui-winit sends no event for it. + mods_frame(&ctx, &mut a, ctrl, vec![]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::ControlLeft, false, Modifiers::NONE)]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::V, false, Modifiers::NONE)]); + assert_eq!(pictures(&mut a), 1); + + // A plain V afterwards (its press arrives) isn't a paste. + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::V, true, Modifiers::NONE)]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::V, false, Modifiers::NONE)]); + assert_eq!(pictures(&mut a), 1); + + // Nor is Ctrl+B, Ctrl up, then a V release whose press never came (focus moved in). + mods_frame(&ctx, &mut a, ctrl, vec![key_event(Key::B, true, ctrl)]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::ControlLeft, false, Modifiers::NONE), egui::Event::WindowFocused(true)]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::V, false, Modifiers::NONE)]); + assert_eq!(pictures(&mut a), 1); + + // Mod+Shift+V released Mod first stays Paste Formatting, not a picture paste. + let ctrl_shift = Modifiers { shift: true, ..ctrl }; + mods_frame(&ctx, &mut a, ctrl_shift, vec![]); + mods_frame(&ctx, &mut a, Modifiers::SHIFT, vec![key_event(Key::ControlLeft, false, Modifiers::SHIFT)]); + mods_frame(&ctx, &mut a, Modifiers::SHIFT, vec![key_event(Key::V, false, Modifiers::SHIFT)]); + assert_eq!(pictures(&mut a), 1); + + // Released V first, as before, still pastes. + let mut a = picture_app(); + mods_frame(&ctx, &mut a, ctrl, vec![]); + mods_frame(&ctx, &mut a, ctrl, vec![key_event(Key::V, false, ctrl)]); + assert_eq!(pictures(&mut a), 1); + } + + /// Ctrl down, the kept Mod+V press, Ctrl up and V up can all arrive in one frame: the frame's + /// modifiers no longer have Mod, but the ModifiersChanged events in between did. + #[test] + fn mod_v_within_one_frame_pastes_the_clipboard_picture() { + let ctx = egui::Context::default(); + let mut a = picture_app(); + let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; + let events = vec![ + egui::Event::ModifiersChanged(ctrl), + key_event(Key::ControlLeft, true, ctrl), + egui::Event::ModifiersChanged(Modifiers::NONE), + key_event(Key::ControlLeft, false, Modifiers::NONE), + key_event(Key::V, false, Modifiers::NONE), + ]; + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| canvas_events(&mut a, ui.ctx())).drop_without_applying_deltas(); + assert_eq!(pictures(&mut a), 1); + } + + /// A V release keeps its own modifiers unless Mod went up first and no V press arrived. + #[test] + fn paste_release_modifiers_fall_back_to_those_held_with_mod() { + let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; + assert_eq!(paste_release_modifiers(ctrl, false, None), ctrl, "Mod still down"); + assert_eq!(paste_release_modifiers(Modifiers::NONE, false, Some(ctrl)), ctrl, "Mod up first, press kept by egui-winit"); + assert_eq!(paste_release_modifiers(Modifiers::NONE, true, Some(ctrl)), Modifiers::NONE, "a plain V's press arrived"); + assert_eq!(paste_release_modifiers(Modifiers::NONE, false, None), Modifiers::NONE); + } + #[test] fn shift_delete_is_only_shift_and_delete() { let m = |shift, ctrl, alt| egui::Modifiers { shift, ctrl, command: ctrl, alt, ..Default::default() };