Merge fix/717

This commit is contained in:
bflatastic
2026-10-17 15:20:33 +07:00
8 changed files with 672 additions and 23 deletions
Generated
+66
View File
@@ -259,6 +259,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1734,6 +1735,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -2820,6 +2827,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3204,6 +3220,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3264,6 +3290,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -4574,6 +4611,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -5726,6 +5774,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "write-fonts"
version = "0.48.1"
+1 -1
View File
@@ -21,7 +21,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
webbrowser = { workspace = true }
image = { workspace = true }
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
+24 -2
View File
@@ -223,6 +223,28 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
#[cfg(all(unix, not(target_os = "macos")))]
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle
/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no
/// timeout of its own, so an app that hangs must not hang the window too.
#[cfg(all(unix, not(target_os = "macos")))]
fn read_clipboard<T: Send + 'static>(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c)));
});
rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten()
}
/// Read the clipboard (the system's own reads don't wait on the app that copied).
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn read_clipboard<T>(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
read(c)
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
@@ -239,7 +261,7 @@ impl Services for Desktop {
}
}
fn clipboard_get_text(&mut self) -> Option<String> {
self.clipboard.as_mut()?.get_text().ok()
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
}
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
if let Some(c) = self.clipboard.as_mut() {
@@ -247,7 +269,7 @@ impl Services for Desktop {
}
}
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
let img = self.clipboard.as_mut()?.get_image().ok()?;
let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?;
let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
// Refuse absurd sizes (and mismatched buffers) rather than allocate.
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 {
+17 -1
View File
@@ -142,6 +142,8 @@ mod panel_reorder_tests;
#[cfg(test)]
mod panel_width_tests;
#[cfg(test)]
mod paste_image_tests;
#[cfg(test)]
mod pen_mode_tests;
#[cfg(test)]
mod perf_tests;
@@ -496,6 +498,15 @@ pub struct UiState {
/// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste
/// uses our copied layers; anything else there (an image, SVG, text) came from another app.
pub last_copy: Option<String>,
/// Since the last V key release: whether its press arrived, and whether a paste event did
/// (`shortcuts::swallowed_paste`).
pub v_pressed: bool,
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 modifier change in the raw events: 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
@@ -589,6 +600,10 @@ impl Default for UiState {
dev: dev_mode::DevUi::default(),
last_copy: None,
v_pressed: false,
paste_event: false,
refocused: false,
held: egui::Modifiers::NONE,
next_autosave: 0.0,
auto_save_at: None,
auto_saving: Vec::new(),
@@ -1289,6 +1304,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 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.
@@ -1404,7 +1420,7 @@ impl FigApp {
branch_ui::keys(self, &ctx);
live::dialog_keys(self, &ctx);
dialogs::keys(self, &ctx);
shortcuts::handle(self, &ctx, typing);
shortcuts::handle(self, &ctx, typing, swallowed);
menus::popups(self, &ctx);
variable_ui::picker(self, &ctx);
style_picker::show(self, &ctx);
+10 -13
View File
@@ -32,17 +32,18 @@ pub const UI_COMMANDS: &[&str] = &[
/// Run a menu item: some open dialogs or use platform services, the rest go to the engine.
/// Paste: the system clipboard wins when it holds something newer than our own copy (an image, SVG
/// or text from another app); otherwise our copied layers. `event_text` is the text the platform
/// handed over with the paste event (the browser has no other way to read it).
pub fn paste(app: &mut FigApp, event_text: Option<&str>) {
/// handed over with the paste event (the browser has no other way to read it). `at`: the page
/// point to paste at (Paste here); `None` pastes in the middle of the view.
pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) {
let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty());
let ours = sys_text.is_some() && sys_text == app.ui.last_copy;
let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0));
let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0));
if !ours {
if let Some((png, w, h)) = clipboard_png(app) {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
// Centred in the view.
let (x, y) = ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round());
// Centred in the view; Paste here puts its top-left at the point, as it does layers.
let (x, y) = if at.is_some() { (cx, cy) } else { ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()) };
app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y}));
return;
}
@@ -54,14 +55,14 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) {
} else if figcraft_engine::looks_like_svg(&text) {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": true}));
app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": at.is_none()}));
} else {
app.run("text.create", json!({"text": text, "x": cx, "y": cy}));
}
return;
}
}
app.run("edit.paste", json!({}));
app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y})));
}
/// The system clipboard's image as PNG bytes, with its size.
@@ -143,7 +144,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
app.services.clipboard_set_text(&text);
app.ui.last_copy = Some(text);
}
"edit.paste" => paste(app, None),
"edit.paste" => paste(app, None, None),
"file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => {
let save = pretty_shortcut(crate::shortcuts::shortcut("file.save").unwrap_or(""));
app.toast(trf(
@@ -553,11 +554,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
if item(ui, label, sc, available(id), false, &t) {
if *id == "edit.paste" {
let p = app.to_page(pos);
if app.session.clipboard.nodes.is_empty() {
invoke(app, id);
} else {
app.run("edit.paste", json!({"x": p.x, "y": p.y}));
}
paste(app, None, Some((p.x, p.y)));
} else {
invoke(app, id);
}
+408
View File
@@ -0,0 +1,408 @@
//! Pasting an image copied in another app (#663). On the desktop, egui-winit swallows Cmd/Ctrl+V's
//! press and sends a paste event only when the clipboard holds text: an image leaves just the
//! key's release. The browser sends the press and a paste event (`web_clipboard_tests`).
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<String>,
image: Option<(u32, u32, Vec<u8>)>,
}
/// The desktop clipboard.
struct Desktop(Rc<RefCell<Clip>>);
impl Services for Desktop {
fn clipboard_set_text(&mut self, s: &str) {
*self.0.borrow_mut() = Clip { text: Some(s.into()), image: None };
}
fn clipboard_get_text(&mut self) -> Option<String> {
self.0.borrow().text.clone()
}
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
self.0.borrow().image.clone()
}
}
struct Win {
ctx: egui::Context,
app: FigApp,
time: f64,
clip: Rc<RefCell<Clip>>,
shapes: Vec<Shape>,
}
impl Win {
fn new() -> Self {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
let ctx = egui::Context::default();
let clip = Rc::new(RefCell::new(Clip::default()));
let app = FigApp::new(s, Box::new(Desktop(clip.clone())));
let mut w = Self { ctx, app, time: 0.0, clip, shapes: Vec::new() };
for _ in 0..3 {
w.frame(vec![]);
}
w
}
fn frame(&mut self, events: Vec<Event>) {
self.frame_with(events, false);
}
/// `take_v`: something ahead of the app (a prototype's key trigger) consumes a V press.
fn frame_with(&mut self, events: Vec<Event>, take_v: bool) {
self.time += 1.0 / 60.0;
let input =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() };
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| {
if take_v {
ui.ctx().input_mut(|i| i.consume_key(Modifiers::NONE, Key::V));
}
app.ui(ui);
});
out.textures_delta.clear();
self.shapes = out.shapes.into_iter().map(|s| s.shape).collect();
}
fn copy_image_elsewhere(&mut self) {
*self.clip.borrow_mut() = Clip { text: None, image: Some((2, 2, vec![255; 16])) };
}
/// Cmd/Ctrl+V as egui-winit delivers it: the press brings a paste event when the clipboard
/// holds text and nothing otherwise; the release comes as a key event.
fn cmd_v(&mut self, m: Modifiers) {
let text = self.clip.borrow().text.clone().filter(|t| !t.is_empty());
self.frame(text.map(Event::Paste).into_iter().collect());
self.frame(vec![key(Key::V, false, m)]);
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()
}
fn texts(&self) -> usize {
self.app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))).count()
}
fn rects(&self) -> usize {
self.named("Rectangle")
}
}
const CMD: Modifiers = Modifiers::COMMAND.plus(Modifiers::CTRL);
fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
}
#[test]
fn cmd_v_pastes_an_image_copied_elsewhere() {
let mut w = Win::new();
w.copy_image_elsewhere();
w.cmd_v(CMD);
assert_eq!(w.named("Pasted image"), 1, "the image was pasted from the key's release");
w.cmd_v(CMD);
assert_eq!(w.named("Pasted image"), 2, "and again");
}
#[test]
fn cmd_released_before_v_still_pastes_the_image() {
let mut w = Win::new();
w.copy_image_elsewhere();
w.cmd_v(Modifiers::NONE);
assert_eq!(w.named("Pasted image"), 1);
}
#[test]
fn text_pastes_once_from_its_paste_event() {
let mut w = Win::new();
*w.clip.borrow_mut() = Clip { text: Some("hello from another app".into()), image: None };
w.cmd_v(CMD);
assert_eq!(w.texts(), 1, "the paste event pasted the text, the release nothing more");
assert_eq!(w.rects(), 1);
}
#[test]
fn text_pastes_once_when_another_key_is_let_go_first() {
let mut w = Win::new();
*w.clip.borrow_mut() = Clip { text: Some("hello".into()), image: None };
// Cmd+A then Cmd+V typed fast: A comes up after V went down.
w.frame(vec![key(Key::A, true, CMD)]);
w.frame(vec![Event::Paste("hello".into())]);
w.frame(vec![key(Key::A, false, CMD)]);
w.frame(vec![key(Key::V, false, CMD)]);
w.frame(vec![]);
assert_eq!(w.texts(), 1);
assert_eq!(w.rects(), 1);
}
#[test]
fn a_v_press_consumed_elsewhere_does_not_paste_on_release() {
let mut w = Win::new();
w.copy_image_elsewhere();
w.frame_with(vec![key(Key::V, true, Modifiers::NONE)], true);
w.frame(vec![key(Key::V, false, Modifiers::NONE)]);
w.frame(vec![]);
assert_eq!(w.named("Pasted image"), 0);
}
#[test]
fn the_browser_sequence_pastes_once() {
let mut w = Win::new();
w.copy_image_elsewhere();
w.frame(vec![key(Key::V, true, CMD)]);
w.frame(vec![key(Key::V, false, CMD)]);
w.frame(vec![]);
assert_eq!(w.named("Pasted image"), 1);
}
#[test]
fn v_alone_pastes_nothing() {
let mut w = Win::new();
w.copy_image_elsewhere();
w.frame(vec![key(Key::V, true, Modifiers::NONE)]);
w.frame(vec![key(Key::V, false, Modifiers::NONE)]);
assert_eq!(w.named("Pasted image"), 0);
}
#[test]
fn a_paste_event_from_another_key_does_not_block_the_next_cmd_v() {
let mut w = Win::new();
*w.clip.borrow_mut() = Clip { text: Some("text".into()), image: None };
// Shift+Insert on Windows.
w.frame(vec![Event::Paste("text".into())]);
w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT)]);
w.copy_image_elsewhere();
w.cmd_v(CMD);
assert_eq!(w.named("Pasted image"), 1);
}
#[test]
fn a_v_press_lost_to_another_window_does_not_block_the_next_cmd_v() {
let mut w = Win::new();
w.copy_image_elsewhere();
w.frame(vec![key(Key::V, true, Modifiers::NONE), Event::WindowFocused(false)]);
w.frame(vec![Event::WindowFocused(true)]);
w.cmd_v(CMD);
assert_eq!(w.named("Pasted image"), 1);
}
#[test]
fn v_held_while_the_window_was_away_pastes_nothing_on_release() {
let mut w = Win::new();
w.copy_image_elsewhere();
// Pressed in another window: only the focus and the release arrive here.
w.frame(vec![Event::WindowFocused(true)]);
w.frame(vec![key(Key::V, false, Modifiers::NONE)]);
w.frame(vec![]);
assert_eq!(w.named("Pasted image"), 0);
w.cmd_v(CMD);
assert_eq!(w.named("Pasted image"), 1, "the next Cmd+V still pastes");
}
#[test]
fn cmd_v_in_a_panel_field_does_not_paste_on_the_canvas() {
let mut w = Win::new();
w.copy_image_elsewhere();
w.frame(vec![]);
w.app.field_focus = true;
w.frame(vec![key(Key::V, false, CMD)]);
w.frame(vec![]);
assert_eq!(w.named("Pasted image"), 0);
}
#[test]
fn paste_here_prefers_a_newer_image_over_copied_layers() {
let mut w = Win::new();
w.app.session.execute("select.all", &json!({})).unwrap();
crate::menus::invoke(&mut w.app, "edit.copy");
assert!(!w.app.session.clipboard.nodes.is_empty(), "layers copied");
w.app.session.execute("select.none", &json!({})).unwrap();
w.copy_image_elsewhere();
let at = Pos2::new(900.0, 600.0);
for pressed in [true, false] {
w.frame(vec![
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE },
]);
}
w.frame(vec![]);
let item = crate::live_tests::text_center(&w.shapes, "Paste here");
for pressed in [true, false] {
w.frame(vec![
Event::PointerMoved(item),
Event::PointerButton { pos: item, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE },
]);
}
w.frame(vec![]);
assert_eq!(w.named("Pasted image"), 1, "the newer image was pasted");
assert_eq!(w.rects(), 1, "not the copied rectangle");
let p = w.app.to_page(at);
let doc = w.app.session.doc().unwrap();
let img = doc.nodes().find(|n| n.name == "Pasted image").unwrap();
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");
}
+80 -6
View File
@@ -94,7 +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.
pub fn handle(app: &mut FigApp, ctx: &Context, typing: 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() {
@@ -109,18 +110,21 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) {
text_keys(app, ctx, &events);
return;
}
// An image-only clipboard brings no paste event, just the key: paste from the key then.
// 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 {
crate::menus::paste(app, 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)),
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) =>
{
@@ -131,6 +135,76 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: 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 out = Swallowed::default();
ctx.input(|i| {
for ev in &i.raw.events {
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, .. } => {
// 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;
}
// The other keys that paste (Shift+Insert, a Paste key) end their paste event.
Event::Key { key: Key::Insert | Key::Paste, pressed: false, .. } => ui.paste_event = false,
// Keys let go in another window never come back as releases.
Event::WindowFocused(false) => {
ui.v_pressed = false;
ui.paste_event = false;
ui.held = Modifiers::NONE;
}
Event::WindowFocused(true) => ui.refocused = true,
_ => {}
}
}
});
out
}
/// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out
/// before it deselects, Delete ends a pen path, Enter edits the selected text or vector).
pub fn run_binding(app: &mut FigApp, id: &str) {
+66
View File
@@ -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();