diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9fbd17cc..a64ef6a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -502,6 +502,8 @@ pub struct UiState { pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, + /// The modifiers held, as of the last raw event that told: a paste event doesn't carry them. + pub held: egui::Modifiers, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and @@ -598,6 +600,7 @@ impl Default for UiState { v_pressed: false, paste_event: false, refocused: false, + held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), @@ -1294,7 +1297,7 @@ impl FigApp { // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); - let paste_release = shortcuts::swallowed_paste(self, &ctx); + let swallowed = shortcuts::swallowed_paste(self, &ctx); // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); // Control requests run first so their results reflect this frame. @@ -1410,7 +1413,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing, paste_release); + shortcuts::handle(self, &ctx, typing, swallowed); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index fa797fb9..23cec8ea 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -6,11 +6,25 @@ use std::cell::RefCell; use std::rc::Rc; use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, vec2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; use crate::{FigApp, Services}; +/// What the system clipboard holds when a paste chord is pressed (#716). +#[derive(Clone, Copy, Debug)] +enum Held { + /// FigCraft's own copy (its SVG text). + Ours, + /// Text copied in another app. + Text, + /// An image copied in another app. + Image, +} + +const HELD: [Held; 3] = [Held::Ours, Held::Text, Held::Image]; + #[derive(Default)] struct Clip { text: Option, @@ -87,6 +101,55 @@ impl Win { self.frame(vec![]); } + /// A Cmd/Ctrl+V chord with its modifiers, as egui-winit delivers it: they go down first, the V + /// press brings a paste event only for text, its release comes as a key event, then they're + /// let go. + fn chord(&mut self, m: Modifiers) { + let text = self.clip.borrow().text.clone().filter(|t| !t.is_empty()); + self.frame(vec![Event::ModifiersChanged(m)]); + self.frame(text.map(Event::Paste).into_iter().collect()); + self.frame(vec![key(Key::V, false, m)]); + self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + } + + /// A red rectangle A copied in FigCraft, and a grey rectangle B selected; then `held` on the + /// system clipboard. Returns (A, B). + fn copy_a_select_b(&mut self, held: Held) -> (NodeId, NodeId) { + let s = &mut self.app.session; + let a = s.doc().unwrap().nodes().find(|n| n.name == "Rectangle").unwrap().id; + s.execute("fill.set", &json!({"ids": [a.0], "color": "#e53935"})).unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 100, "height": 50})).unwrap(); + let b = NodeId(b["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + crate::menus::invoke(&mut self.app, "edit.copy"); + self.app.session.execute("edit.copyProperties", &json!({})).unwrap(); + self.app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + match held { + Held::Ours => {} + Held::Text => self.clip.borrow_mut().text = Some("hello from another app".into()), + Held::Image => self.copy_image_elsewhere(), + } + (a, b) + } + + fn fill(&self, id: NodeId) -> String { + let n = figcraft_engine::inspect::node_json(self.app.session.doc().unwrap(), id, 0); + n["fills"][0]["color"].as_str().unwrap_or_default().to_string() + } + + /// Top-lefts of the rectangles other than `b`. + fn rects_besides(&self, b: NodeId) -> Vec<(i64, i64)> { + let doc = self.app.session.doc().unwrap(); + let mut at: Vec<_> = doc + .nodes() + .filter(|n| n.name == "Rectangle" && n.id != b) + .filter_map(|n| doc.world_bounds(n.id)) + .map(|r| (r.x0.round() as i64, r.y0.round() as i64)) + .collect(); + at.sort(); + at + } + fn named(&self, name: &str) -> usize { self.app.session.doc().unwrap().nodes().filter(|n| n.name == name).count() } @@ -256,3 +319,92 @@ fn paste_here_prefers_a_newer_image_over_copied_layers() { let b = doc.world_bounds(img.id).unwrap(); assert!((b.x0 - p.x).abs() < 0.01 && (b.y0 - p.y).abs() < 0.01, "top-left where the menu opened: {b:?} vs {p:?}"); } + +#[test] +fn shift_cmd_v_pastes_over_the_selection_whatever_the_clipboard_holds() { + for held in HELD { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(held); + w.chord(CMD.plus(Modifiers::SHIFT)); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "{held:?}: A's copy at B's top-left, nothing else"); + assert_eq!(w.texts(), 0, "{held:?}: no text pasted"); + assert_eq!(w.named("Pasted image"), 0, "{held:?}: no image pasted"); + } +} + +#[test] +fn alt_cmd_v_pastes_properties_whatever_the_clipboard_holds() { + for held in HELD { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(held); + w.chord(CMD.plus(Modifiers::ALT)); + assert_eq!(w.fill(b), "#E53935", "{held:?}: B took A's fill"); + assert_eq!(w.rects_besides(b), [(10, 10)], "{held:?}: no layer pasted"); + assert_eq!(w.texts(), 0, "{held:?}: no text pasted"); + assert_eq!(w.named("Pasted image"), 0, "{held:?}: no image pasted"); + } +} + +#[test] +fn a_chord_let_go_within_one_frame_still_reads_its_modifiers() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + let m = CMD.plus(Modifiers::SHIFT); + w.frame(vec![ + Event::ModifiersChanged(m), + Event::Paste("hello from another app".into()), + key(Key::V, false, m), + Event::ModifiersChanged(Modifiers::NONE), + ]); + w.frame(vec![]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)]); + assert_eq!(w.texts(), 0); +} + +#[test] +fn cmd_let_go_before_v_still_pastes_over() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Image); + w.frame(vec![Event::ModifiersChanged(CMD.plus(Modifiers::SHIFT))]); + w.frame(vec![Event::ModifiersChanged(Modifiers::SHIFT)]); + w.frame(vec![key(Key::V, false, Modifiers::SHIFT)]); + w.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "pasted over, not flipped or a plain paste"); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn a_held_chord_pastes_over_once() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + let m = CMD.plus(Modifiers::SHIFT); + w.frame(vec![Event::ModifiersChanged(m)]); + // Auto-repeat: egui-winit sends a paste event for every repeated press. + for _ in 0..3 { + w.frame(vec![Event::Paste("hello from another app".into())]); + } + w.frame(vec![key(Key::V, false, m)]); + w.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)]); + assert_eq!(w.texts(), 0); +} + +#[test] +fn shift_insert_still_pastes_text() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + w.frame(vec![Event::ModifiersChanged(Modifiers::SHIFT), Event::Paste("hello from another app".into())]); + w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT), Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.texts(), 1); + assert_eq!(w.rects_besides(b), [(10, 10)]); +} + +#[test] +fn cmd_v_after_a_paste_chord_is_a_plain_paste_again() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + w.chord(CMD.plus(Modifiers::SHIFT)); + w.chord(CMD); + assert_eq!(w.texts(), 1, "the text was pasted by the plain Cmd+V"); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "Shift+Cmd+V pasted over once"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f28a2433..57295c22 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -94,8 +94,8 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// Run the shortcuts this frame's keys press. `typing`: a field had the keyboard when the frame /// began. -/// `paste_release`: this frame released a Cmd/Ctrl+V the shell swallowed ([`swallowed_paste`]). -pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool) { +/// `swallowed`: what this frame's Cmd/Ctrl+V chords the shell swallowed ask for ([`swallowed_paste`]). +pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowed) { note_platform(ctx); // Text fields in panels own the keyboard (also the key that ends their editing). if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { @@ -113,16 +113,18 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool // An image-only clipboard brings no paste event: paste from the key then. The browser sends // the press; the desktop shell swallows it and sends only the release. let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_))); - let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt)); - if (paste_key && !paste_event) || paste_release { - crate::menus::paste(app, None, None); + let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && v_binding(*modifiers) == Some("edit.paste"))); + match swallowed.run.or((paste_key && !paste_event).then_some("edit.paste")) { + Some("edit.paste") => crate::menus::paste(app, None, None), + Some(id) => run_binding(app, id), + None => {} } let now = ctx.input(|i| i.time); for ev in &events { match ev { Event::Copy => crate::menus::invoke(app, "edit.copy"), Event::Cut => crate::menus::invoke(app, "edit.cut"), - Event::Paste(text) => crate::menus::paste(app, Some(text), None), + Event::Paste(text) if !swallowed.drop_text => crate::menus::paste(app, Some(text), None), Event::Key { key, physical_key, pressed: true, modifiers, repeat } if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) => { @@ -133,23 +135,59 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool } } -/// Whether this frame releases a Cmd/Ctrl+V whose press never arrived and that brought no paste -/// event: egui-winit swallows the press and sends a paste event only when the clipboard holds -/// text, so an image on the clipboard leaves just this release. Call at the start of every frame: -/// it reads the raw events, which keys consumed by panels or the presenter still carry. -pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { +/// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), +/// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, +/// whether the key press arrived (browser) or only a paste event and the release (desktop). +fn v_binding(m: Modifiers) -> Option<&'static str> { + lookup(keymap::Context::Canvas, Key::V, Some(Key::V), Modifiers { command: true, ..m }).map(|b| b.id) +} + +/// What this frame's Cmd/Ctrl+V chords ask for when the desktop shell swallowed their press +/// ([`swallowed_paste`]). +#[derive(Clone, Copy, Default)] +pub struct Swallowed { + /// The command to run ([`v_binding`]). + pub run: Option<&'static str>, + /// This frame's paste event belongs to another command or an unbound chord: its text isn't + /// pasted. + pub drop_text: bool, +} + +/// egui-winit swallows every Cmd/Ctrl+V press, Shift or Alt held or not, and sends a paste event +/// only when the clipboard holds text. So a chord brings a paste event (text) or just its release +/// (an image, or nothing). The browser sends the press as well, which runs the command itself. +/// Call at the start of every frame: it reads the raw events, which keys consumed by panels or the +/// presenter still carry. +pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { let ui = &mut app.ui; - let mut paste = false; + let mut out = Swallowed::default(); ctx.input(|i| { for ev in &i.raw.events { + if let Event::Key { modifiers, .. } = ev { + ui.held = *modifiers; + } match ev { + Event::ModifiersChanged(m) => ui.held = *m, + // Without Cmd/Ctrl held it's another paste key (Shift+Insert, a Paste key). + Event::Paste(_) if ui.held.command => { + let id = v_binding(ui.held); + if id != Some("edit.paste") { + out.drop_text = true; + // Held down, the chord repeats its paste event: run once, as keys do. + if !ui.v_pressed && !ui.paste_event { + out.run = out.run.or(id); + } + } + ui.paste_event = true; + } Event::Paste(_) => ui.paste_event = true, Event::Key { key: Key::V, pressed: true, .. } => ui.v_pressed = true, Event::Key { key: Key::V, pressed: false, modifiers, .. } => { - // Shift or Alt with it is another command (paste over, paste properties). A V - // held while the window was away comes back as a release alone: without Cmd - // held, that's no paste. - paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt && (modifiers.command || !ui.refocused); + // Cmd was down when V went down, maybe let go first. A V held while the window + // was away comes back as a release alone: without Cmd held, that's no paste. + if !ui.v_pressed && !ui.paste_event && (modifiers.command || !ui.refocused) { + out.run = out.run.or(v_binding(*modifiers)); + } ui.v_pressed = false; ui.paste_event = false; ui.refocused = false; @@ -160,13 +198,14 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { Event::WindowFocused(false) => { ui.v_pressed = false; ui.paste_event = false; + ui.held = Modifiers::NONE; } Event::WindowFocused(true) => ui.refocused = true, _ => {} } } }); - paste + out } /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index a7787120..26389ff4 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -86,6 +86,26 @@ impl Page { self.frame(vec![Self::key(Key::V, false)]); } + /// Cmd+V with `m` added, as the browser delivers it: the key press, then the paste event. + fn chord(&mut self, m: Modifiers) { + let m = m | Modifiers::MAC_CMD | Modifiers::COMMAND; + let v = |pressed| Event::Key { key: Key::V, physical_key: Some(Key::V), pressed, repeat: false, modifiers: m }; + let text = self.clipboard.clone(); + self.frame(vec![Event::ModifiersChanged(m), v(true), Event::Paste(text)]); + self.frame(vec![v(false)]); + self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + } + + /// Copy the selected rectangle A (made red) and its properties, then select a new rectangle B. + fn copy_a_select_b(&mut self) -> figcraft_doc::NodeId { + self.app.session.execute("fill.set", &json!({"color": "#e53935"})).unwrap(); + self.copy(); + self.app.session.execute("edit.copyProperties", &json!({})).unwrap(); + let b = self.app.session.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 100, "height": 50})).unwrap(); + self.app.session.execute("select.set", &json!({"ids": [b["id"]]})).unwrap(); + figcraft_doc::NodeId(b["id"].as_u64().unwrap()) + } + fn count(&self, kind: fn(&NodeKind) -> bool) -> usize { self.app.session.doc().unwrap().nodes().filter(|n| kind(&n.kind)).count() } @@ -121,6 +141,52 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); } +#[test] +fn shift_cmd_v_pastes_over_once_and_no_text() { + for foreign in [false, true] { + let mut p = Page::new(); + let b = p.copy_a_select_b(); + if foreign { + p.clipboard = "hello from another app".into(); + } + p.chord(Modifiers::SHIFT); + assert_eq!(p.count(rect), 3, "foreign text {foreign}: one copy pasted over B"); + assert_eq!(p.count(text), 0, "foreign text {foreign}: the paste event pasted nothing"); + let doc = p.app.session.doc().unwrap(); + let over = doc.nodes().filter(|n| n.id != b).filter_map(|n| doc.world_bounds(n.id)).any(|r| r.x0 == 400.0 && r.y0 == 250.0); + assert!(over, "foreign text {foreign}: the copy sits at B's top-left"); + } +} + +#[test] +fn alt_cmd_v_pastes_properties_once_and_no_text() { + for foreign in [false, true] { + let mut p = Page::new(); + let b = p.copy_a_select_b(); + if foreign { + p.clipboard = "hello from another app".into(); + } + p.chord(Modifiers::ALT); + assert_eq!(p.count(rect), 2, "foreign text {foreign}: no layer pasted"); + assert_eq!(p.count(text), 0, "foreign text {foreign}: the paste event pasted nothing"); + let fill = figcraft_engine::inspect::node_json(p.app.session.doc().unwrap(), b, 0)["fills"][0]["color"].clone(); + assert_eq!(fill, "#E53935", "foreign text {foreign}: B took A's fill"); + } +} + +#[test] +fn a_chord_tapped_within_one_frame_runs_once() { + let mut p = Page::new(); + p.copy_a_select_b(); + let m = Modifiers::SHIFT | Modifiers::MAC_CMD | Modifiers::COMMAND; + let v = |pressed| Event::Key { key: Key::V, physical_key: Some(Key::V), pressed, repeat: false, modifiers: m }; + let clip = p.clipboard.clone(); + p.frame(vec![Event::ModifiersChanged(m), v(true), Event::Paste(clip), v(false), Event::ModifiersChanged(Modifiers::NONE)]); + p.frame(vec![]); + assert_eq!(p.count(rect), 3, "one copy pasted over B"); + assert_eq!(p.count(text), 0); +} + #[test] fn copy_as_png_puts_an_image_on_the_clipboard() { let mut p = Page::new();