Clipboard: Copy and Cut make the system clipboard's formats on the background thread

Copy and Cut made the system clipboard's formats in the frame that ran
them. The formats include the SVG markup and a PNG of the selection at
1 px per pt, up to 4,096 px a side. In a release build on Windows, Copy
of 50,002 rectangles on a 1600 x 1200 pt page took 1.00 s in its frame.

The thread that writes background saves and exports now makes the
formats from a copy of the clipboard, and the app writes them in a
later frame. The same Copy takes 0.18 s in its frame, and the SVG and
PNG are the same bytes. While that thread has a save, an export, a print or
a recovery copy to finish, Copy and Cut make and write the formats at
once. A later Copy or Cut, or text copied in a field or with the Type
tool, drops formats that are not written yet. Until they are written,
Paste on the canvas takes the internal clipboard, and while a text
field or the Type tool has the keyboard the app waits for them.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 04:04:51 +07:00
committed by bflatastic
parent f14fbded0e
commit 580aa8daa5
13 changed files with 513 additions and 65 deletions
+11 -6
View File
@@ -175,15 +175,13 @@ impl Clipboard {
d.fix_next_id();
d
}
}
impl Session {
/// The internal clipboard as standalone SVG (`None` when it is empty).
pub fn clipboard_svg(&self) -> Option<String> {
if self.clipboard.is_empty() {
/// The objects as standalone SVG (`None` when there are none).
pub fn svg(&self) -> Option<String> {
if self.is_empty() {
return None;
}
let d = self.clipboard.to_document();
let d = self.to_document();
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
let flat = super::rasterfx::flatten_pixel_effects(&d);
Some(vectorcraft_svg::export(
@@ -193,6 +191,13 @@ impl Session {
}
}
impl Session {
/// The internal clipboard as standalone SVG (`None` when it is empty).
pub fn clipboard_svg(&self) -> Option<String> {
self.clipboard.svg()
}
}
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "svg": s.clipboard_svg() }))
}
+63 -34
View File
@@ -217,11 +217,10 @@ fn type_only(n: &Node) -> bool {
}
}
impl Session {
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
/// only).
pub fn clipboard_text(&self) -> Option<String> {
let nodes = &self.clipboard.nodes;
impl Clipboard {
/// The copied type objects' text, one object per line (`None` unless it holds type only).
pub fn text(&self) -> Option<String> {
let nodes = &self.nodes;
if nodes.is_empty() || !nodes.iter().all(type_only) {
return None;
}
@@ -236,64 +235,94 @@ impl Session {
Some(lines.join("\n"))
}
/// The clipboard as a document whose one artboard is the objects' visual bounds (`None` when
/// it is empty or has no extent).
fn clipboard_cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
let b = self.clipboard.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
Some((fileio::with_single_artboard(self.clipboard.to_document(), b, "Clipboard"), b))
/// The objects as a document whose one artboard is their visual bounds (`None` when there are
/// none or they have no extent).
fn cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
let b = self.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
}
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
/// when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when
/// there are none.
pub fn png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
const C: &str = "clipboard.exportPng";
let Some((doc, b)) = self.clipboard_cropped() else { return Ok(None) };
let Some((doc, b)) = self.cropped() else { return Ok(None) };
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
Ok(Some((png, w, h)))
}
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
/// clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
let Some((doc, _)) = self.clipboard_cropped() else { return Ok(None) };
/// A one-page PDF of the objects (without the native document inside); `None` when there are
/// none.
pub fn pdf(&self) -> Result<Option<Vec<u8>>> {
let Some((doc, _)) = self.cropped() else { return Ok(None) };
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
}
/// The MIME types of [`Self::clipboard_flavours`], without making them.
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
if self.clipboard.is_empty() {
/// The MIME types of [`Self::flavours`], without making them.
pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> {
if self.is_empty() {
return vec![];
}
let (svg, pdf) = (self.prefs.copy_as_svg, self.prefs.copy_as_pdf);
let text = svg || self.clipboard.nodes.iter().all(type_only);
let text = svg || self.nodes.iter().all(type_only);
// Art without an area (a lone guide-like line) has no page or picture.
let area = self.clipboard.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
[(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect()
}
/// What Copy offers other apps, best first (see `clipboard.flavours`). A flavour that can't be
/// made (art too large to render) is left out; the PNG is scaled down to at most
/// [`COPY_PNG_SIDE`] pixels a side.
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
let types = self.clipboard_flavour_types();
let svg = types.contains(&SVG).then(|| self.clipboard_svg()).flatten();
/// What Copy offers other apps, best first, with Include SVG Code (`svg`) and On Copy: PDF
/// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large
/// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side.
pub fn flavours(&self, svg: bool, pdf: bool) -> Vec<Flavour> {
let types = self.flavour_types(svg, pdf);
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
let mut out = vec![];
for mime in types {
let data = match mime {
TEXT => self.clipboard_text().or_else(|| svg.clone()).map(String::into_bytes),
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
SVG => svg.clone().map(String::into_bytes),
PDF => self.clipboard_pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
_ => {
let side = self.clipboard.bounds().map_or(1.0, |b| b.width().max(b.height()));
let side = self.bounds().map_or(1.0, |b| b.width().max(b.height()));
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
self.clipboard_png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
}
};
out.extend(data.map(|data| Flavour { mime, data }));
}
out
}
}
impl Session {
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
/// only).
pub fn clipboard_text(&self) -> Option<String> {
self.clipboard.text()
}
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
/// when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
self.clipboard.png(scale)
}
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
/// clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
self.clipboard.pdf()
}
/// The MIME types of [`Self::clipboard_flavours`], without making them.
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
self.clipboard.flavour_types(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
}
/// What Copy offers other apps, best first (see `clipboard.flavours`): [`Clipboard::flavours`]
/// by the Clipboard Handling preferences.
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
self.clipboard.flavours(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
}
/// Replace the clipboard with `clip`, centred on `p`'s `center` (default: the first
/// artboard's centre) → the number of objects.
+14 -3
View File
@@ -45,7 +45,8 @@ struct Task {
/// A job in progress.
pub struct Job {
id: u64,
/// What the status bar shows ("Saving Poster.vectorcraft").
/// What the status bar and `ui.inspect` show ("Saving Poster.vectorcraft"); empty for work they
/// don't show.
pub label: String,
/// The document the job reads (`DocState::uid`), if any.
pub doc: Option<u64>,
@@ -96,7 +97,8 @@ impl Background {
/// Run `work` on a worker thread when `enabled` and the host can write from one, else now; `then`
/// gets its result on the UI thread. → `then`'s result, or `{background: true, status}` while the
/// work runs (the status bar shows `label`).
/// work runs (the status bar shows `label`). With an empty `label` neither the status bar nor
/// `ui.inspect` shows the work, and the reply has no `status`.
pub fn run(
app: &mut VectorcraftApp,
enabled: bool,
@@ -115,6 +117,9 @@ pub fn run(
let bg = &mut app.background;
bg.next_id += 1;
bg.jobs.push(Job { id, label: label.clone(), doc, then: Box::new(then) });
if label.is_empty() {
return Ok(json!({ "background": true }));
}
let status = format!("{label}…");
app.status(status.clone());
return Ok(json!({ "background": true, "status": status }));
@@ -142,7 +147,13 @@ pub fn poll(app: &mut VectorcraftApp) {
/// Wait for every job and apply its result (before quitting, closing a document, or another save
/// of a document a job is reading).
pub fn wait_all(app: &mut VectorcraftApp) {
while !app.background.jobs.is_empty() {
wait_until(app, |_| false);
}
/// Apply the jobs' results as they arrive (in the order the jobs were started) until `ready` holds
/// or no job is left.
pub fn wait_until(app: &mut VectorcraftApp, ready: impl Fn(&VectorcraftApp) -> bool) {
while !ready(app) && !app.background.jobs.is_empty() {
let Some(done) = app.background.worker.as_ref().and_then(|(_, rx)| rx.recv().ok()) else {
// The worker is gone: its jobs can't finish.
for job in std::mem::take(&mut app.background.jobs) {
+8 -2
View File
@@ -704,8 +704,14 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
);
}
// Background saves, exports and opens in progress, else the last message.
let busy: Vec<&str> =
app.background.jobs.iter().map(|j| j.label.as_str()).chain(app.opening.pending.iter().map(|p| p.label.as_str())).collect();
let busy: Vec<&str> = app
.background
.jobs
.iter()
.filter(|j| !j.label.is_empty())
.map(|j| j.label.as_str())
.chain(app.opening.pending.iter().map(|p| p.label.as_str()))
.collect();
if let Some(job) = busy.first() {
let more = busy.len() - 1;
let label = crate::i18n::msg(job);
+1 -1
View File
@@ -159,7 +159,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// The menus are in the macOS menu bar rather than the window.
"nativeMenuBar": app.services.native_menu.is_some(),
// Saves and exports still being written in the background (Background Save / Export).
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
"background": app.background.jobs.iter().filter(|j| !j.label.is_empty()).map(|j| j.label.as_str()).collect::<Vec<_>>(),
// Documents still being read off the UI thread (File › Open, Open Recent): their paths.
"opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(),
// The screen color sampler is shown, waiting for the person at the computer
+5
View File
@@ -390,6 +390,8 @@ pub struct VectorcraftApp {
clipboard_out: Option<String>,
clipboard_published: Option<String>,
pub(crate) clipboard_in: Option<vectorcraft_engine::cmd::clipboard::Flavour>,
/// The Copy or Cut whose formats go to the system clipboard ([`Self::publish_clipboard`]).
clipboard_publishing: sysclip::Publishing,
/// A URL to open through egui next frame (when the host has no `open_url` service).
pending_url: Option<String>,
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
@@ -500,6 +502,7 @@ impl VectorcraftApp {
clipboard_out: None,
clipboard_published: None,
clipboard_in: None,
clipboard_publishing: Default::default(),
pending_url: None,
control_rx: None,
pending_screenshots: vec![],
@@ -1112,6 +1115,7 @@ impl VectorcraftApp {
}
self.poll_font_check(ctx);
picks::poll(self, ctx);
self.write_formats_before_text_input(ctx);
background::poll(self);
screen_color::poll(self, ctx);
opening::poll(self);
@@ -1358,6 +1362,7 @@ impl VectorcraftApp {
self.ui_fonts.frame(&ctx);
scrub::end_frame(self, &ctx);
native_menu::sync(self, &ctx);
self.drop_formats_after_egui_copy(&ctx);
self.perf.frame_ms = now_ms() - t0;
let _ = json!(null);
}
+1 -1
View File
@@ -3162,7 +3162,7 @@ fn field_event(app: &mut VectorcraftApp, id: &str) -> Option<egui::Event> {
"select.all" => egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND },
"edit.copy" => egui::Event::Copy,
"edit.cut" => egui::Event::Cut,
"edit.paste" | "edit.pasteWithoutFormatting" => egui::Event::Paste(app.system_clipboard_text()?),
"edit.paste" | "edit.pasteWithoutFormatting" => egui::Event::Paste(app.written_clipboard_text()?),
_ => return None,
})
}
+1 -1
View File
@@ -133,7 +133,7 @@ pub(crate) fn paste_command(held: Modifiers) -> &'static str {
/// 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.
#[derive(Default)]
#[derive(Clone, Default)]
pub(crate) struct PasteChord {
/// Paste keys whose press was reported (typed, not pasting).
pressed: Vec<Key>,
+122 -11
View File
@@ -7,10 +7,15 @@
//! publishes SVG text through egui and Paste takes SVG text, and the pictures and files the host
//! reads from a paste itself ([`VectorcraftApp::paste_from_host`]).
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::channel;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, EPS, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};
use crate::VectorcraftApp;
use crate::background::{self, Writer};
/// The platform clipboard, with every format it carries.
pub trait SystemClipboard {
@@ -29,6 +34,17 @@ pub trait SystemClipboard {
/// thread and the handle lives and dies there, so the handle needn't be `Send`.
pub type ClipboardProbeFactory = Box<dyn FnOnce() -> Box<dyn SystemClipboard> + Send>;
/// The Copy or Cut whose formats go to the system clipboard ([`VectorcraftApp::publish_clipboard`]).
#[derive(Default)]
pub(crate) struct Publishing {
/// The number of the latest Copy or Cut, raised by one when a copy through egui drops the
/// formats not written yet. Only the formats of the Copy or Cut with this number are made and
/// written.
latest: Arc<AtomicU64>,
/// The latest one's formats wait to be written.
pending: bool,
}
/// The command (and its params) that loads `f` into the internal clipboard, centred on `center`.
pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static str, Value) {
let b64 = || vectorcraft_format::base64_encode(&f.data);
@@ -50,20 +66,106 @@ pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static
}
impl VectorcraftApp {
/// After Copy or Cut: offer the copied objects to other apps.
/// After Copy or Cut: offer the copied objects to other apps. With the system clipboard
/// service, the background worker makes the formats from a copy of the clipboard
/// ([`background::run`]) and they are written when they are ready. While the worker has a
/// save, an export, a print or a recovery copy to finish, they are made and written at once
/// instead. A later Copy or Cut, or a copy through egui
/// ([`Self::drop_formats_after_egui_copy`]), drops the formats not written yet. While the
/// latest Copy or Cut's formats wait to be written, Paste takes the internal clipboard, and
/// while a text field or type has the keyboard the app waits for them
/// ([`Self::write_formats_before_text_input`]).
pub(crate) fn publish_clipboard(&mut self) {
match self.services.system_clipboard.as_mut() {
Some(cb) => {
if let Err(e) = cb.write(&self.session.clipboard_flavours()) {
self.ui.status = format!("Couldn't copy to the system clipboard: {e}");
}
}
None if self.session.prefs.copy_as_svg => {
if self.services.system_clipboard.is_none() {
if self.session.prefs.copy_as_svg {
self.clipboard_out = self.session.clipboard_svg();
self.clipboard_published = self.clipboard_out.clone();
}
None => {}
return;
}
let (clip, svg, pdf) = (self.session.clipboard.clone(), self.session.prefs.copy_as_svg, self.session.prefs.copy_as_pdf);
let latest = self.clipboard_publishing.latest.clone();
let n = latest.fetch_add(1, Ordering::SeqCst).wrapping_add(1);
self.clipboard_publishing.pending = true;
let (tx, made) = channel();
let work = move |_: Writer| {
// These formats are not made when a later Copy or Cut, or a copy through egui, dropped them.
if latest.load(Ordering::SeqCst) == n {
// The receiver is gone only when the job was dropped unapplied.
let _ = tx.send(clip.flavours(svg, pdf));
}
Ok(Value::Null)
};
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
let publishing = &mut app.clipboard_publishing;
if publishing.latest.load(Ordering::SeqCst) != n {
return Ok(Value::Null);
}
publishing.pending = false;
let flavours = r.and_then(|_| made.try_recv().map_err(|e| e.to_string()));
let written = flavours.and_then(|f| app.services.system_clipboard.as_mut().map_or(Ok(()), |cb| cb.write(&f)));
if let Err(e) = written {
app.ui.status = format!("Couldn't copy to the system clipboard: {e}");
}
Ok(Value::Null)
};
// Only the formats of other copies (jobs without a label) may be ahead of these on the worker.
let free = self.background.jobs.iter().all(|j| j.label.is_empty());
// No label: the status bar doesn't show the job, and the status stays as it is.
if let Err(e) = background::run(self, free, String::new(), None, work, then) {
self.ui.status = e;
}
}
/// End of a frame: when something copied text or a picture through egui this frame (a text
/// field, the Type tool), the formats of an earlier Copy or Cut that are not written yet are
/// dropped, and the system clipboard keeps that copy.
pub(crate) fn drop_formats_after_egui_copy(&mut self, ctx: &egui::Context) {
let copied = ctx.output(|o| o.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_))));
let publishing = &mut self.clipboard_publishing;
if copied && publishing.pending {
publishing.latest.fetch_add(1, Ordering::SeqCst);
publishing.pending = false;
}
}
/// Start of a frame, before the background results that arrived are applied: while a text
/// field or type has the keyboard (egui's or the Type tool's), the formats a Copy or Cut is
/// making are written first ([`Self::written_clipboard_text`]). A paste whose text the host read
/// before that pastes their text instead, or nothing when they have none. A paste chord the
/// host found no text for pastes their text, also when its key comes up after they were
/// written, while the system clipboard still holds them. On the canvas the app does not wait
/// for them: Paste takes the internal clipboard ([`Self::adopt_system_clipboard`]).
pub(crate) fn write_formats_before_text_input(&mut self, ctx: &egui::Context) {
if !(ctx.egui_wants_keyboard_input() || self.session.tool_wants_text()) {
return;
}
// A paste chord egui sent no Paste event for: the clipboard held no text when it came.
let textless = ctx.input(|i| self.paste_chord.clone().textless_paste(&i.events).is_some());
if !self.clipboard_publishing.pending {
// The formats the clipboard still holds were written after the chord's key went down.
if textless
&& self.services.system_clipboard.as_mut().is_some_and(|cb| cb.holds_ours())
&& let Some(text) = self.system_clipboard_text()
{
ctx.input_mut(|i| i.events.insert(0, egui::Event::Paste(text)));
}
return;
}
let text = self.written_clipboard_text();
ctx.input_mut(|i| {
i.events.retain_mut(|e| match (e, &text) {
(egui::Event::Paste(t), Some(text)) => {
t.clone_from(text);
true
}
(egui::Event::Paste(_), None) => false,
_ => true,
});
if textless && let Some(text) = text {
i.events.insert(0, egui::Event::Paste(text));
}
});
}
/// Paste `f`, which the host read from the system clipboard itself (a web page's paste carries
@@ -79,7 +181,8 @@ impl VectorcraftApp {
/// Before a paste: what another app put on the system clipboard replaces the internal
/// clipboard (centred in the view). What we published ourselves keeps the lossless internal
/// copy. An error means nothing should be pasted.
/// copy, and so does what we are still making the formats of. An error means nothing should be
/// pasted.
pub(crate) fn adopt_system_clipboard(&mut self) -> Result<(), String> {
let pasted = self.clipboard_in.take();
let center = self.view().map(|v| [v.center.x, v.center.y]);
@@ -87,7 +190,7 @@ impl VectorcraftApp {
// A picture or file the host read from the paste.
(Some(f), _) if f.mime != TEXT => import_command(&f, center),
(_, Some(cb)) => {
if cb.holds_ours() {
if self.clipboard_publishing.pending || cb.holds_ours() {
return Ok(());
}
let Some(f) = cb.read(&PASTE_ORDER) else { return Ok(()) };
@@ -116,6 +219,14 @@ impl VectorcraftApp {
})
}
/// The text on the system clipboard once the formats a Copy or Cut is making are written, for a
/// paste into a text field or type.
pub(crate) fn written_clipboard_text(&mut self) -> Option<String> {
// Only other copies' formats are ahead of them on the worker ([`Self::publish_clipboard`]).
background::wait_until(self, |app| !app.clipboard_publishing.pending);
self.system_clipboard_text()
}
/// The text on the system clipboard, if any.
pub(crate) fn system_clipboard_text(&mut self) -> Option<String> {
match self.services.system_clipboard.as_mut() {
+1 -1
View File
@@ -141,7 +141,7 @@ fn quitting_waits_for_background_saves() {
}
/// The status bar's texts in one headless frame.
fn status_bar_texts(app: &mut VectorcraftApp) -> Vec<String> {
pub(super) fn status_bar_texts(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
+279 -3
View File
@@ -3,20 +3,25 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::mpsc::{Sender, channel};
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::clipboard::{BITMAP, Flavour, PDF, PNG, SVG, TEXT};
use vectorcraft_geom::Point;
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::{Services, SystemClipboard, VectorcraftApp, menus};
use crate::{Services, SystemClipboard, VectorcraftApp, background, menus};
/// The fake clipboard's contents: what the app wrote, or what "another app" copied.
#[derive(Default)]
pub(super) struct Board {
flavours: Vec<Flavour>,
ours: bool,
/// How many times the app wrote it.
writes: usize,
}
struct Fake(Rc<RefCell<Board>>);
@@ -31,7 +36,8 @@ impl Fake {
impl SystemClipboard for Fake {
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
*self.0.borrow_mut() = Board { flavours: flavours.to_vec(), ours: true };
let mut b = self.0.borrow_mut();
*b = Board { flavours: flavours.to_vec(), ours: true, writes: b.writes + 1 };
Ok(())
}
fn holds_ours(&mut self) -> bool {
@@ -59,7 +65,8 @@ pub(super) fn app() -> (VectorcraftApp, Rc<RefCell<Board>>) {
/// Another app copies `flavours`.
pub(super) fn copy_elsewhere(board: &Rc<RefCell<Board>>, flavours: Vec<(&'static str, Vec<u8>)>) {
let flavours = flavours.into_iter().map(|(mime, data)| Flavour { mime, data }).collect();
*board.borrow_mut() = Board { flavours, ours: false };
let mut b = board.borrow_mut();
*b = Board { flavours, ours: false, writes: b.writes };
}
pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
@@ -109,6 +116,51 @@ fn key(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event
egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
}
/// [`app`] with the background worker of a host that writes from any thread, as the desktop app's.
fn app_with_worker() -> (VectorcraftApp, Rc<RefCell<Board>>) {
let (mut app, board) = app();
app.services.write_shared = Some(Arc::new(|_: &str, _: &[u8]| Err("no files are written here".into())));
(app, board)
}
/// [`app_with_worker`], its worker busy with a job labeled `label` (none: another Copy's formats)
/// until the sender is dropped, or for 10 s.
fn app_with_busy_worker(label: &str) -> (VectorcraftApp, Rc<RefCell<Board>>, Sender<()>) {
let (mut app, board) = app_with_worker();
let hold = hold_worker(&mut app, label);
(app, board, hold)
}
/// Start a job labeled `label` that holds the worker until the sender is dropped, or for 10 s.
fn hold_worker(app: &mut VectorcraftApp, label: &str) -> Sender<()> {
let (hold, held) = channel::<()>();
let work = move |_: background::Writer| {
let _ = held.recv_timeout(std::time::Duration::from_secs(10));
Ok(Value::Null)
};
background::run(app, true, label.into(), None, work, |_, r| r).unwrap();
hold
}
/// One headless frame of the whole window (app logic, then layout), in a context this test thread
/// keeps for these frames; `copy`: text copied through egui in it, as a text field or the Type
/// tool copies.
fn window_frame(app: &mut VectorcraftApp, copy: Option<&str>) {
thread_local! {
static CTX: egui::Context = egui::Context::default();
}
let ctx = CTX.with(Clone::clone);
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 850.0)));
let mut out = ctx.run_ui(egui::RawInput { screen_rect, ..Default::default() }, |ui| {
app.logic(ui.ctx());
if let Some(text) = copy {
ui.ctx().copy_text(text.into());
}
app.ui(ui);
});
out.textures_delta.clear();
}
#[test]
fn copy_publishes_pdf_to_the_service_only_with_the_preference_on() {
let (mut app, board) = app();
@@ -133,6 +185,230 @@ fn our_own_copy_pastes_the_lossless_internal_clipboard() {
assert!(matches!(pasted(&app)[0].kind, NodeKind::Path { .. }));
}
/// Copy returns before the system clipboard's formats are made: the background worker makes them
/// (here after another Copy's) and they are written when it is done. A paste meanwhile takes what
/// was copied, and neither the status bar nor `ui.inspect` shows the work.
#[test]
fn copy_publishes_the_formats_the_worker_makes_and_pastes_meanwhile() {
let (mut app, board, hold) = app_with_busy_worker("");
app.status("Ready");
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec())]);
copy_rect(&mut app);
assert_eq!(written(&board), [TEXT], "Copy wrote nothing itself");
assert_eq!(app.ui.status, "Ready");
let bar = crate::tests_background::status_bar_texts(&mut app);
assert!(bar.iter().any(|t| t == "Ready") && !bar.iter().any(|t| t.contains('…')), "{bar:?}");
assert_eq!(crate::control::inspect(&app, &egui::Context::default())["background"], json!([]));
run(&mut app, "edit.paste", json!({}));
assert!(matches!(pasted(&app)[0].kind, NodeKind::Path { .. }), "the copied rectangle, not the other app's text");
drop(hold);
background::wait_all(&mut app);
assert_eq!(written(&board), [TEXT, SVG, PNG]);
assert_eq!(board.borrow().flavours, app.session.clipboard_flavours());
assert_eq!(app.ui.status, "Ready");
}
/// Two copies made before the worker gets to them: only the later one's formats are made and
/// written, and the earlier one ends without a message.
#[test]
fn a_later_copy_replaces_formats_not_written_yet() {
let (mut app, board, hold) = app_with_busy_worker("");
app.status("Ready");
copy_rect(&mut app);
let first = app.session.clipboard_flavours();
let id = run(&mut app, "shape.ellipse", json!({"x": 100, "y": 100, "width": 30, "height": 30}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [id]}));
run(&mut app, "edit.cut", json!({}));
drop(hold);
background::wait_all(&mut app);
let b = board.borrow();
assert_eq!(b.writes, 1);
assert_eq!(b.flavours, app.session.clipboard_flavours());
assert_ne!(b.flavours, first);
assert_eq!(app.ui.status, "Ready");
}
/// While the worker saves (or exports, prints, keeps a recovery copy), Copy makes and writes the
/// formats at once: they don't wait for that work.
#[test]
fn a_copy_while_the_worker_saves_writes_the_formats_at_once() {
let (mut app, board, hold) = app_with_busy_worker("Saving a.vectorcraft");
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec())]);
copy_rect(&mut app);
assert_eq!(written(&board), [TEXT, SVG, PNG]);
assert_eq!(board.borrow().flavours, app.session.clipboard_flavours());
assert_eq!(app.background.jobs.len(), 1, "only the save");
drop(hold);
background::wait_all(&mut app);
assert_eq!(board.borrow().writes, 1);
}
/// Text copied in a field or with the Type tool after Copy, before the formats are written: they
/// are not written, the system clipboard keeps the text, and Paste takes it.
#[test]
fn text_copied_before_the_formats_are_written_stays_on_the_clipboard() {
let (mut app, board, hold) = app_with_busy_worker("");
window_frame(&mut app, None);
window_frame(&mut app, None);
copy_rect(&mut app);
window_frame(&mut app, Some("Words copied in a field"));
// What the host puts on the system clipboard for that copy.
copy_elsewhere(&board, vec![(TEXT, b"Words copied in a field".to_vec())]);
drop(hold);
background::wait_all(&mut app);
assert_eq!(board.borrow().writes, 0);
run(&mut app, "edit.paste", json!({}));
let NodeKind::Text(t) = &pasted(&app)[0].kind else { panic!("not type: {:?}", pasted(&app)[0].kind) };
assert_eq!(t.plain_text(), "Words copied in a field");
}
/// The Edit menu's Paste into a text field after Copy of type, before the formats are written: it
/// waits for them, not for a save started after them, and pastes the type's text, not what
/// another app copied before.
#[test]
fn the_menus_paste_into_a_field_waits_for_the_formats() {
let (mut app, board) = app_with_worker();
let t = run(&mut app, "text.create", json!({"x": 20, "y": 250, "text": "Words"}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [t]}));
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec())]);
run(&mut app, "edit.copy", json!({}));
let hold = hold_worker(&mut app, "Saving a.vectorcraft");
// A text field with the keyboard; in the second frame Paste is chosen from the menu.
let (ctx, id, mut field) = (egui::Context::default(), egui::Id::new("field"), String::new());
for paste in [false, true] {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
if paste {
menus::invoke_from_system_menu(&mut app, ui.ctx(), "edit.paste", json!({}));
}
ui.add(egui::TextEdit::singleline(&mut field).id(id)).request_focus();
});
out.textures_delta.clear();
}
assert_eq!(field, "Words");
assert_eq!(app.background.jobs.len(), 1, "the save is still running");
drop(hold);
background::wait_all(&mut app);
}
/// A paste chord into type being edited, before the formats are written: the formats are written
/// first, and the paste pastes their text whether the host read another app's text for it or found
/// no text, also when the chord's key comes up a frame after they were written; with no text in
/// the formats (Include SVG Code off, a rectangle) it pastes nothing.
#[test]
fn a_paste_chord_into_type_waits_for_the_formats() {
let (mut app, board) = app_with_worker();
frame(&mut app, vec![]);
frame(&mut app, vec![]);
let texts = |app: &VectorcraftApp| -> Vec<String> {
let layer = &app.session.active().unwrap().doc.layers[0];
layer.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect()
};
// The chord's release comes after the Paste event egui sends when the clipboard holds text.
let release = || key(egui::Key::V, false, egui::Modifiers::COMMAND);
let read = || vec![egui::Event::Paste("Words another app copied".into()), release()];
let paste_into_new_type = |app: &mut VectorcraftApp, y: f64, frames: Vec<Vec<egui::Event>>| {
app.select_tool("type");
let view = app.view_info();
for kind in [PointerKind::Down, PointerKind::Up] {
app.session.pointer(&PointerEvent::new(kind, 100.0, y), view).unwrap();
}
assert!(app.session.tool_wants_text());
for events in frames {
frame(app, events);
}
app.select_tool("selection");
};
let t = run(&mut app, "text.create", json!({"x": 20, "y": 250, "text": "Words"}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [t]}));
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec())]);
run(&mut app, "edit.copy", json!({}));
paste_into_new_type(&mut app, 100.0, vec![read()]);
assert_eq!(texts(&app), ["Words", "Words"], "the host read another app's text");
run(&mut app, "select.set", json!({"ids": [t]}));
copy_elsewhere(&board, vec![(PNG, blue_png())]);
run(&mut app, "edit.copy", json!({}));
paste_into_new_type(&mut app, 130.0, vec![vec![release()]]);
assert_eq!(texts(&app), ["Words", "Words", "Words"], "the host found no text");
// The key goes down before the formats are written (the host finds no text) and comes up in
// the frame after the one that writes them.
run(&mut app, "select.set", json!({"ids": [t]}));
copy_elsewhere(&board, vec![(PNG, blue_png())]);
run(&mut app, "edit.copy", json!({}));
paste_into_new_type(&mut app, 190.0, vec![vec![], vec![release()]]);
assert_eq!(texts(&app), ["Words", "Words", "Words", "Words"], "the key came up a frame later");
run(&mut app, "prefs.set", json!({"key": "copyAsSvg", "value": false}));
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec())]);
copy_rect(&mut app);
paste_into_new_type(&mut app, 160.0, vec![read()]);
assert_eq!(written(&board), [PNG]);
assert!(!texts(&app).iter().any(|t| t.contains("another app")), "{:?}", texts(&app));
}
/// A paste key on the canvas before the formats are written pastes the internal clipboard at once,
/// without waiting for them.
#[test]
fn a_paste_key_on_the_canvas_does_not_wait_for_the_formats() {
let (mut app, board, hold) = app_with_busy_worker("");
frame(&mut app, vec![]);
frame(&mut app, vec![]);
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec())]);
copy_rect(&mut app);
frame(&mut app, vec![egui::Event::Paste("Words another app copied".into())]);
assert!(matches!(pasted(&app)[0].kind, NodeKind::Path { .. }), "the copied rectangle");
assert_eq!(written(&board), [TEXT], "the formats are not written yet");
drop(hold);
background::wait_all(&mut app);
}
/// The formats the worker makes from its copy of the clipboard are the ones the session makes,
/// byte for byte: a rectangle; a group with a gradient, a drop shadow and 50% opacity; an
/// embedded image; type alone (its text); and everything with On Copy: PDF.
#[test]
fn the_worker_makes_the_formats_the_session_makes() {
let (mut app, board) = app_with_worker();
// Copy the selection, then compare the formats the worker wrote with the session's. A PDF is
// dated to the second it is made in, so when formats with a PDF differ and the clock has moved
// to another second since the Copy, they are compared again after another Copy.
let check = |app: &mut VectorcraftApp, what: &str, mimes: &[&str]| {
for _ in 0..100 {
let second = vectorcraft_doc::metadata::now_unix();
run(app, "edit.copy", json!({}));
assert_eq!(app.background.jobs.len(), 1, "{what}: made on the worker");
background::wait_all(app);
assert_eq!(written(&board), mimes, "{what}");
let session = app.session.clipboard_flavours();
let dated = mimes.contains(&PDF) && vectorcraft_doc::metadata::now_unix() != second;
if board.borrow().flavours == session || !dated {
assert_eq!(board.borrow().flavours, session, "{what}");
return;
}
}
panic!("{what}: the clock moved to another second during each of 100 copies");
};
let r = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20}))["id"].clone();
run(&mut app, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut app, "select.set", json!({"ids": [r]}));
check(&mut app, "a rectangle", &[TEXT, SVG, PNG]);
let e = run(&mut app, "shape.ellipse", json!({"x": 60, "y": 60, "width": 80, "height": 50}))["id"].clone();
run(&mut app, "paint.setFill", json!({"gradient": {"kind": "linear"}}));
let s = run(&mut app, "shape.star", json!({"cx": 200, "cy": 100, "radius1": 40, "radius2": 20}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [e, s]}));
run(&mut app, "object.group", json!({}));
run(&mut app, "effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 3}}));
run(&mut app, "object.setProps", json!({"opacity": 50}));
check(&mut app, "a group", &[TEXT, SVG, PNG]);
run(&mut app, "clipboard.importImage", json!({"dataBase64": vectorcraft_format::base64_encode(&blue_png())}));
run(&mut app, "edit.paste", json!({}));
check(&mut app, "an embedded image", &[TEXT, SVG, PNG]);
let t = run(&mut app, "text.create", json!({"x": 20, "y": 250, "text": "Words"}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [t]}));
check(&mut app, "type alone", &[TEXT, SVG, PNG]);
assert_eq!(board.borrow().flavours[0].data, b"Words");
run(&mut app, "prefs.set", json!({"key": "copyAsPdf", "value": true}));
run(&mut app, "select.all", json!({}));
check(&mut app, "everything, with On Copy: PDF", &[TEXT, SVG, PDF, PNG]);
}
#[test]
fn a_bitmap_from_another_app_pastes_as_an_embedded_image_at_the_view_centre() {
let (mut app, board) = app();
+6 -2
View File
@@ -2312,8 +2312,12 @@ as `document.open` reads one), `clipboard.importImage {dataBase64, mime?}` (an e
physical size) or `clipboard.importText {text}` (point text in the default type style), each centred on `center`
(default: the first artboard); then any `edit.paste*` command pastes it. The desktop app does this itself: Copy and
Cut publish the formats to the system clipboard, and a Paste outside text editing first reads what another app
copied (SVG, then PDF, then text, then a bitmap; SVG markup in text counts as SVG). Pasting what VectorCraft copied
keeps the lossless internal clipboard. Windows carries every format both ways and pastes a bitmap from PNG, else
copied (SVG, then PDF, then text, then a bitmap; SVG markup in text counts as SVG). A background thread makes the
formats and they are published when they are ready. While that thread has a save, an export, a print or a recovery
copy to finish, Copy and Cut make and publish them at once. When another Copy or Cut, or a copy of text in
VectorCraft, comes first, they are dropped. Pasting what VectorCraft copied keeps the lossless internal clipboard,
also before its formats are published. While a text field or the Type tool has the keyboard, the app waits for
them. Windows carries every format both ways and pastes a bitmap from PNG, else
from the device-independent bitmap screenshots copy (`CF_DIBV5`, then `CF_DIB`; alpha kept, opaque when it is zero
throughout, at most 32768 px a side); macOS and Linux carry one format (the text, else the PNG) and paste text and
bitmaps (macOS the pasteboard's PNG, else TIFF, as screenshots copy them). On the web Copy publishes SVG text, and
+1
View File
@@ -568,6 +568,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
- Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected.
- Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`).
- Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499).