Fix #5159: ⌘V from the macOS menu bar pastes a picture another app copied

The menu bar takes ⌘V's key-down as Edit › Paste's key equivalent, and
`native_menu::key_events` turns it back into egui input. With text on the
clipboard that was a Paste event; with only a picture (a screenshot) it
was nothing, leaving the paste to the window's own key-up. egui's window
integration only releases a key whose press it saw, so that release never
came and ⌘V pasted nothing, while Edit › Paste and ⌘V with the menu open
worked.

`key_events` now ends ⌘V with the key's release, as it already did for
⌘X and ⌘C: without a Paste event that release is the picture paste
`PasteChord` looks for, and after one it ends that paste so the next
picture paste still fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HassanKhan
2026-11-02 16:18:13 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 683abdb2d6
commit b6f90d42fb
2 changed files with 42 additions and 7 deletions
+8 -4
View File
@@ -260,16 +260,20 @@ pub fn is_function_key(key: Key) -> bool {
/// The input egui's window integration would have made of a press of `sc` (key and modifiers
/// held): the key press and its release, but for ⌘X, ⌘C and ⌘V the Cut, Copy and Paste events
/// (Paste with the clipboard's text, from `paste_text`). A paste's release is left to the window's
/// own key-up: one without a press or a Paste event is how a paste of a picture or a PDF is told
/// apart ([`crate::shortcuts`]).
/// (Paste with the clipboard's text, from `paste_text`), each followed by the key's release. The
/// window never reports that release itself: AppKit gave the key-down to the menu, and egui's
/// window integration only releases a key whose press it saw. A paste's release without a press or
/// a Paste event is how a paste of a picture or a PDF is told apart ([`crate::shortcuts`], #5159).
pub fn key_events(sc: KeyboardShortcut, paste_text: impl FnOnce() -> Option<String>) -> Vec<egui::Event> {
let key = |pressed| egui::Event::Key { key: sc.logical_key, physical_key: None, pressed, repeat: false, modifiers: sc.modifiers };
if sc.modifiers.command {
match sc.logical_key {
Key::X => return vec![egui::Event::Cut, key(false)],
Key::C => return vec![egui::Event::Copy, key(false)],
Key::V => return paste_text().map(|t| t.replace("\r\n", "\n")).filter(|t| !t.is_empty()).map(egui::Event::Paste).into_iter().collect(),
Key::V => {
let text = paste_text().map(|t| t.replace("\r\n", "\n")).filter(|t| !t.is_empty());
return text.map(egui::Event::Paste).into_iter().chain([key(false)]).collect();
}
_ => {}
}
}
+34 -3
View File
@@ -244,7 +244,7 @@ fn key_equivalents_are_command_and_function_keys_only() {
}
/// What a key equivalent becomes: the press and release egui's window integration sends, or for
/// ⌘X, ⌘C and ⌘V its clipboard events (a paste's release comes from the window).
/// ⌘X, ⌘C and ⌘V its clipboard events and the key's release (the window never sends it, #5159).
#[test]
fn key_equivalents_become_the_input_their_keys_make() {
let cmd = |key| KeyboardShortcut::new(Modifiers::COMMAND, key);
@@ -252,8 +252,12 @@ fn key_equivalents_become_the_input_their_keys_make() {
assert_eq!(native_menu::key_events(cmd(Key::A), || None), [key(Key::A, true), key(Key::A, false)]);
assert_eq!(native_menu::key_events(cmd(Key::C), || None), [Event::Copy, key(Key::C, false)]);
assert_eq!(native_menu::key_events(cmd(Key::X), || None), [Event::Cut, key(Key::X, false)]);
assert_eq!(native_menu::key_events(cmd(Key::V), || Some("a\r\nb".into())), [Event::Paste("a\nb".into())]);
assert_eq!(native_menu::key_events(cmd(Key::V), || None), [], "a picture: the release tells the paste");
assert_eq!(native_menu::key_events(cmd(Key::V), || Some("a\r\nb".into())), [Event::Paste("a\nb".into()), key(Key::V, false)]);
assert_eq!(native_menu::key_events(cmd(Key::V), || None), [key(Key::V, false)], "a picture: the release tells the paste");
// A text paste's release is taken with its Paste event, so the next picture paste still fires.
let mut chord = crate::shortcuts::PasteChord::default();
assert_eq!(chord.textless_paste(&native_menu::key_events(cmd(Key::V), || Some("a".into()))), None);
assert_eq!(chord.textless_paste(&native_menu::key_events(cmd(Key::V), || None)), Some(Modifiers::COMMAND));
}
/// The fake backend: the menus the app synced, and events to report.
@@ -682,3 +686,30 @@ fn the_menu_bar_is_inert_while_a_file_dialog_is_open() {
m.key_equivalent("Cmd+A");
assert_eq!(m.selected(), 2, "⌘A selected every object");
}
/// #5159: ⌘V chosen by the menu bar's key equivalent pastes a picture another app copied (a
/// screenshot): AppKit gives the key-down to the menu, so the window never reports the key, and
/// the release the key equivalent makes is what tells the paste. Each press pastes once.
#[test]
fn a_key_equivalent_paste_pastes_a_picture_from_another_app() {
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::cmd::clipboard::PNG;
let (mut app, board) = super::sysclip::app();
super::sysclip::copy_elsewhere(&board, vec![(PNG, super::sysclip::blue_png())]);
let fake = Fake::default();
app.services.native_menu = Some(NativeMenu::new(Box::new(fake.clone())));
let mut m = Mac { app, fake, ctx: egui::Context::default(), time: 0.0 };
for _ in 0..3 {
m.frame(vec![]);
}
let images = |m: &Mac| {
let d = &m.app.session.active().unwrap().doc;
d.layers[0].children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Image(_))).count()
};
for n in 1..=2 {
m.key_equivalent("Cmd+V");
m.frame(vec![]);
m.frame(vec![]);
assert_eq!(images(&m), n, "press {n} pastes one picture");
}
}