mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 04:16:46 -06:00
Merge fix/717
This commit is contained in:
Generated
+66
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user