mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Fix #34: Ctrl/Cmd+Shift+V pastes in place
Paste in Place is Cmd+Shift+V, but egui reports every Cmd+V chord as a Paste event (or, for what isn't text, only the key's release), never as the key, and the handler ran Paste for every Paste event. A paste now runs the paste command whose V chord is held (Paste in Place, Paste on All Artboards, Paste without Formatting; user overrides included), Paste for the Paste key and Shift+Insert. Envelope Distort > Edit Contents also had Cmd+Shift+V; it loses the duplicate, and a test checks that no two commands, tools or panels share a default shortcut. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
72be324732
commit
d3d6cce917
4 files changed
+109
-13
No files matched your search
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Modifiers> {
|
||||
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;
|
||||
|
||||
@@ -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<Event>) -> 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![]);
|
||||
}
|
||||
Reference in new issue
Block a user