diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 591179c8..0aa83dbd 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -142,7 +142,7 @@ pub fn specs() -> Vec { "object.envelope.editContents", "Edit Contents", ["Object", "Envelope Distort"], - Some("Cmd+Shift+V"), + None, "{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}", has_envelope, env_edit_contents diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2497ebf6..9162d4ff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -56,6 +56,8 @@ mod tests_overprint; #[cfg(test)] mod tests_paintchips; #[cfg(test)] +mod tests_pastechords; +#[cfg(test)] mod tests_pdfoutput; #[cfg(test)] mod tests_place; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 0eaf610c..0927a266 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -81,6 +81,15 @@ pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut, native: b /// 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) +} + /// 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. @@ -93,21 +102,26 @@ pub(crate) struct PasteChord { } impl PasteChord { - /// Follow one frame's `events` → whether they hold a paste egui sent no event for. - fn textless_paste(&mut self, events: &[egui::Event]) -> bool { - let mut fire = false; + /// 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, .. } if PASTE_KEYS.contains(key) => match self.pressed.iter().position(|k| k == key) { - Some(i) => { - self.pressed.swap_remove(i); + 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 => {} } - None => fire |= !std::mem::take(&mut self.pasted), - }, + } _ => {} } } @@ -170,14 +184,16 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } return; } - // Clipboard keys arrive as events, not key presses (except where the native menu has them). + // Clipboard keys arrive as events, not key presses (except where the native menu has them); + // the chord held says which paste. let mut clip = vec![]; ctx.input_mut(|i| { + let held = i.modifiers; i.events.retain(|e| { let (id, text) = match e { egui::Event::Copy => ("edit.copy", None), egui::Event::Cut => ("edit.cut", None), - egui::Event::Paste(t) => ("edit.paste", Some(t.clone())), + egui::Event::Paste(t) => (paste_command(held), Some(t.clone())), _ => return true, }; if app.native_shortcuts.contains(id) { @@ -188,8 +204,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { }) }); // Only the system clipboard service reads what isn't text (the native menu has its own Paste). - if textless_paste && app.services.system_clipboard.is_some() && !app.native_shortcuts.contains("edit.paste") { - clip.push(("edit.paste", None)); + if let Some(id) = textless_paste.map(paste_command) + && app.services.system_clipboard.is_some() + && !app.native_shortcuts.contains(id) + { + clip.push((id, None)); } for (id, text) in clip { app.clipboard_in = text; diff --git a/crates/ui-egui/src/tests_pastechords.rs b/crates/ui-egui/src/tests_pastechords.rs new file mode 100644 index 00000000..fd6d9680 --- /dev/null +++ b/crates/ui-egui/src/tests_pastechords.rs @@ -0,0 +1,75 @@ +//! The paste chords (egui reports every Cmd+V chord as a paste, not as its key) and the default +//! shortcuts (no chord does two things). + +use std::collections::BTreeMap; + +use egui::{Event, Key, Modifiers}; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, shortcut_editor, shortcuts}; + +/// One headless frame of the shortcut handler (and the app's logic, which publishes copies) with +/// `modifiers` held and `events`. +fn frame(app: &mut VectorcraftApp, modifiers: Modifiers, events: Vec) -> egui::FullOutput { + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let mut out = egui::Context::default().run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + shortcuts::handle(app, ui.ctx()); + app.logic(ui.ctx()); + }); + out.textures_delta.clear(); + out +} + +fn last_object_bounds(app: &VectorcraftApp) -> (f64, f64) { + let st = app.session.active().unwrap(); + let id = st.doc.layers[0].children().unwrap().last().unwrap().id; + let b = st.doc.node(id).unwrap().geometric_bounds().unwrap(); + (b.x0, b.y0) +} + +#[test] +fn cmd_shift_v_pastes_in_place() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 30})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let out = frame(&mut app, Modifiers::COMMAND, vec![Event::Copy]); + let svg = out + .platform_output + .commands + .iter() + .find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + .unwrap(); + // The text egui read from the clipboard comes with the chord still held. + frame(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, vec![Event::Paste(svg.clone())]); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 2); + assert_eq!(last_object_bounds(&app), (10.0, 20.0), "Cmd+Shift+V pastes in place"); + frame(&mut app, Modifiers::COMMAND, vec![Event::Paste(svg)]); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 3); + assert_ne!(last_object_bounds(&app), (10.0, 20.0), "Cmd+V pastes offset or centred"); +} + +#[test] +fn a_paste_takes_the_command_of_the_v_chord_held() { + let cmd = Modifiers::COMMAND; + assert_eq!(shortcuts::paste_command(cmd), "edit.paste"); + assert_eq!(shortcuts::paste_command(cmd | Modifiers::SHIFT), "edit.pasteInPlace"); + assert_eq!(shortcuts::paste_command(cmd | Modifiers::ALT), "edit.pasteWithoutFormatting"); + assert_eq!(shortcuts::paste_command(cmd | Modifiers::ALT | Modifiers::SHIFT), "edit.pasteOnAllArtboards"); + // The Paste key, and Shift+Insert, paste. + assert_eq!(shortcuts::paste_command(Modifiers::NONE), "edit.paste"); + assert_eq!(shortcuts::paste_command(Modifiers::SHIFT), "edit.paste"); + // What isn't text: only the key's release is reported, with the chord's modifiers. + let mut chord = shortcuts::PasteChord::default(); + let release = Event::Key { key: Key::V, physical_key: None, pressed: false, repeat: false, modifiers: cmd | Modifiers::SHIFT }; + assert_eq!(chord.textless_paste(&[release]), Some(cmd | Modifiers::SHIFT)); +} + +#[test] +fn no_two_commands_tools_or_panels_share_a_default_shortcut() { + assert_eq!(shortcut_editor::all_conflicts(&BTreeMap::new()), vec![]); +}