Merge pull request #4277 from dotCooCoo/fix/bitmap-paste-off-ui-thread

Clipboard: a bitmap pasted from another app is decoded off the UI thread
This commit is contained in:
BFlat
2026-11-02 14:55:08 +07:00
committed by GitHub
43 changed files with 1572 additions and 48 deletions
+64 -18
View File
@@ -20,7 +20,7 @@ use std::io::{Cursor, Read};
use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
use vectorcraft_ui_egui::SystemClipboard;
use vectorcraft_ui_egui::{ClipboardRead, SystemClipboard};
/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where
/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
@@ -172,7 +172,8 @@ mod win {
use clipboard_win::{formats, options::NoClear, raw};
use super::{
BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats,
BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of,
windows_formats,
};
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
@@ -249,10 +250,16 @@ mod win {
// A clipboard manager may have copied our data again as its own.
let Some(ours) = self.text.as_deref() else { return false };
let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false };
read_one(TEXT).is_some_and(|f| f.data == ours.as_bytes())
read_one(TEXT).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes())
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
self.read_deferred(mimes)?.flavour()
}
/// A device-independent bitmap is copied off the clipboard here and decoded and encoded
/// as PNG by the work that comes back.
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {
let mut files = vec![];
{
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
@@ -260,7 +267,7 @@ mod win {
let _ = raw::get_file_list_path(&mut files);
}
// The files are read with the clipboard closed again, not keeping other apps waiting.
copied_file(files, mimes).or_else(|| {
copied_file(files, mimes).map(ClipboardRead::Now).or_else(|| {
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
mimes.iter().find_map(|m| read_one(m))
})
@@ -284,16 +291,28 @@ mod win {
})
}
/// One flavour, while the clipboard is open.
fn read_one(mime: &'static str) -> Option<Flavour> {
/// A device-independent bitmap as a PNG flavour.
fn dib_png(dib: &[u8]) -> Option<Flavour> {
png_flavour(&dib_image(dib)?)
}
/// Copy both bitmap formats while the clipboard is open, then decode them in preference order.
pub(super) fn read_dibs(mut get: impl FnMut(u32) -> Option<Vec<u8>>) -> Option<ClipboardRead> {
let dibs: Vec<_> = DIBS.into_iter().filter_map(&mut get).collect();
(!dibs.is_empty()).then_some(ClipboardRead::Later(Box::new(move || dibs.iter().find_map(|dib| dib_png(dib)))))
}
/// One flavour, while the clipboard is open. A device-independent bitmap is copied here, and
/// the work that comes back decodes each captured format in preference order.
fn read_one(mime: &'static str) -> Option<ClipboardRead> {
let mut data = vec![];
let mime = match mime {
BITMAP if read_registered(PNG, &mut data) => PNG,
BITMAP => {
return DIBS.into_iter().find_map(|id| {
data.clear();
raw::get_vec(id, &mut data).ok()?;
png_flavour(&dib_image(&data)?)
return read_dibs(|id| {
let mut dib = vec![];
raw::get_vec(id, &mut dib).ok()?;
Some(dib)
});
}
TEXT => {
@@ -309,7 +328,7 @@ mod win {
{
data.truncate(end);
}
(!data.is_empty()).then_some(Flavour { mime, data })
(!data.is_empty()).then_some(ClipboardRead::Now(Flavour { mime, data }))
}
}
@@ -322,7 +341,7 @@ mod portable {
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
use super::{BITMAP, ClipboardRead, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
/// What our last write put on the clipboard.
enum Ours {
@@ -580,22 +599,27 @@ mod portable {
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
self.read_deferred(mimes)?.flavour()
}
/// arboard's image is read here and encoded as PNG by the work that comes back.
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {
let cb = self.cb(Asker::User).ok()?;
if let Some(f) = copied_file(cb.file_list(), mimes) {
return Some(f);
return Some(ClipboardRead::Now(f));
}
mimes.iter().find_map(|m| {
#[cfg(target_os = "macos")]
if let Some(f) = super::mac::read(m) {
return Some(f);
return Some(ClipboardRead::Now(f));
}
match *m {
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })),
BITMAP => {
let img = cb.get_image().ok()?;
let rgba =
image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
png_flavour(&rgba)
let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
let bytes = img.bytes.into_owned();
Some(ClipboardRead::Later(Box::new(move || png_flavour(&image::RgbaImage::from_raw(width, height, bytes)?))))
}
_ => None,
}
@@ -1057,6 +1081,28 @@ mod tests {
img.pixels().map(|p| p.0).collect()
}
#[test]
#[cfg(windows)]
fn dib_fallback_captures_both_formats_before_decoding() {
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
let valid = dib(40, 1, 1, 32, 0, &[], &bgra(&[RED]));
let mut captured = Vec::new();
let read = win::read_dibs(|id| {
captured.push(id);
match id {
CF_DIBV5 => Some(vec![40, 0, 0]),
CF_DIB => Some(valid.clone()),
_ => None,
}
})
.unwrap();
assert_eq!(captured, [CF_DIBV5, CF_DIB]);
let flavour = read.flavour().unwrap();
assert_eq!(flavour.mime, PNG);
assert_eq!(pixels(&decode_png(&flavour.data).unwrap()), [RED]);
}
#[test]
fn a_screenshot_dib_pastes_opaque_and_upright() {
// CF_DIB as screenshots copy it: 32-bit BI_RGB, bottom row first, the fourth byte zero.
+1
View File
@@ -22,6 +22,7 @@ use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDe
use vectorcraft_geom::Rect;
pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub use flavours::{ImageRead, read_image};
pub(crate) use resources::SwatchChoices;
pub use resources::{ArtResources, art_resources};
+27 -3
View File
@@ -413,14 +413,38 @@ fn flavours(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "flavours": s.clipboard_flavour_types() }))
}
/// A bitmap read as `clipboard.importImage` reads its `dataBase64` ([`read_image`]), for
/// [`Session::import_image_read`].
pub struct ImageRead(fileio::RasterImage);
/// Read `bytes` as `clipboard.importImage` reads its `dataBase64`, on any thread: the formats it
/// stores as PNG are decoded and encoded here.
pub fn read_image(bytes: &[u8]) -> Result<ImageRead> {
let mut img = fileio::raster_image(bytes).map_err(|e| bad("clipboard.importImage", e.to_string()))?;
img.blob = links::readable_and_kept(img.blob, links::kept_unread(bytes), false).map_err(|e| bad("clipboard.importImage", e.to_string()))?;
Ok(ImageRead(img))
}
impl Session {
/// `clipboard.importImage` with `p` (its `mime` and `center`) for an image [`read_image`] read
/// (off the UI thread in the desktop app), which it loads instead of reading `dataBase64`.
pub fn import_image_read(&mut self, read: ImageRead, p: &Value) -> Result<Value> {
self.image_hand_off = Some(read);
let r = self.execute("clipboard.importImage", p);
self.image_hand_off = None;
r
}
}
fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "clipboard.importImage";
if let Some(m) = str_param(p, "mime").filter(|m| !m.starts_with("image/") || *m == SVG) {
return Err(bad(C, format!("`{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText")));
}
let data = data_param(p, C)?;
let mut img = fileio::raster_image(&data).map_err(|e| bad(C, e.to_string()))?;
img.blob = links::readable_and_kept(img.blob, links::kept_unread(&data), false).map_err(|e| bad(C, e.to_string()))?;
let ImageRead(img) = match s.image_hand_off.take() {
Some(read) => read,
None => read_image(&data_param(p, C)?)?,
};
let (sx, sy) = pt_per_px(img.ppi);
let (w, h) = (img.width, img.height);
let image = ImageObject {
+3
View File
@@ -163,6 +163,9 @@ pub struct Session {
/// While [`Session::place_queue_read`] runs `file.place.queue`: the files read for it, which
/// the command loads the place cursor with instead of reading them; `None` otherwise.
pub(crate) queue_hand_off: Option<cmd::place::QueueRead>,
/// While [`Session::import_image_read`] runs `clipboard.importImage`: the image read for it,
/// which the command loads instead of reading `dataBase64`; `None` otherwise.
pub(crate) image_hand_off: Option<cmd::clipboard::ImageRead>,
/// What a [`cmd::TraceRun`] traced, until the next `imageTrace.make` or
/// `imageTrace.makeAndExpand` takes it ([`cmd::Traced::hand_in`]).
pub(crate) trace_hand_off: Option<cmd::Traced>,
+1
View File
@@ -79,6 +79,7 @@ impl Session {
batch_steps: None,
place_hand_off: None,
queue_hand_off: None,
image_hand_off: None,
trace_hand_off: None,
place_cursor_requests: false,
package_hand_off: None,
+25
View File
@@ -150,6 +150,30 @@ fn import_image_then_paste_gives_an_image() {
assert!(s.execute("clipboard.importImage", &json!({"dataBase64": "!!"})).is_err());
}
/// An image read apart from the session (off the UI thread in the desktop app) loads as
/// `clipboard.importImage` loads its bytes: the same reply and clipboard, and the same errors.
#[test]
fn an_image_read_apart_loads_as_import_image_loads_it() {
let mut bmp = vec![];
image::RgbImage::from_pixel(30, 20, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut bmp), image::ImageFormat::Bmp).unwrap();
let (mut at_once, mut apart) = (session(), session());
let p = json!({"mime": "image/bmp", "center": [100, 80]});
let mut with_data = p.clone();
with_data["dataBase64"] = json!(vectorcraft_format::base64_encode(&bmp));
let r = run(&mut at_once, "clipboard.importImage", with_data);
let read = crate::cmd::clipboard::read_image(&bmp).unwrap();
assert_eq!(apart.import_image_read(read, &p).unwrap(), r);
assert_eq!(apart.clipboard, at_once.clipboard);
assert!(apart.image_hand_off.is_none());
// Errors: bytes that aren't a picture, and a type that isn't a bitmap.
let not_a_picture = at_once.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(b"hello")})).unwrap_err();
assert_eq!(crate::cmd::clipboard::read_image(b"hello").err().map(|e| e.to_string()), Some(not_a_picture.to_string()));
let read = crate::cmd::clipboard::read_image(&bmp).unwrap();
let svg = apart.import_image_read(read, &json!({"mime": SVG})).unwrap_err();
assert!(svg.to_string().contains("isn't a bitmap"), "{svg}");
assert!(apart.image_hand_off.is_none() && apart.clipboard == at_once.clipboard, "nothing loaded");
}
/// A pasted PNG cut to half its length (a copied file still downloading) has a header but not
/// all its pixels: importing it is an error, and the clipboard keeps what it held.
#[test]
@@ -160,6 +184,7 @@ fn a_bitmap_whose_pixels_cant_be_read_is_not_pasted() {
let cut = crate::tests_links::cut_png(600, 300);
let e = s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&cut), "mime": "image/png"})).unwrap_err();
assert!(e.to_string().ends_with("the image's pixels can't be read"), "{e}");
assert_eq!(crate::cmd::clipboard::read_image(&cut).err().map(|e| e.to_string()), Some(e.to_string()));
assert_eq!(s.clipboard.nodes, held.nodes);
}
+18 -8
View File
@@ -1,5 +1,6 @@
use super::fonts::MAX_RECENT_FONTS;
use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar};
use crate::placing;
/// The last repeatable Pathfinder operation completed by a command or atomic batch.
fn completed_pathfinder_operation<'a>(id: &'a str, params: &'a Value) -> Option<&'a str> {
@@ -15,11 +16,14 @@ impl VectorcraftApp {
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
// A file dialog it shows off the UI thread runs it again with the path picked.
if self.picks.is_entry_free() {
let result = if self.picks.is_entry_free() {
let entry = picks::Entry::Command(id.to_string(), params.clone());
return picks::as_entry(self, move || entry, |app| app.run_now(id, params));
}
self.run_now(id, params)
picks::as_entry(self, move || entry, |app| app.run_now(id, params))
} else {
self.run_now(id, params)
};
placing::settle_swatch_paste(self);
result
}
/// [`Self::run`] it, inside what asks for file dialogs.
@@ -89,10 +93,6 @@ impl VectorcraftApp {
}
}
if id.starts_with("edit.paste") {
if let Err(e) = self.adopt_system_clipboard() {
self.ui.status = e.clone();
return Err(e);
}
// Paste (also without formatting) goes to the centre of the view.
if matches!(id, "edit.paste" | "edit.pasteWithoutFormatting")
&& ["center", "dx", "dy"].iter().all(|k| params.get(k).is_none())
@@ -101,6 +101,16 @@ impl VectorcraftApp {
{
p.insert("center".into(), serde_json::json!([c.x, c.y]));
}
// A Paste the user gives decodes a bitmap off the UI thread ([`placing::adopt_for_paste`]),
// and runs with these params once it is decoded.
match placing::adopt_for_paste(self, id, &params) {
Err(e) => {
self.ui.status = e.clone();
return Err(e);
}
Ok(Some(started)) => return Ok(started),
Ok(None) => {}
}
if let Some(r) = dialogs::swatch_conflict::ask(self, id, &params) {
return r;
}
+4
View File
@@ -492,6 +492,9 @@ pub(crate) fn parent(d: &Dialog) -> Option<Dialog> {
pub fn cancel(app: &mut VectorcraftApp) {
missing_fonts::requeue_replaced(app);
if let Some(d) = app.ui.dialog.take() {
if d.kind == swatch_conflict::KIND {
crate::placing::cancel_swatch_paste(app);
}
// Toolbar-only Flare options start no preview and must not cancel a gesture
// that was already active before opening them. The generic parameter dialog has a spec
// without a preview, yet previews some commands (Grid Repeat Options) when asked to.
@@ -536,6 +539,7 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
/// that dialog is gone, and with no dialog open the next document's Missing Fonts dialog opens
/// (one dialog at a time).
pub(crate) fn settle(app: &mut VectorcraftApp) {
crate::placing::settle_swatch_paste(app);
missing_fonts::requeue_replaced(app);
missing_fonts::stop_when_closed(app);
find_links::stop_when_closed(app);
@@ -41,6 +41,9 @@ pub fn ask(app: &mut VectorcraftApp, command: &str, params: &Value) -> Option<Di
"conflicts": conflicts, "index": 0, "choice": "merge", "applyToAll": false, "choices": {},
"__command": command, "__params": params,
});
if app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND) {
crate::placing::cancel_swatch_paste(app);
}
app.ui.dialog = Some(Dialog::new(KIND, fields));
Some(Ok(json!({ "dialog": KIND })))
}
@@ -125,5 +128,5 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
let mut params = d.fields.get("__params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
params["swatchConflict"] = Value::Object(choices);
app.ui.dialog = None;
app.run(&d.str("__command"), params)
crate::placing::confirm_swatch_paste(app, &d.str("__command"), params, d.fields.get("__deferredPaste").and_then(Value::as_u64))
}
+4
View File
@@ -4389,6 +4389,9 @@ axis Both И двете
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Неуспешно поставяне на {name}: {e}
@msg Couldn't paste from the clipboard: {e} Неуспешно поставяне от клипборда: {e}
@msg Couldn't paste: the document it was pasted in was closed Неуспешно поставяне: документът, в който се поставяше, е затворен
@msg Saved {path} with {_1} note(s): {first} {path} е запазен с {_1} бележка/и: {first}
@msg Exported {what} with {_1} note(s): {first} {what} е експортиран с {_1} бележка/и: {first}
@msg Exported {what} Експортирано: {what}
@msg Saved {n} files beside {path} Записани са {n} файла до {path}
@msg Saving Запазване
@@ -4396,6 +4399,7 @@ axis Both И двете
@msg Exporting {_1} Експортиране на {_1}
@msg Packaging {_1} Пакетиране на {_1}
@msg Placing {_1} Вмъкване на {_1}
@msg Pasting Поставяне
@msg Saving recovery data for {_1} Записване на данни за възстановяване на {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Изхвърлен е {n} възстановен документ|Изхвърлени са {n} възстановени документа
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Възстановен е {n} документ: запазете го, за да го съхраните|Възстановени са {n} документа: запазете ги, за да ги съхраните
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4791,6 +4791,9 @@ imageTrace Outline Obris
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Nije moguće postaviti {name}: {e}
@msg Couldn't paste from the clipboard: {e} Nije moguće zalijepiti iz međuspremnika: {e}
@msg Couldn't paste: the document it was pasted in was closed Nije moguće zalijepiti: dokument u koji se lijepilo je zatvoren
@msg Saved {path} with {_1} note(s): {first} Sačuvano {path} s bilješkama ({_1}): {first}
@msg Exported {what} with {_1} note(s): {first} Izvezeno {what} s bilješkama ({_1}): {first}
@msg Exported {what} Izvezeno: {what}
@msg Saved {n} files beside {path} Sačuvano datoteka pored {path}: {n}
@msg Saving Čuvanje
@@ -4798,6 +4801,7 @@ imageTrace Outline Obris
@msg Exporting {_1} Izvoz {_1}
@msg Packaging {_1} Pakovanje {_1}
@msg Placing {_1} Postavljanje {_1}
@msg Pasting Lijepljenje
@msg Saving recovery data for {_1} Čuvanje podataka za oporavak za {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Odbačen {n} oporavljeni dokument|Odbačena {n} oporavljena dokumenta|Odbačeno {n} oporavljenih dokumenata
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Vraćen {n} oporavljeni dokument; sačuvajte ga da ga zadržite|Vraćena {n} oporavljena dokumenta; sačuvajte ih da ih zadržite|Vraćeno {n} oporavljenih dokumenata; sačuvajte ih da ih zadržite
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4977,6 +4977,7 @@ digits Malayalam Malaiàlam
@msg Couldn't copy to the system clipboard: {e} No s’ha pogut copiar al porta-retalls del sistema: {e}
@msg Couldn't open {name}: {e} No s’ha pogut obrir {name}: {e}
@msg Couldn't paste from the clipboard: {e} No s’ha pogut enganxar del porta-retalls: {e}
@msg Couldn't paste: the document it was pasted in was closed No s’ha pogut enganxar: el document on s’enganxava s’ha tancat
@msg Couldn't place {name}: the document it was dropped on was closed No s’ha pogut col·locar {name}: el document on es va deixar anar estava tancat
@msg Couldn't place {name}: the document it was placed in was closed No s’ha pogut col·locar {name}: el document on es va col·locar estava tancat
@msg Couldn't place {name}: {e} No s’ha pogut col·locar {name}: {e}
@@ -5335,6 +5336,7 @@ digits Malayalam Malaiàlam
@msg Persistent libraries not found: {files} No s’han trobat biblioteques persistents: {files}
@msg pick a colour, gradient or pattern to save as a swatch tria un color, un degradat o un motiu per desar-lo com a mostra
@msg Placing {_1} Col·locant {_1}
@msg Pasting S’està enganxant
@msg plane must be left, right or ground plane ha de ser left, right o ground
@msg plug-in ABI: {0} ABI del complement: {0}
@msg plug-in exceeded its {0} el complement ha superat el seu {0}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4836,6 +4836,9 @@ imageTrace Outline Obrys
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} {name} nelze umístit: {e}
@msg Couldn't paste from the clipboard: {e} Nelze vložit ze schránky: {e}
@msg Couldn't paste: the document it was pasted in was closed Nelze vložit: dokument, do kterého se vkládalo, byl zavřen
@msg Saved {path} with {_1} note(s): {first} Uloženo: {path}, poznámky ({_1}): {first}
@msg Exported {what} with {_1} note(s): {first} Exportováno: {what}, poznámky ({_1}): {first}
@msg Exported {what} Exportováno: {what}
@msg Saved {n} files beside {path} Uloženo souborů: {n} vedle {path}
@msg Saving Ukládání
@@ -4843,6 +4846,7 @@ imageTrace Outline Obrys
@msg Exporting {_1} Exportování {_1}
@msg Packaging {_1} Balení {_1}
@msg Placing {_1} Umísťování {_1}
@msg Pasting Vkládání
@msg Saving recovery data for {_1} Ukládání dat pro obnovení pro {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Zahozen {n} obnovený dokument|Zahozeny {n} obnovené dokumenty|Zahozeno {n} obnovených dokumentů
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Obnoven {n} dokument; pokud ho chcete zachovat, uložte ho|Obnoveny {n} dokumenty; pokud je chcete zachovat, uložte je|Obnoveno {n} dokumentů; pokud je chcete zachovat, uložte je
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4482,6 +4482,9 @@ imageTrace Outline Kontur
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Kunne ikke placere {name}: {e}
@msg Couldn't paste from the clipboard: {e} Kunne ikke indsætte fra udklipsholderen: {e}
@msg Couldn't paste: the document it was pasted in was closed Kunne ikke indsætte: dokumentet, der blev indsat i, var lukket
@msg Saved {path} with {_1} note(s): {first} Gemte {path} med {_1} note(r): {first}
@msg Exported {what} with {_1} note(s): {first} Eksporterede {what} med {_1} note(r): {first}
@msg Exported {what} Eksporterede {what}
@msg Saved {n} files beside {path} Gemte {n} filer ved siden af {path}
@msg Saving Gemmer
@@ -4489,6 +4492,7 @@ imageTrace Outline Kontur
@msg Exporting {_1} Eksporterer {_1}
@msg Packaging {_1} Pakker {_1}
@msg Placing {_1} Placerer {_1}
@msg Pasting Indsætter
@msg Saving recovery data for {_1} Gemmer gendannelsesdata for {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Kasserede {n} gendannet dokument|Kasserede {n} gendannede dokumenter
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Gendannede {n} dokument: gem det for at beholde det|Gendannede {n} dokumenter: gem dem for at beholde dem
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4445,6 +4445,9 @@ theme Appearance Erscheinungsbild
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} {name} konnte nicht platziert werden: {e}
@msg Couldn't paste from the clipboard: {e} Aus der Zwischenablage konnte nicht eingefügt werden: {e}
@msg Couldn't paste: the document it was pasted in was closed Konnte nicht eingefügt werden: Das Dokument, in das eingefügt wurde, wurde geschlossen
@msg Saved {path} with {_1} note(s): {first} {path} mit {_1} Hinweis(en) gespeichert: {first}
@msg Exported {what} with {_1} note(s): {first} {what} mit {_1} Hinweis(en) exportiert: {first}
@msg Exported {what} {what} exportiert
@msg Saved {n} files beside {path} {n} Dateien neben {path} gespeichert
@msg Saving Wird gespeichert
@@ -4452,6 +4455,7 @@ theme Appearance Erscheinungsbild
@msg Exporting {_1} {_1} wird exportiert
@msg Packaging {_1} {_1} wird verpackt
@msg Placing {_1} {_1} wird platziert
@msg Pasting Wird eingefügt
@msg Saving recovery data for {_1} Wiederherstellungsdaten für {_1} werden gespeichert
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents {n} wiederhergestelltes Dokument verworfen|{n} wiederhergestellte Dokumente verworfen
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them {n} wiederhergestelltes Dokument geöffnet: Speichern Sie es, um es zu behalten|{n} wiederhergestellte Dokumente geöffnet: Speichern Sie sie, um sie zu behalten
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4478,6 +4478,9 @@ digits Malayalam Μαλαγιάλαμ
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Δεν ήταν δυνατή η τοποθέτηση του {name}: {e}
@msg Couldn't paste from the clipboard: {e} Δεν ήταν δυνατή η επικόλληση από το πρόχειρο: {e}
@msg Couldn't paste: the document it was pasted in was closed Δεν ήταν δυνατή η επικόλληση: το έγγραφο στο οποίο γινόταν η επικόλληση έκλεισε
@msg Saved {path} with {_1} note(s): {first} Αποθηκεύτηκε το {path} με {_1} σημειώσεις: {first}
@msg Exported {what} with {_1} note(s): {first} Εξήχθη το {what} με {_1} σημειώσεις: {first}
@msg Exported {what} Εξήχθη: {what}
@msg Saved {n} files beside {path} Αποθηκεύτηκαν {n} αρχεία δίπλα στο {path}
@msg Saving Αποθήκευση σε εξέλιξη
@@ -4485,6 +4488,7 @@ digits Malayalam Μαλαγιάλαμ
@msg Exporting {_1} Εξαγωγή {_1}
@msg Packaging {_1} Συσκευασία {_1}
@msg Placing {_1} Τοποθέτηση {_1}
@msg Pasting Επικόλληση
@msg Saving recovery data for {_1} Αποθήκευση δεδομένων ανάκτησης για {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Απορρίφθηκε {n} ανακτημένο έγγραφο|Απορρίφθηκαν {n} ανακτημένα έγγραφα
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Επαναφέρθηκε {n} ανακτημένο έγγραφο: αποθηκεύστε το για να διατηρηθεί|Επαναφέρθηκαν {n} ανακτημένα έγγραφα: αποθηκεύστε τα για να διατηρηθούν
Can't render this file because it contains an unexpected character in line 163 and column 5.
+4
View File
@@ -4530,6 +4530,9 @@ axis Both Ambos
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} No se pudo colocar {name}: {e}
@msg Couldn't paste from the clipboard: {e} No se pudo pegar desde el portapapeles: {e}
@msg Couldn't paste: the document it was pasted in was closed No se pudo pegar: se cerró el documento en el que se estaba pegando
@msg Saved {path} with {_1} note(s): {first} Se guardó {path} con {_1} nota(s): {first}
@msg Exported {what} with {_1} note(s): {first} Se exportó {what} con {_1} nota(s): {first}
@msg Exported {what} Se exportó {what}
@msg Saved {n} files beside {path} Se guardaron {n} archivos junto a {path}
@msg Saving Guardando
@@ -4537,6 +4540,7 @@ axis Both Ambos
@msg Exporting {_1} Exportando {_1}
@msg Packaging {_1} Empaquetando {_1}
@msg Placing {_1} Colocando {_1}
@msg Pasting Pegando
@msg Saving recovery data for {_1} Guardando datos de recuperación de {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Se descartó {n} documento recuperado|Se descartaron {n} documentos recuperados
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Se restauró {n} documento recuperado: guárdalo para conservarlo|Se restauraron {n} documentos recuperados: guárdalos para conservarlos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4738,6 +4738,9 @@ imageTrace Outline Ääriviiva
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Kohdetta {name} ei voitu sijoittaa: {e}
@msg Couldn't paste from the clipboard: {e} Leikepöydältä ei voitu liittää: {e}
@msg Couldn't paste: the document it was pasted in was closed Liittäminen ei onnistunut: asiakirja, johon liitettiin, on suljettu
@msg Saved {path} with {_1} note(s): {first} Tallennettiin {path}, {_1} huomautus(ta): {first}
@msg Exported {what} with {_1} note(s): {first} Vietiin {what}, {_1} huomautus(ta): {first}
@msg Exported {what} Vietiin {what}
@msg Saved {n} files beside {path} Tallennettiin {n} tiedostoa kohteen {path} viereen
@msg Saving Tallennetaan
@@ -4745,6 +4748,7 @@ imageTrace Outline Ääriviiva
@msg Exporting {_1} Viedään {_1}
@msg Packaging {_1} Paketoidaan {_1}
@msg Placing {_1} Sijoitetaan {_1}
@msg Pasting Liitetään
@msg Saving recovery data for {_1} Tallennetaan palautustietoja: {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Hylättiin {n} palautettu asiakirja|Hylättiin {n} palautettua asiakirjaa
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Palautettiin {n} asiakirja: tallenna se säilyttääksesi sen|Palautettiin {n} asiakirjaa: tallenna ne säilyttääksesi ne
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4519,6 +4519,7 @@ axis Both Les deux
@msg {path}: {e} {path} : {e}
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Impossible d’importer {name} : {e}
@msg Couldn't paste: the document it was pasted in was closed Impossible de coller, car le document de destination a été fermé
@msg Couldn't paste from the clipboard: {e} Impossible de coller depuis le presse-papiers : {e}
@msg Exported {what} {what} exporté
@msg Saved {n} files beside {path} {n} fichiers enregistrés à côté de {path}
@@ -4527,6 +4528,7 @@ axis Both Les deux
@msg Exporting {_1} Exportation de {_1}
@msg Packaging {_1} Assemblage de {_1}
@msg Placing {_1} Importation de {_1}
@msg Pasting Collage
@msg Saving recovery data for {_1} Enregistrement des données de récupération de {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents {n} document récupéré supprimé|{n} documents récupérés supprimés
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them {n} document récupéré restauré : enregistrez-le pour le conserver|{n} documents récupérés restaurés : enregistrez-les pour les conserver
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4707,6 +4707,7 @@ imageTrace Outline Körvonal
@msg Couldn't copy to the system clipboard: {e} Nem sikerült a rendszer vágólapjára másolni: {e}
@msg Couldn't open {name}: {e} Nem sikerült megnyitni: {name}: {e}
@msg Couldn't paste from the clipboard: {e} Nem sikerült beilleszteni a vágólapról: {e}
@msg Couldn't paste: the document it was pasted in was closed Nem sikerült beilleszteni: a dokumentumot, amelybe a beillesztés történt, bezárták
@msg Couldn't place {name}: the document it was dropped on was closed Nem sikerült elhelyezni: {name}; a céldokumentumot bezárták
@msg Couldn't place {name}: the document it was placed in was closed Nem sikerült elhelyezni: {name}; a dokumentumot, amelybe került volna, bezárták
@msg Couldn't place {name}: {e} Nem sikerült elhelyezni: {name}: {e}
@@ -4743,6 +4744,7 @@ imageTrace Outline Körvonal
@msg Exporting {_1} Exportálás: {_1}
@msg Packaging {_1} Csomagolás: {_1}
@msg Placing {_1} Elhelyezés: {_1}
@msg Pasting Beillesztés
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) a fájl verziója újabb, mint amit ez a VectorCraft támogat: {0} (támogatott: {VERSION})
@msg Flatten Transparency: select objects to flatten Átlátszóság lapítása: jelöld ki a lapítandó objektumokat
@msg Flatten Transparency: {e} Átlátszóság lapítása: {e}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4482,6 +4482,9 @@ imageTrace Outline Kontur
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Tidak dapat menempatkan {name}: {e}
@msg Couldn't paste from the clipboard: {e} Tidak dapat menempel dari papan klip: {e}
@msg Couldn't paste: the document it was pasted in was closed Tidak dapat menempel: dokumen tempatnya ditempel sudah ditutup
@msg Saved {path} with {_1} note(s): {first} {path} disimpan dengan {_1} catatan: {first}
@msg Exported {what} with {_1} note(s): {first} {what} diekspor dengan {_1} catatan: {first}
@msg Exported {what} {what} diekspor
@msg Saved {n} files beside {path} {n} berkas disimpan di sebelah {path}
@msg Saving Menyimpan
@@ -4489,6 +4492,7 @@ imageTrace Outline Kontur
@msg Exporting {_1} Mengekspor {_1}
@msg Packaging {_1} Mengemas {_1}
@msg Placing {_1} Menempatkan {_1}
@msg Pasting Menempel
@msg Saving recovery data for {_1} Menyimpan data pemulihan untuk {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents {n} dokumen hasil pemulihan dibuang
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them {n} dokumen hasil pemulihan dipulihkan: simpan agar tidak hilang
Can't render this file because it contains an unexpected character in line 2204 and column 5.
+4
View File
@@ -4571,6 +4571,9 @@ axis Both Entrambi
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Impossibile inserire {name}: {e}
@msg Couldn't paste from the clipboard: {e} Impossibile incollare dagli appunti: {e}
@msg Couldn't paste: the document it was pasted in was closed Impossibile incollare: il documento in cui si incollava è stato chiuso
@msg Saved {path} with {_1} note(s): {first} Salvato {path} con {_1} nota/e: {first}
@msg Exported {what} with {_1} note(s): {first} Esportato {what} con {_1} nota/e: {first}
@msg Exported {what} Esportato {what}
@msg Saved {n} files beside {path} Salvati {n} file accanto a {path}
@msg Saving Salvataggio
@@ -4578,6 +4581,7 @@ axis Both Entrambi
@msg Exporting {_1} Esportazione di {_1}
@msg Packaging {_1} Creazione del pacchetto {_1}
@msg Placing {_1} Inserimento di {_1}
@msg Pasting Incollamento
@msg Saving recovery data for {_1} Salvataggio dei dati di recupero per {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Scartato {n} documento recuperato|Scartati {n} documenti recuperati
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Ripristinato {n} documento recuperato. Salvalo per conservarlo|Ripristinati {n} documenti recuperati. Salvali per conservarli
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4465,6 +4465,7 @@ swatch Bright Violet ビビッドバイオレット
@msg Exporting {_1} {_1} を書き出し中
@msg Packaging {_1} {_1} をパッケージ中
@msg Placing {_1} {_1} を配置中
@msg Pasting 貼り付け中
@msg Exported {what} {what} を書き出しました
@msg "{0}" is not a colour: write it as #rrggbb 「{0}」はカラーではありません: #rrggbb の形式で指定してください
@msg could not decode image: {0} 画像をデコードできませんでした: {0}
@@ -4713,6 +4714,7 @@ swatch Bright Violet ビビッドバイオレット
@msg {_1} is reserved by the system {_1} はシステムで予約されています
@msg Couldn't copy to the system clipboard: {e} システムのクリップボードにコピーできませんでした: {e}
@msg Couldn't paste from the clipboard: {e} クリップボードから貼り付けられませんでした: {e}
@msg Couldn't paste: the document it was pasted in was closed 貼り付けられませんでした: 貼り付け先のドキュメントは閉じられています
@msg nothing selected 何も選択されていません
@msg nothing selected is in a group 選択したオブジェクトはどれもグループ内にありません
@msg the {tool} tool has no options {tool} ツールにはオプションがありません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4271,6 +4271,9 @@ font-technology Variable ცვლადი
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} {name}-ის განთავსება ვერ მოხერხდა: {e}
@msg Couldn't paste from the clipboard: {e} გაცვლის ბუფერიდან ჩასმა ვერ მოხერხდა: {e}
@msg Couldn't paste: the document it was pasted in was closed ჩასმა ვერ მოხერხდა: დოკუმენტი, რომელშიც ის ჩაისმებოდა, დაიხურა
@msg Saved {path} with {_1} note(s): {first} შენახულია {path}, შენიშვნები: {_1}: {first}
@msg Exported {what} with {_1} note(s): {first} ექსპორტირებულია {what}, შენიშვნები: {_1}: {first}
@msg Exported {what} ექსპორტირებულია {what}
@msg Saved {n} files beside {path} {path}-ის გვერდით შენახულია ფაილები: {n}
@msg Saving შენახვა
@@ -4278,6 +4281,7 @@ font-technology Variable ცვლადი
@msg Exporting {_1} ექსპორტირდება: {_1}
@msg Packaging {_1} იფუთება: {_1}
@msg Placing {_1} თავსდება: {_1}
@msg Pasting ჩასმა
@msg Saving recovery data for {_1} {_1}-ის აღდგენის მონაცემების შენახვა
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents უარყოფილია აღდგენილი დოკუმენტები: {n}
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them აღდგენილია დოკუმენტები: {n}: შესანარჩუნებლად შეინახეთ ისინი
Can't render this file because it is too large.
+2
View File
@@ -4392,6 +4392,7 @@ theme Appearance 외관
@msg Exporting {_1} {_1} 내보내는 중
@msg Packaging {_1} {_1} 패키지 만드는 중
@msg Placing {_1} {_1} 가져오는 중
@msg Pasting 붙여넣는 중
@msg Exported {what} {what}을(를) 내보냈습니다
@msg "{0}" is not a colour: write it as #rrggbb "{0}"은(는) 색상이 아닙니다: #rrggbb 형식으로 입력하세요
@msg could not decode image: {0} 이미지를 디코딩할 수 없습니다: {0}
@@ -4640,6 +4641,7 @@ theme Appearance 외관
@msg {_1} is reserved by the system {_1}은(는) 시스템에서 예약되어 있습니다
@msg Couldn't copy to the system clipboard: {e} 시스템 클립보드로 복사할 수 없습니다: {e}
@msg Couldn't paste from the clipboard: {e} 클립보드에서 붙여넣을 수 없습니다: {e}
@msg Couldn't paste: the document it was pasted in was closed 붙여넣을 수 없습니다: 붙여넣던 문서가 닫혔습니다
@msg nothing selected 선택된 항목이 없습니다
@msg nothing selected is in a group 선택한 항목 중 그룹에 속한 항목이 없습니다
@msg the {tool} tool has no options {tool} 도구에는 옵션이 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4494,6 +4494,9 @@ imageTrace Outline Contour
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Kon {name} niet plaatsen: {e}
@msg Couldn't paste from the clipboard: {e} Kon niet van het klembord plakken: {e}
@msg Couldn't paste: the document it was pasted in was closed Kon niet plakken: het document waarin werd geplakt, is gesloten
@msg Saved {path} with {_1} note(s): {first} {path} opgeslagen met {_1} opmerking(en): {first}
@msg Exported {what} with {_1} note(s): {first} {what} geëxporteerd met {_1} opmerking(en): {first}
@msg Exported {what} {what} geëxporteerd
@msg Saved {n} files beside {path} {n} bestanden opgeslagen naast {path}
@msg Saving Bezig met opslaan
@@ -4501,6 +4504,7 @@ imageTrace Outline Contour
@msg Exporting {_1} Bezig met exporteren van {_1}
@msg Packaging {_1} Bezig met verpakken van {_1}
@msg Placing {_1} Bezig met plaatsen van {_1}
@msg Pasting Bezig met plakken
@msg Saving recovery data for {_1} Bezig met opslaan van herstelgegevens voor {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents {n} hersteld document verworpen|{n} herstelde documenten verworpen
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them {n} hersteld document teruggezet: sla het op om het te behouden|{n} herstelde documenten teruggezet: sla ze op om ze te behouden
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4662,6 +4662,9 @@ imageTrace Outline Kontur
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Nie udało się umieścić {name}: {e}
@msg Couldn't paste from the clipboard: {e} Nie udało się wkleić ze schowka: {e}
@msg Couldn't paste: the document it was pasted in was closed Nie udało się wkleić: dokument, do którego wklejano, został zamknięty
@msg Saved {path} with {_1} note(s): {first} Zapisano {path}, uwagi ({_1}): {first}
@msg Exported {what} with {_1} note(s): {first} Wyeksportowano {what}, uwagi ({_1}): {first}
@msg Exported {what} Wyeksportowano {what}
@msg Saved {n} files beside {path} Zapisane pliki obok {path}: {n}
@msg Saving Zapisywanie
@@ -4669,6 +4672,7 @@ imageTrace Outline Kontur
@msg Exporting {_1} Eksportowanie {_1}
@msg Packaging {_1} Pakowanie {_1}
@msg Placing {_1} Umieszczanie {_1}
@msg Pasting Wklejanie
@msg Saving recovery data for {_1} Zapisywanie danych odzyskiwania dla {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Odrzucono {n} odzyskany dokument|Odrzucono {n} odzyskane dokumenty|Odrzucono {n} odzyskanych dokumentów
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Przywrócono {n} odzyskany dokument: zapisz go, aby go zachować|Przywrócono {n} odzyskane dokumenty: zapisz je, aby je zachować|Przywrócono {n} odzyskanych dokumentów: zapisz je, aby je zachować
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4112,6 +4112,9 @@ axis Both Ambos
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Não foi possível inserir {name}: {e}
@msg Couldn't paste from the clipboard: {e} Não foi possível colar da área de transferência: {e}
@msg Couldn't paste: the document it was pasted in was closed Não foi possível colar: o documento em que estava sendo colado foi fechado
@msg Saved {path} with {_1} note(s): {first} {path} salvo com {_1} observação(ões): {first}
@msg Exported {what} with {_1} note(s): {first} {what} exportado com {_1} observação(ões): {first}
@msg Exported {what} {what} exportado
@msg Saved {n} files beside {path} {n} arquivos salvos ao lado de {path}
@msg Saving Salvando
@@ -4119,6 +4122,7 @@ axis Both Ambos
@msg Exporting {_1} Exportando {_1}
@msg Packaging {_1} Empacotando {_1}
@msg Placing {_1} Inserindo {_1}
@msg Pasting Colando
@msg Saving recovery data for {_1} Salvando dados de recuperação de {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents {n} documento recuperado descartado|{n} documentos recuperados descartados
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them {n} documento recuperado restaurado: salve-o para mantê-lo|{n} documentos recuperados restaurados: salve-os para mantê-los
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -4566,6 +4566,9 @@ axis Both Оба
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Не удалось поместить {name}: {e}
@msg Couldn't paste from the clipboard: {e} Не удалось вставить из буфера обмена: {e}
@msg Couldn't paste: the document it was pasted in was closed Не удалось вставить: документ, в который выполнялась вставка, был закрыт
@msg Saved {path} with {_1} note(s): {first} Сохранено: {path} с примечаниями ({_1}) : {first}
@msg Exported {what} with {_1} note(s): {first} Экспортировано: {what} с примечаниями ({_1}) : {first}
@msg Exported {what} Экспортировано: {what}
@msg Saved {n} files beside {path} Сохранено файлов: {n} рядом с {path}
@msg Saving Сохранение
@@ -4573,6 +4576,7 @@ axis Both Оба
@msg Exporting {_1} Экспорт {_1}
@msg Packaging {_1} Упаковка {_1}
@msg Placing {_1} Размещение {_1}
@msg Pasting Вставка
@msg Saving recovery data for {_1} Сохранение данных восстановления для {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Отменён {n} восстановленный документ|Отменено {n} восстановленных документа|Отменено {n} восстановленных документов
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Восстановлен {n} документ: сохраните его, чтобы оставить|Восстановлено {n} документа: сохраните их, чтобы оставить|Восстановлено {n} документов: сохраните их, чтобы оставить
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4429,6 +4429,9 @@ axis Both Båda
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Kunde inte placera {name}: {e}
@msg Couldn't paste from the clipboard: {e} Kunde inte klistra in från urklippet: {e}
@msg Couldn't paste: the document it was pasted in was closed Kunde inte klistra in: dokumentet det klistrades in i har stängts
@msg Saved {path} with {_1} note(s): {first} Sparade {path} med {_1} anteckning(ar): {first}
@msg Exported {what} with {_1} note(s): {first} Exporterade {what} med {_1} anteckning(ar): {first}
@msg Exported {what} Exporterade {what}
@msg Saved {n} files beside {path} Sparade {n} filer bredvid {path}
@msg Saving Sparar
@@ -4436,6 +4439,7 @@ axis Both Båda
@msg Exporting {_1} Exporterar {_1}
@msg Packaging {_1} Paketerar {_1}
@msg Placing {_1} Placerar {_1}
@msg Pasting Klistrar in
@msg Saving recovery data for {_1} Sparar återställningsdata för {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Kastade {n} återställt dokument|Kastade {n} återställda dokument
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Återställde {n} återskapat dokument: spara det för att behålla det|Återställde {n} återskapade dokument: spara dem för att behålla dem
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -4267,6 +4267,9 @@ axis Both Her İkisi
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} {name} yerleştirilemedi: {e}
@msg Couldn't paste from the clipboard: {e} Panodan yapıştırılamadı: {e}
@msg Couldn't paste: the document it was pasted in was closed Yapıştırılamadı: yapıştırıldığı belge kapatıldı
@msg Saved {path} with {_1} note(s): {first} {path}, {_1} notlarıyla kaydedildi: {first}
@msg Exported {what} with {_1} note(s): {first} {_1} notlarıyla birlikte dışa aktarılan {what}: {first}
@msg Exported {what} Dışa aktarılan {what}
@msg Saved {n} files beside {path} {path}'nin yanında kaydedilen {n} dosyaları
@msg Saving Kaydediliyor
@@ -4274,6 +4277,7 @@ axis Both Her İkisi
@msg Exporting {_1} {_1}'yi dışa aktarma
@msg Packaging {_1} {_1} paketleniyor
@msg Placing {_1} {_1} yerleştiriliyor
@msg Pasting Yapıştırılıyor
@msg Saving recovery data for {_1} {_1} için kurtarma verileri kaydediliyor
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Kurtarılan {n} belge atıldı
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Kurtarılan {n} belge geri yüklendi: saklamak için kaydedin
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -4555,6 +4555,9 @@ axis Both Обидві
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} Не вдалося помістити {name}: {e}
@msg Couldn't paste from the clipboard: {e} Не вдалося вставити з буфера обміну: {e}
@msg Couldn't paste: the document it was pasted in was closed Не вдалося вставити: документ, у який виконувалося вставлення, було закрито
@msg Saved {path} with {_1} note(s): {first} Збережено: {path} з примітками ({_1}) : {first}
@msg Exported {what} with {_1} note(s): {first} Експортовано: {what} з примітками ({_1}) : {first}
@msg Exported {what} Експортовано: {what}
@msg Saved {n} files beside {path} Збережено файлів: {n} поруч із {path}
@msg Saving Збереження
@@ -4562,6 +4565,7 @@ axis Both Обидві
@msg Exporting {_1} Експорт {_1}
@msg Packaging {_1} Пакування {_1}
@msg Placing {_1} Розміщення {_1}
@msg Pasting Вставлення
@msg Saving recovery data for {_1} Збереження даних відновлення для {_1}
@msgplural Discarded {n} recovered document|Discarded {n} recovered documents Скасовано {n} відновлений документ|Скасовано {n} відновлені документи|Скасовано {n} відновлених документів
@msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them Відновлено {n} документ: збережіть його, щоб залишити|Відновлено {n} документи: збережіть їх, щоб залишити|Відновлено {n} документів: збережіть їх, щоб залишити
Can't render this file because it contains an unexpected character in line 177 and column 5.
+2
View File
@@ -4359,6 +4359,7 @@ theme Appearance Giao diện
@msg Couldn't copy to the system clipboard: {e} Không thể sao chép vào khay nhớ tạm hệ thống: {e}
@msg Couldn't open {name}: {e} Không thể mở {name}: {e}
@msg Couldn't paste from the clipboard: {e} Không thể dán từ khay nhớ tạm: {e}
@msg Couldn't paste: the document it was pasted in was closed Không thể dán: tài liệu mà nó được dán vào đã đóng
@msg Couldn't place {name}: the document it was dropped on was closed Không thể đặt {name}: tài liệu mà nó được thả vào đã đóng
@msg Couldn't place {name}: the document it was placed in was closed Không thể đặt {name}: tài liệu mà nó được đặt vào đã đóng
@msg Couldn't place {name}: {e} Không thể đặt {name}: {e}
@@ -4394,6 +4395,7 @@ theme Appearance Giao diện
@msg Exporting {_1} Đang xuất {_1}
@msg Packaging {_1} Đang đóng gói {_1}
@msg Placing {_1} Đang đặt {_1}
@msg Pasting Đang dán
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) phiên bản tệp {0} mới hơn VectorCraft hỗ trợ ({VERSION})
@msg Flatten Transparency: select objects to flatten Dẹp phẳng độ trong suốt: hãy chọn đối tượng để dẹp phẳng
@msg Flatten Transparency: {e} Dẹp phẳng độ trong suốt: {e}
Can't render this file because it contains an unexpected character in line 199 and column 5.
+2
View File
@@ -4188,6 +4188,7 @@ imageTrace Outline 轮廓
@msg Exporting {_1} 正在导出 {_1}
@msg Packaging {_1} 正在打包 {_1}
@msg Placing {_1} 正在置入 {_1}
@msg Pasting 正在粘贴
@msg Exported {what} 已导出 {what}
@msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是颜色:请写为 #rrggbb
@msg could not decode image: {0} 无法解码图像:{0}
@@ -4429,6 +4430,7 @@ imageTrace Outline 轮廓
@msg {_1} is reserved by the system {_1} 已被系统保留
@msg Couldn't copy to the system clipboard: {e} 无法复制到系统剪贴板:{e}
@msg Couldn't paste from the clipboard: {e} 无法从剪贴板粘贴:{e}
@msg Couldn't paste: the document it was pasted in was closed 无法粘贴:要粘贴到的文档已关闭
@msg nothing selected 未选择任何对象
@msg nothing selected is in a group 所选对象均不在编组中
@msg the {tool} tool has no options {tool} 工具没有选项
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2098,6 +2098,7 @@ file-filter Templates 範本
@msg Exporting {_1} 正在匯出 {_1}
@msg Packaging {_1} 正在封裝 {_1}
@msg Placing {_1} 正在置入 {_1}
@msg Pasting 正在貼上
@msg Exported {what} 已匯出 {what}
@msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是顏色:請寫成 #rrggbb
@msg could not decode image: {0} 無法解碼影像:{0}
@@ -2339,6 +2340,7 @@ file-filter Templates 範本
@msg {_1} is reserved by the system {_1} 已被系統保留
@msg Couldn't copy to the system clipboard: {e} 無法複製到系統剪貼簿:{e}
@msg Couldn't paste from the clipboard: {e} 無法從剪貼簿貼上:{e}
@msg Couldn't paste: the document it was pasted in was closed 無法貼上:要貼上的文件已關閉
@msg nothing selected 未選取任何物件
@msg nothing selected is in a group 選取的物件都不在群組中
@msg the {tool} tool has no options {tool} 工具沒有選項
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -103,7 +103,7 @@ use vectorcraft_engine::{Session, ViewInfo};
pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket};
pub use state::{UiState, View};
pub use sysclip::{ClipboardProbeFactory, SystemClipboard};
pub use sysclip::{ClipboardProbeFactory, ClipboardRead, MakeFlavour, SystemClipboard};
mod app;
mod caches;
+5 -1
View File
@@ -164,7 +164,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
}
return;
}
if let Err(e) = app.run(id, p) {
// A Paste the user gives decodes a bitmap off the UI thread ([`crate::placing::adopt_for_paste`]).
app.placing.for_user = id.starts_with("edit.paste");
let r = app.run(id, p);
app.placing.for_user = false;
if let Err(e) = r {
app.status(e);
} else if matches!(id, "object.pattern.make" | "object.pattern.edit") {
app.ui.open_panel = Some("patternOptions".into());
+401 -8
View File
@@ -4,23 +4,121 @@
//! the document it was placed in once it is ready, as it would be at once: the same object, undo
//! step, selection and errors. The files the Place dialog loads the place cursor with are read, and
//! their thumbnails made, the same way, and so is the file each click or drag of the place cursor
//! places. Files are read and placed in the order they were started. `file.place` and
//! `file.place.queue` from MCP and scripts run at once, and so does every place on wasm (no
//! threads), with `VECTORCRAFT_SYNC_OPEN` set, and in unit tests that do not set
//! `off_the_ui_thread` ([`available`]).
//! places. A Paste the user gives decodes another app's bitmap on the same thread, and runs once it
//! is decoded ([`adopt_for_paste`]). Files are read and placed in the order they were started.
//! `file.place`, `file.place.queue` and `edit.paste*` from MCP and scripts run at once, and so does
//! every place and paste on wasm (no threads), with `VECTORCRAFT_SYNC_OPEN` set, and in unit tests
//! that do not set `off_the_ui_thread` ([`available`]).
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_engine::cmd::clipboard::{Clipboard, Flavour, ImageRead, read_image};
use vectorcraft_engine::cmd::place::{PlaceRead, QueueRead};
use vectorcraft_engine::file_access::ScopeHandle;
use crate::VectorcraftApp;
/// What was read: one file to place, or the files to load the place cursor with.
/// What was read: one file to place, the files to load the place cursor with, or a bitmap to paste.
enum Read {
Place(Box<PlaceRead>),
Queue(QueueRead),
Paste(Box<Paste>),
}
/// A paste whose bitmap was decoded off the UI thread ([`adopt_for_paste`]).
struct Paste {
/// The paste command and its params.
id: String,
params: Value,
/// What it pastes (`None`: a bitmap the host converts couldn't be converted, or the paste
/// waited behind another one without a bitmap of its own, and it takes the internal clipboard
/// as it was when the paste started, as at once).
pasted: Option<Pasted>,
/// The view's center when the paste started, which the pasted objects are centered on.
center: Option<[f64; 2]>,
/// The objects selected when the paste started, which Paste in Front and Paste in Back paste
/// next to (`None`: the paste follows an earlier one of its chain, and takes the selection
/// that one left, [`Chain::left`]).
selection: Option<Vec<vectorcraft_doc::NodeId>>,
/// The number of its chain in [`Placing::chains`].
chain: u64,
/// Where new art went when the paste started, which is where the paste goes.
insertion: Insertion,
/// The latest Copy or Cut when the paste started ([`crate::sysclip::Publishing::latest`]).
copied: u64,
/// The internal clipboard when the paste started, which it pastes when [`Self::pasted`] is
/// `None`.
clipboard: Clipboard,
}
/// Where new art goes in the active document: the current layer, the isolated group or object, and
/// the drawing mode with the object Draw Inside draws into.
#[derive(Clone, PartialEq)]
struct Insertion {
layer: Option<vectorcraft_doc::NodeId>,
isolation: Option<vectorcraft_doc::NodeId>,
draw_mode: vectorcraft_engine::DrawMode,
draw_inside: Option<vectorcraft_doc::NodeId>,
}
impl Insertion {
/// Where new art goes in `app`'s active document now.
fn of(app: &VectorcraftApp) -> Option<Self> {
let st = app.session.doc().ok()?;
Some(Self { layer: st.active_layer, isolation: st.isolation, draw_mode: app.session.draw_mode, draw_inside: app.session.draw_inside })
}
/// Make it where new art goes in `app`'s active document → where new art went before.
fn set(self, app: &mut VectorcraftApp) -> Option<Self> {
let st = app.session.doc_mut().ok()?;
let (layer, isolation) = (std::mem::replace(&mut st.active_layer, self.layer), std::mem::replace(&mut st.isolation, self.isolation));
let draw_mode = std::mem::replace(&mut app.session.draw_mode, self.draw_mode);
let draw_inside = std::mem::replace(&mut app.session.draw_inside, self.draw_inside);
Some(Self { layer, isolation, draw_mode, draw_inside })
}
/// Put back each part of `self` (where new art went before [`Self::set`] set `started`) that
/// differs from `started`, what the user changed meanwhile; a part the paste itself changed
/// stays as the paste left it.
fn restore_changed(self, started: &Self, app: &mut VectorcraftApp) {
if let Ok(st) = app.session.doc_mut() {
if self.layer != started.layer {
st.active_layer = self.layer;
}
if self.isolation != started.isolation {
st.isolation = self.isolation;
}
}
if self.draw_mode != started.draw_mode {
app.session.draw_mode = self.draw_mode;
}
if self.draw_inside != started.draw_inside {
app.session.draw_inside = self.draw_inside;
}
}
}
/// Pastes decoded off the UI thread that follow each other in one document: each was given while the
/// one before waited, with the selection and the insertion context that one started with or, once
/// it ran, left.
struct Chain {
/// The selection and the insertion context a paste given now must have to join the chain.
context: (Vec<vectorcraft_doc::NodeId>, Insertion),
/// The selection the chain's latest paste left, which the next one takes.
left: Option<Vec<vectorcraft_doc::NodeId>>,
/// How many of its pastes wait.
waiting: usize,
}
/// What a paste whose bitmap was decoded off the UI thread pastes.
enum Pasted {
/// The bitmap's type and the image read from it.
Image(&'static str, Result<ImageRead, String>),
/// Another flavour: the text that names the picture, when the picture couldn't be made.
Flavour(Flavour),
}
/// Files being read for Place.
@@ -34,6 +132,10 @@ pub struct Pending {
/// Whether the file was dropped on the canvas (its messages say so, as
/// [`crate::place::drop_files`]'s do).
dropped: bool,
/// Whether it is a paste's bitmap ([`adopt_for_paste`]) rather than a file.
paste: bool,
/// The number of the paste's chain ([`Placing::chains`]).
chain: Option<u64>,
done: Receiver<Result<Read, String>>,
}
@@ -44,8 +146,21 @@ type Job = Box<dyn FnOnce() + Send>;
#[derive(Default)]
pub struct Placing {
pub pending: Vec<Pending>,
/// A deferred paste awaiting its own Swatch Conflict dialog.
swatch_paste: Option<PendingSwatch>,
/// Set by unit tests whose app reads the files the user places off the UI thread.
pub(crate) off_the_ui_thread: bool,
/// Set while [`crate::menus::invoke`] runs a Paste the user gave.
pub(crate) for_user: bool,
/// Set while a paste whose bitmap was converted off the UI thread runs: it takes the internal
/// clipboard as it is.
adopted: bool,
/// The chains of pastes decoded off the UI thread that have pastes waiting, by number.
chains: HashMap<u64, Chain>,
/// For each document, the number of its latest chain, which a paste given there may join.
latest_chain: HashMap<u64, u64>,
/// The number the next chain takes.
next_chain: u64,
/// The thread that reads the files, one at a time in the order they were started: started with
/// the first read, it ends when the app does.
reader: Option<Sender<Job>>,
@@ -54,6 +169,14 @@ pub struct Placing {
pub(crate) reading: std::sync::Arc<[std::sync::atomic::AtomicUsize; 2]>,
}
struct PendingSwatch {
clipboard: Clipboard,
insertion: Insertion,
selection: Vec<vectorcraft_doc::NodeId>,
doc: u64,
chain: u64,
}
/// Does `app` read a file the user places off the UI thread? Where documents open off it
/// ([`crate::opening::available`]), and in the unit tests that set `off_the_ui_thread`.
pub fn available(app: &VectorcraftApp) -> bool {
@@ -97,6 +220,78 @@ pub fn start_queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String>
}
}
/// Before paste command `id` with `p`: what another app put on the system clipboard replaces the
/// internal clipboard ([`VectorcraftApp::adopt_system_clipboard`]) → `None`, and the paste runs at
/// once. For a Paste the user gives ([`crate::menus::invoke`]) where placed files are read off the
/// UI thread ([`available`]), the bitmap's bytes are copied off the system clipboard at once and
/// decoded on the thread that reads placed files, after the files started before it →
/// `{background: true, status}`: a bitmap the host converts to PNG is converted there
/// ([`crate::SystemClipboard::read_deferred`]), and the bitmap is read as `clipboard.importImage`
/// reads it ([`read_image`]). The paste runs once it is decoded, in the document it was given in,
/// as it would at once ([`poll`]). A Paste the user gives while such a paste waits waits behind it,
/// also when it has no bitmap to read. When no thread could start, the bitmap is read at once.
pub fn adopt_for_paste(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result<Option<Value>, String> {
if std::mem::take(&mut app.placing.adopted) {
app.clipboard_image_request = None;
return Ok(None);
}
if !(app.placing.for_user && available(app)) {
return app.adopt_system_clipboard().map(|()| None);
}
let center = app.view().map(|v| [v.center.x, v.center.y]);
let make = app.adopt_system_clipboard_or_defer(true)?;
// A paste that takes the internal clipboard as it is (what another app put there was loaded
// at once, or nothing was) runs at once, unless a paste whose bitmap is read waits: then it
// waits behind that one, with the internal clipboard as it is now.
if make.is_none() && !app.placing.pending.iter().any(|p| p.paste) {
return Ok(None);
}
let copied = app.clipboard_publishing.latest();
let (doc, objects) = app.session.doc().map(|st| (st.uid, st.selection.objects.clone())).map_err(|e| e.to_string())?;
let insertion = Insertion::of(app).ok_or("no document")?;
// A paste given while one of the document's latest chain waits, with the selection and the
// insertion context that chain expects, joins it: it goes next to what the paste before it
// leaves selected, as at once. Otherwise it starts a chain.
let context = (objects, insertion.clone());
let joins =
app.placing.latest_chain.get(&doc).copied().filter(|c| app.placing.chains.get(c).is_some_and(|ch| ch.waiting > 0 && ch.context == context));
let (chain, selection) = match joins {
Some(chain) => (chain, None),
None => (app.placing.next_chain, Some(context.0.clone())),
};
let clipboard = app.session.clipboard.clone();
let (id, params) = (id.to_string(), p.clone());
// The work stays here when no thread could start.
let slot = Arc::new(Mutex::new(make));
let work = slot.clone();
let started = spawn_labeled(app, "clipboard".into(), "Pasting".into(), false, Some(chain), move || {
let make = work.lock().ok().and_then(|mut m| m.take());
let pasted = make.and_then(|m| m()).map(|f| {
if crate::sysclip::imports_as_image(f.mime) {
Pasted::Image(f.mime, read_image(&f.data).map_err(|e| e.to_string()))
} else {
Pasted::Flavour(f)
}
});
Ok(Read::Paste(Box::new(Paste { id, params, pasted, center, selection, chain, insertion, copied, clipboard })))
})?;
if started.is_some() {
if joins.is_none() {
app.placing.next_chain += 1;
app.placing.chains.insert(chain, Chain { context, left: None, waiting: 0 });
app.placing.latest_chain.insert(doc, chain);
}
if let Some(ch) = app.placing.chains.get_mut(&chain) {
ch.waiting += 1;
}
return Ok(started);
}
match slot.lock().ok().and_then(|mut m| m.take()).and_then(|m| m()) {
Some(f) => app.adopt_flavour(&f, center).map(|()| None),
None => Ok(None),
}
}
/// Run `read` for the active document on the thread that reads the files, after the reads started
/// before it (so a batch of large pictures is decoded one at a time, as at once) →
/// `{background: true, status}`, or `None` when that thread could not start.
@@ -106,6 +301,18 @@ fn spawn(
shown: String,
dropped: bool,
read: impl FnOnce() -> Result<Read, String> + Send + 'static,
) -> Result<Option<Value>, String> {
spawn_labeled(app, name, format!("Placing {shown}"), dropped, None, read)
}
/// [`spawn`] with the status bar's `label`; `chain` for a paste's bitmap, its chain's number.
fn spawn_labeled(
app: &mut VectorcraftApp,
name: String,
label: String,
dropped: bool,
chain: Option<u64>,
read: impl FnOnce() -> Result<Read, String> + Send + 'static,
) -> Result<Option<Value>, String> {
let doc = app.session.doc().map_err(|e| e.to_string())?.uid;
let (tx, done) = channel();
@@ -145,8 +352,7 @@ fn spawn(
let _ = reader.send(job);
app.placing.reader = Some(reader);
}
let label = format!("Placing {shown}");
app.placing.pending.push(Pending { name, label: label.clone(), doc, dropped, done });
app.placing.pending.push(Pending { name, label: label.clone(), doc, dropped, paste: chain.is_some(), chain, done });
let status = format!("{label}…");
app.status(status.clone());
Ok(Some(json!({ "background": true, "status": status })))
@@ -162,6 +368,7 @@ fn spawn(
/// button is not held is waiting between its clicks (the Dimension tool after its first point):
/// the file is placed unless the tool has a preview open (the Flare tool after its center drag).
pub fn poll(app: &mut VectorcraftApp) {
settle_swatch_paste(app);
while app.ui.dialog.is_none()
&& !app.session.in_interaction()
&& !(app.session.tool_busy() && app.session.pointer_held())
@@ -178,6 +385,11 @@ pub fn poll(app: &mut VectorcraftApp) {
app.ui.status.clear();
}
finish(app, &p, r);
if let Some(chain) = p.chain
&& !app.placing.swatch_paste.as_ref().is_some_and(|waiting| waiting.chain == chain)
{
finish_chain(app, chain);
}
}
}
@@ -187,17 +399,43 @@ pub fn poll(app: &mut VectorcraftApp) {
fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result<Read, String>) {
let Some(i) = app.session.documents().iter().position(|st| st.uid == p.doc) else {
let name = &p.name;
if p.dropped {
if p.paste {
app.status("Couldn't paste: the document it was pasted in was closed");
} else if p.dropped {
app.status(format!("Couldn't place {name}: the document it was dropped on was closed"));
} else {
app.status(format!("Couldn't place {name}: the document it was placed in was closed"));
}
return;
};
if p.paste {
let error = match &r {
Ok(Read::Paste(paste)) => match &paste.pasted {
Some(Pasted::Image(_, Err(e))) => Some(e.as_str()),
_ => None,
},
Err(e) => Some(e.as_str()),
_ => None,
};
if let Some(e) = error {
if let Some(ch) = p.chain.and_then(|chain| app.placing.chains.get_mut(&chain))
&& ch.left.is_none()
{
let selection = match &r {
Ok(Read::Paste(paste)) => paste.selection.clone(),
_ => None,
};
ch.left = Some(selection.unwrap_or_else(|| ch.context.0.clone()));
}
app.status(format!("Couldn't paste from the clipboard: {e}"));
return;
}
}
app.session.set_active(i);
let r = match r {
Ok(Read::Place(read)) => app.session.place_read(*read).map(|_| ()).map_err(|e| e.to_string()),
Ok(Read::Queue(read)) => app.session.place_queue_read(read).map(|r| crate::place::show_thumbnails(app, &r)).map_err(|e| e.to_string()),
Ok(Read::Paste(paste)) => return finish_paste(app, *paste),
Err(e) => Err(e),
};
app.sync_views();
@@ -209,5 +447,160 @@ fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result<Read, String>) {
}
}
/// Run a paste whose bitmap was decoded off the UI thread, in its document, which is the active one:
/// the image replaces the internal clipboard, the objects selected when the paste started are
/// selected again, and the paste command runs as it runs at once ([`VectorcraftApp::run`]), into
/// the layer that was current when it started, with its errors in the status bar. A Copy or Cut
/// made since the paste started keeps the internal clipboard it made.
fn finish_paste(app: &mut VectorcraftApp, paste: Paste) {
let kept = (app.clipboard_publishing.latest() != paste.copied).then(|| app.session.clipboard.clone());
let adopted = match paste.pasted {
Some(Pasted::Image(mime, read)) => app.adopt_image_read(mime, paste.center, read),
Some(Pasted::Flavour(f)) => app.adopt_flavour(&f, paste.center),
None => {
app.session.clipboard = paste.clipboard;
Ok(())
}
};
let chain = paste.chain;
let selection = paste.selection.or_else(|| app.placing.chains.get_mut(&chain).and_then(|ch| ch.left.take()));
let mut deferred = false;
let left = match adopted {
Err(e) => {
app.status(e);
selection
}
Ok(()) => {
// Where new art goes now, before a selection changes the current layer.
let current = Insertion::of(app);
// The objects selected when the paste started (or that the paste it follows left) are
// selected again (those that are left), as `Session::place_read` selects the object
// Replace swaps.
let now = app.session.doc().map(|st| st.selection.objects.clone()).unwrap_or_default();
if let Some(selection) = selection.filter(|s| *s != now) {
let left: Vec<u64> =
selection.iter().filter(|id| app.session.doc().is_ok_and(|st| st.doc.node(**id).is_some())).map(|id| id.0).collect();
let _ = app.session.execute("select.set", &json!({ "ids": left }));
}
// The paste goes where new art went when it started (the current layer, the isolated
// group, the object Draw Inside draws into), and what the user changed meanwhile is
// kept.
paste.insertion.clone().set(app);
let requested_selection = app.session.doc().map(|st| st.selection.objects.clone()).unwrap_or_default();
app.placing.adopted = true;
// `run` shows an error in the status bar.
let _ = app.run(&paste.id, paste.params);
// Also when the paste did not reach the clipboard (the Type tool took it).
app.placing.adopted = false;
if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::swatch_conflict::KIND) {
d.fields.insert("__deferredPaste".into(), json!(chain));
if let Ok(st) = app.session.doc() {
app.placing.swatch_paste = Some(PendingSwatch {
clipboard: app.session.clipboard.clone(),
insertion: paste.insertion.clone(),
selection: requested_selection,
doc: st.uid,
chain,
});
deferred = true;
}
}
if let Some(current) = current {
current.restore_changed(&paste.insertion, app);
}
app.session.doc().ok().map(|st| st.selection.objects.clone())
}
};
// The chain's next paste takes the selection this one left, and a paste given with it and the
// insertion context joins the chain while a paste of it waits ([`poll`] drops the chain).
let context = app.session.doc().ok().map(|st| st.selection.objects.clone()).zip(Insertion::of(app));
if !deferred && let Some(ch) = app.placing.chains.get_mut(&chain) {
ch.left = left;
if let Some(context) = context {
ch.context = context;
}
}
if let Some(kept) = kept {
app.session.clipboard = kept;
}
}
fn finish_chain(app: &mut VectorcraftApp, chain: u64) {
if let Some(ch) = app.placing.chains.get_mut(&chain) {
ch.waiting = ch.waiting.saturating_sub(1);
if ch.waiting == 0 {
app.placing.chains.remove(&chain);
app.placing.latest_chain.retain(|_, c| *c != chain);
}
}
}
/// Release a deferred paste when its Swatch Conflict dialog is canceled or replaced.
pub(crate) fn cancel_swatch_paste(app: &mut VectorcraftApp) {
if let Some(waiting) = app.placing.swatch_paste.take() {
if let Some(ch) = app.placing.chains.get_mut(&waiting.chain) {
ch.left = Some(waiting.selection);
}
finish_chain(app, waiting.chain);
}
}
/// Release a deferred paste once its dialog is no longer the active dialog.
pub(crate) fn settle_swatch_paste(app: &mut VectorcraftApp) {
let active = app.placing.swatch_paste.as_ref().is_some_and(|waiting| {
app.ui.dialog.as_ref().is_some_and(|d| {
d.kind == crate::dialogs::swatch_conflict::KIND && d.fields.get("__deferredPaste").and_then(Value::as_u64) == Some(waiting.chain)
})
});
if app.placing.swatch_paste.is_some() && !active {
cancel_swatch_paste(app);
}
}
/// Run the command confirmed by the deferred paste's Swatch Conflict dialog.
pub(crate) fn confirm_swatch_paste(app: &mut VectorcraftApp, id: &str, params: Value, token: Option<u64>) -> Result<Value, String> {
if !app.placing.swatch_paste.as_ref().is_some_and(|waiting| Some(waiting.chain) == token) {
if token.is_some() || app.placing.swatch_paste.is_some() {
cancel_swatch_paste(app);
return Err("the deferred paste is no longer available".into());
}
return app.run(id, params);
}
let Some(waiting) = app.placing.swatch_paste.take() else { return app.run(id, params) };
let Some(index) = app.session.documents().iter().position(|st| st.uid == waiting.doc) else {
finish_chain(app, waiting.chain);
return Err("Couldn't paste: the document it was pasted in was closed".into());
};
app.session.set_active(index);
app.sync_views();
let current = std::mem::replace(&mut app.session.clipboard, waiting.clipboard);
let changed = Insertion::of(app);
let now = app.session.doc().map(|st| st.selection.objects.clone()).unwrap_or_default();
let selected: Vec<u64> =
waiting.selection.iter().filter(|id| app.session.doc().is_ok_and(|st| st.doc.node(**id).is_some())).map(|id| id.0).collect();
if selected != now.iter().map(|id| id.0).collect::<Vec<_>>() {
let _ = app.session.execute("select.set", &json!({"ids": selected}));
}
let insertion = waiting.insertion.clone();
insertion.clone().set(app);
app.placing.adopted = true;
let result = app.run(id, params);
app.placing.adopted = false;
if let Some(changed) = changed {
changed.restore_changed(&insertion, app);
}
app.session.clipboard = current;
let left = if result.is_ok() { app.session.doc().ok().map(|st| st.selection.objects.clone()) } else { Some(waiting.selection) };
let context = app.session.doc().ok().map(|st| st.selection.objects.clone()).zip(Insertion::of(app));
if let Some(ch) = app.placing.chains.get_mut(&waiting.chain) {
ch.left = left;
if let Some(context) = context {
ch.context = context;
}
}
finish_chain(app, waiting.chain);
result
}
#[cfg(test)]
mod tests;
+825
View File
@@ -1,6 +1,7 @@
use image::ImageFormat::{Bmp, Png, Tiff};
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::clipboard::{BITMAP, Flavour, PNG, TEXT};
use super::*;
use crate::Services;
@@ -111,6 +112,830 @@ fn a_file_read_off_the_ui_thread_places_as_one_read_at_once() {
}
}
/// Another app's bitmap on the system clipboard, and the text beside it, read as the desktop
/// hosts read them: with `converts`, the bitmap as the work that makes its flavour
/// ([`crate::ClipboardRead::Later`], `None`: it can't be made), which notes the thread it runs
/// on in `thread`. The work of each read waits for its entry of `holds` to send, when it has
/// one.
struct OtherApps {
bitmap: Option<Flavour>,
text: Option<Flavour>,
converts: bool,
thread: Arc<Mutex<Option<String>>>,
holds: Vec<Option<std::sync::mpsc::Receiver<()>>>,
}
impl crate::SystemClipboard for OtherApps {
fn write(&mut self, _: &[Flavour]) -> Result<(), String> {
Ok(())
}
fn holds_ours(&mut self) -> bool {
false
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
self.read_deferred(mimes)?.flavour()
}
fn has(&mut self, mimes: &[&'static str]) -> bool {
mimes.contains(&BITMAP) || (mimes.contains(&TEXT) && self.text.is_some())
}
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<crate::ClipboardRead> {
if mimes.contains(&TEXT)
&& let Some(text) = &self.text
{
return Some(crate::ClipboardRead::Now(text.clone()));
}
if !mimes.contains(&BITMAP) {
return None;
}
let (bitmap, thread) = (self.bitmap.clone(), self.thread.clone());
if !self.converts {
return bitmap.map(crate::ClipboardRead::Now);
}
let hold = if self.holds.is_empty() { None } else { self.holds.remove(0) };
Some(crate::ClipboardRead::Later(Box::new(move || {
if let Some(hold) = hold {
let _ = hold.recv_timeout(std::time::Duration::from_secs(10));
}
*thread.lock().unwrap() = std::thread::current().name().map(str::to_string);
bitmap
})))
}
}
/// [`app`] with `bitmap` and `text` on the system clipboard ([`OtherApps`]), the view centered
/// on (250, 180) → the app and the thread the bitmap was converted on.
fn pasting_app(background: bool, bitmap: Option<Flavour>, text: Option<&str>, converts: bool) -> (VectorcraftApp, Arc<Mutex<Option<String>>>) {
let thread = Arc::new(Mutex::new(None));
let mut app = app(background);
let text = text.map(|t| Flavour { mime: TEXT, data: t.as_bytes().to_vec() });
app.services.system_clipboard = Some(Box::new(OtherApps { bitmap, text, converts, thread: thread.clone(), holds: vec![] }));
let v = app.view_mut().unwrap();
(v.center, v.fitted) = (vectorcraft_geom::Point::new(250.0, 180.0), true);
(app, thread)
}
/// A Paste the user gives decodes another app's bitmap off the UI thread: a BMP as
/// `clipboard.importImage` reads it, and a bitmap the host converts to PNG (Windows'
/// device-independent bitmap, Linux's image) converted there too. Until then the document is
/// unchanged, and the status bar and the busy cursor show the paste; then it is pasted as at
/// once, where the view was centered when the paste was given: the same object, selection, undo
/// step and internal clipboard. `edit.paste` from MCP and scripts reads it at once.
#[test]
fn a_bitmap_the_user_pastes_is_read_off_the_ui_thread() {
let bmp = Flavour { mime: "image/bmp", data: picture(Bmp, 300, 200) };
let png = Flavour { mime: PNG, data: picture(Png, 300, 200) };
for (bitmap, converts) in [(bmp, false), (png, true)] {
for id in ["edit.paste", "edit.pasteInPlace"] {
let (mut at_once, _) = pasting_app(false, Some(bitmap.clone()), None, converts);
crate::menus::invoke(&mut at_once, id, json!({}));
let (mut app, _) = pasting_app(true, Some(bitmap.clone()), None, converts);
crate::menus::invoke(&mut app, id, json!({}));
// The view moves before the bitmap is read.
app.view_mut().unwrap().center = vectorcraft_geom::Point::new(40.0, 30.0);
placed(&mut app);
assert!(state(&app) == state(&at_once), "{} {id}: pasted where the view was", bitmap.mime);
}
let (mut at_once, _) = pasting_app(false, Some(bitmap.clone()), None, converts);
crate::menus::invoke(&mut at_once, "edit.paste", json!({}));
let (mut app, thread) = pasting_app(true, Some(bitmap.clone()), None, converts);
let before = state(&app);
app.placing.for_user = true;
let r = app.run("edit.paste", json!({}));
app.placing.for_user = false;
assert_eq!(r, Ok(json!({"background": true, "status": "Pasting…"})), "{}", bitmap.mime);
assert_eq!(app.ui.status, "Pasting…");
assert!(crate::opening::long_operation_busy(&app));
assert!(state(&app) == before, "{}: nothing is pasted until the bitmap is read", bitmap.mime);
placed(&mut app);
assert!(state(&app) == state(&at_once), "{}: pasted as at once", bitmap.mime);
assert!(app.session.clipboard == at_once.session.clipboard, "{}", bitmap.mime);
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
if converts {
assert_eq!(thread.lock().unwrap().as_deref(), Some("vectorcraft-place"));
}
// From the menu or the keys too.
crate::menus::invoke(&mut app, "edit.paste", json!({}));
assert_eq!(app.placing.pending.len(), 1);
placed(&mut app);
// MCP and scripts: at once.
let r = app.run("edit.paste", json!({})).unwrap();
assert!(r["ids"].is_array() && app.placing.pending.is_empty(), "{r}");
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1);
}
}
/// A bitmap that can't be read reports as at once and pastes nothing; one the host can't
/// convert pastes the internal clipboard as it is, or the address beside it as text, as at
/// once.
#[test]
fn a_pasted_bitmap_that_cant_be_read_off_the_ui_thread_reports_as_at_once() {
let url = "https://example.com/images/cat.png";
let cases = [(Some(Flavour { mime: PNG, data: b"not a picture".to_vec() }), None, false), (None, None, true), (None, Some(url), true)];
for (bitmap, text, converts) in cases {
let (mut at_once, _) = pasting_app(false, bitmap.clone(), text, converts);
let (mut app, _) = pasting_app(true, bitmap, text, converts);
for app in [&mut at_once, &mut app] {
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
app.run("edit.copy", json!({})).unwrap();
crate::menus::invoke(app, "edit.paste", json!({}));
assert_eq!(app.placing.pending.len(), usize::from(app.placing.off_the_ui_thread));
placed(app);
}
assert_eq!(app.ui.status, at_once.ui.status);
assert!(state(&app) == state(&at_once), "text: {text:?}, converts: {converts}");
let st = app.session.doc().unwrap();
let pasted = st.selection.objects.first().and_then(|id| st.doc.node(*id)).map(|n| &n.kind);
match (text, converts) {
(Some(_), _) => assert!(matches!(pasted, Some(vectorcraft_doc::NodeKind::Text(_))), "the address is pasted as text"),
(None, true) => assert!(matches!(pasted, Some(vectorcraft_doc::NodeKind::Path { .. })), "the copied square is pasted"),
(None, false) => assert!(app.ui.status.starts_with("Couldn't paste from the clipboard: "), "{}", app.ui.status),
}
}
}
/// A pasted bitmap whose header asks for more than the decoder allows, and a PNG cut short, are
/// refused off the UI thread with a message, as at once, and leave the document as it was.
#[test]
fn a_pasted_bitmap_too_large_or_cut_short_is_refused_with_a_message() {
// A BMP whose header says 30000 × 30000 pixels (2.7 GB of samples), with one pixel's bytes.
let mut huge = picture(Bmp, 1, 1);
huge[18..22].copy_from_slice(&30_000i32.to_le_bytes());
huge[22..26].copy_from_slice(&30_000i32.to_le_bytes());
let png = picture(Png, 600, 300);
let cut = png[..png.len() / 2].to_vec();
for (bitmap, why) in [(Flavour { mime: "image/bmp", data: huge }, "too large to draw"), (Flavour { mime: PNG, data: cut }, "can't be read")] {
let (mut at_once, _) = pasting_app(false, Some(bitmap.clone()), None, false);
crate::menus::invoke(&mut at_once, "edit.paste", json!({}));
let (mut app, _) = pasting_app(true, Some(bitmap.clone()), None, false);
let before = state(&app);
crate::menus::invoke(&mut app, "edit.paste", json!({}));
assert_eq!(app.placing.pending.len(), 1, "{why}");
placed(&mut app);
assert!(app.ui.status.starts_with("Couldn't paste from the clipboard: ") && app.ui.status.contains(why), "{}", app.ui.status);
assert_eq!(app.ui.status, at_once.ui.status);
assert!(state(&app) == before, "{why}: nothing is pasted");
}
}
/// A bitmap pasted off the UI thread is one undo step: Undo leaves the document as it was before
/// the paste, and Redo pastes it again.
#[test]
fn a_bitmap_pasted_off_the_ui_thread_undoes_in_one_step() {
let (mut app, _) = pasting_app(true, Some(Flavour { mime: PNG, data: picture(Png, 60, 40) }), None, true);
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let before = state(&app).0;
let undo = app.session.doc().unwrap().history.undo.len();
crate::menus::invoke(&mut app, "edit.paste", json!({}));
placed(&mut app);
let pasted = state(&app).0;
assert!(pasted != before);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
app.run("edit.undo", json!({})).unwrap();
assert!(state(&app).0 == before, "Undo removes the picture");
app.run("edit.redo", json!({})).unwrap();
assert!(state(&app).0 == pasted, "Redo pastes it again");
}
#[test]
fn a_failed_bitmap_paste_keeps_the_document_the_user_switched_to() {
let (mut app, _) = pasting_app(true, None, None, true);
let (go, hold) = channel();
let clip = OtherApps {
bitmap: Some(Flavour { mime: PNG, data: b"not a picture".to_vec() }),
text: None,
converts: true,
thread: Arc::new(Mutex::new(None)),
holds: vec![Some(hold)],
};
app.services.system_clipboard = Some(Box::new(clip));
let first = app.session.doc().unwrap();
let first = (first.doc.clone(), first.selection.objects.clone(), first.history.undo.len());
crate::menus::invoke(&mut app, "edit.paste", json!({}));
assert_eq!(app.placing.pending.len(), 1);
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let selected = app.run("shape.rectangle", json!({"x": 30, "y": 40, "width": 25, "height": 35})).unwrap()["id"].as_u64().unwrap();
app.run("layer.new", json!({})).unwrap();
app.run("select.set", json!({"ids": [selected]})).unwrap();
let second = state(&app);
let insertion = Insertion::of(&app);
let clipboard = app.session.clipboard.clone();
let view = app.view_mut().unwrap();
(view.center, view.zoom, view.rotation, view.fitted) = (vectorcraft_geom::Point::new(70.0, 90.0), 2.0, 15.0, true);
let view = *app.view().unwrap();
assert!(go.send(()).is_ok());
placed(&mut app);
assert_eq!(app.session.active_index(), Some(1));
assert!(state(&app) == second);
assert!(Insertion::of(&app) == insertion);
assert!(app.session.clipboard == clipboard);
let current_view = app.view().unwrap();
assert_eq!(
(current_view.center, current_view.zoom, current_view.rotation, current_view.fitted),
(view.center, view.zoom, view.rotation, view.fitted)
);
assert!(app.ui.status.starts_with("Couldn't paste from the clipboard: "), "{}", app.ui.status);
assert!(app.session.set_active(0));
let after = app.session.doc().unwrap();
assert!(after.doc == first.0 && after.selection.objects == first.1 && after.history.undo.len() == first.2);
assert!(app.placing.chains.is_empty() && app.placing.latest_chain.is_empty());
}
#[test]
fn a_paste_queued_after_a_failed_bitmap_uses_the_original_selection() {
for id in ["edit.pasteInFront", "edit.pasteInBack"] {
let (mut app, _) = pasting_app(true, None, None, true);
let source = app.run("shape.rectangle", json!({"x": 150, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("edit.copy", json!({})).unwrap();
let clipboard = app.session.clipboard.clone();
let rect = |app: &mut VectorcraftApp, x: u32| {
app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()
};
let (a, b, c) = (rect(&mut app, 10), rect(&mut app, 40), rect(&mut app, 70));
app.run("select.set", json!({"ids": [b]})).unwrap();
let (go, hold) = channel();
app.services.system_clipboard = Some(Box::new(OtherApps {
bitmap: Some(Flavour { mime: PNG, data: b"not a picture".to_vec() }),
text: None,
converts: true,
thread: Arc::new(Mutex::new(None)),
holds: vec![Some(hold)],
}));
crate::menus::invoke(&mut app, id, json!({}));
app.services.system_clipboard = None;
crate::menus::invoke(&mut app, id, json!({}));
assert_eq!(app.placing.pending.len(), 2, "{id}: both pastes wait");
assert_eq!(app.placing.chains.len(), 1, "{id}: the second paste joined the first");
app.run("select.set", json!({"ids": [if id.ends_with("Front") { a } else { c }]})).unwrap();
assert!(go.send(()).is_ok());
placed(&mut app);
let st = app.session.doc().unwrap();
let selected = st.selection.objects[0];
let order: Vec<_> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
let expected = if id.ends_with("Front") {
vec![NodeId(source), NodeId(a), NodeId(b), selected, NodeId(c)]
} else {
vec![NodeId(source), NodeId(a), selected, NodeId(b), NodeId(c)]
};
assert_eq!(order, expected, "{id}: the internal paste stays beside the original anchor");
assert!(matches!(st.doc.node(selected).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Path { .. })), "{id}");
assert!(app.session.clipboard == clipboard, "{id}: the failed bitmap did not replace the clipboard");
assert!(app.placing.chains.is_empty() && app.placing.latest_chain.is_empty(), "{id}: the queue was released");
}
}
/// A Copy made while a pasted bitmap is read keeps the internal clipboard it made, and a paste
/// whose document was closed meanwhile pastes nothing, with a message.
#[test]
fn a_copy_or_a_close_while_a_pasted_bitmap_is_read() {
let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });
let (mut app, _) = pasting_app(true, bitmap.clone(), None, true);
crate::menus::invoke(&mut app, "edit.paste", json!({}));
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
app.run("edit.copy", json!({})).unwrap();
let copied = app.session.clipboard.clone();
placed(&mut app);
let st = app.session.doc().unwrap();
let pasted = st.doc.node(st.selection.objects[0]).unwrap();
assert!(matches!(pasted.kind, vectorcraft_doc::NodeKind::Image(_)), "the bitmap is pasted");
assert!(app.session.clipboard == copied, "the copy stays on the internal clipboard");
// A bitmap that can't be converted pastes the internal clipboard as it was when the paste
// was given, and the later copy stays on it.
let (mut app, _) = pasting_app(true, None, None, true);
let square = |app: &mut VectorcraftApp, x: u32| {
app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap();
app.run("edit.copy", json!({})).unwrap();
};
square(&mut app, 10);
let first = app.session.clipboard.clone();
crate::menus::invoke(&mut app, "edit.pasteInPlace", json!({}));
square(&mut app, 200);
let second = app.session.clipboard.clone();
placed(&mut app);
let st = app.session.doc().unwrap();
let pasted = st.doc.node(st.selection.objects[0]).and_then(|n| n.geometric_bounds()).map(|b| b.x0);
assert!(first != second && pasted == Some(10.0), "the first copy is pasted: {pasted:?}");
assert!(app.session.clipboard == second, "the later copy stays on the internal clipboard");
let (mut app, _) = pasting_app(true, bitmap, None, true);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
crate::menus::invoke(&mut app, "edit.paste", json!({}));
app.session.execute("file.close", &json!({})).unwrap();
let left = state(&app);
placed(&mut app);
assert_eq!(app.ui.status, "Couldn't paste: the document it was pasted in was closed");
assert!(state(&app) == left, "the other document is unchanged");
assert!(app.placing.chains.is_empty() && app.placing.latest_chain.is_empty(), "no paste waits");
}
#[test]
fn deferred_fallback_keeps_its_swatch_dialog_art_and_later_copy() {
for confirm in [true, false] {
let (mut app, _) = pasting_app(true, None, None, true);
app.run("swatch.new", json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
let source = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("paint.setFill", json!({"ids": [source], "swatch": "Brand"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
let requested = app.session.clipboard.clone();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("swatch.new", json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInPlace", json!({}));
assert_eq!(app.placing.pending.len(), 1);
let newer = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("edit.copy", json!({})).unwrap();
let copied = app.session.clipboard.clone();
assert!(requested != copied);
placed(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, crate::dialogs::swatch_conflict::KIND);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["conflicts"][0]["clipboard"], "#ff0000");
assert!(app.session.clipboard == copied);
let latest = app.run("shape.rectangle", json!({"x": 250, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("edit.copy", json!({})).unwrap();
let latest_copy = app.session.clipboard.clone();
if confirm {
app.ui.dialog.as_mut().unwrap().fields.insert("choice".into(), json!("add"));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let st = app.session.doc().unwrap();
let pasted = st.doc.node(st.selection.objects[0]).unwrap();
assert_eq!(pasted.geometric_bounds().unwrap().x0, 10.0);
assert_eq!(pasted.appearance.fill_paint().color(), Some(vectorcraft_color::Color::from_hex("#ff0000").unwrap()));
} else {
crate::dialogs::cancel(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
}
assert!(app.session.clipboard == latest_copy);
app.run("edit.pasteInPlace", json!({})).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(st.doc.node(st.selection.objects[0]).unwrap().geometric_bounds().unwrap().x0, 250.0);
assert!(st.doc.node(NodeId(newer)).is_some() && st.doc.node(NodeId(latest)).is_some());
}
}
#[test]
fn a_synchronous_paste_replaces_a_deferred_swatch_dialog() {
let (mut app, _) = pasting_app(true, None, None, true);
app.run("swatch.new", json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
let first = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [first], "swatch": "Brand"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("swatch.new", json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInPlace", json!({}));
placed(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["conflicts"][0]["clipboard"], "#ff0000");
app.run("swatch.new", json!({"name": "Other", "color": "#ffff00", "global": true})).unwrap();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("swatch.new", json!({"name": "Other", "color": "#00ff00", "global": true})).unwrap();
let second = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [second], "swatch": "Other"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
app.session.set_active(1);
app.sync_views();
let result = crate::control::handle(
&mut app,
&egui::Context::default(),
&crate::control::ControlRequest::new("engine.execute", json!({"command": "edit.pasteInPlace", "params": {}})).0,
);
let crate::control::Outcome::Done(result) = result else { panic!("expected command result") };
assert_eq!(result["ok"], true, "{result}");
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["conflicts"][0]["clipboard"], "#00ff00");
app.ui.dialog.as_mut().unwrap().fields.insert("choice".into(), json!("add"));
crate::dialogs::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(st.doc.node(st.selection.objects[0]).unwrap().geometric_bounds().unwrap().x0, 200.0);
assert!(app.placing.chains.is_empty());
}
#[test]
fn a_find_font_dialog_releases_a_deferred_swatch_paste_before_queued_work() {
for id in ["edit.pasteInFront", "edit.pasteInBack"] {
let (mut app, _) = pasting_app(true, None, None, true);
app.run("swatch.new", json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
let source = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [source], "swatch": "Brand"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
let requested = app.session.clipboard.clone();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("swatch.new", json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
let anchor = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [anchor]})).unwrap();
crate::menus::invoke(&mut app, id, json!({}));
crate::menus::invoke(&mut app, id, json!({}));
let start = std::time::Instant::now();
while app.ui.dialog.is_none() && start.elapsed().as_secs() < 60 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, crate::dialogs::swatch_conflict::KIND);
assert_eq!(app.placing.pending.len(), 1);
let later = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("edit.copy", json!({})).unwrap();
let newer = app.session.clipboard.clone();
assert!(requested != newer);
let result = crate::control::handle(
&mut app,
&egui::Context::default(),
&crate::control::ControlRequest::new("engine.execute", json!({"command": "type.findFont", "params": {}})).0,
);
let crate::control::Outcome::Done(result) = result else { panic!("expected command result") };
assert_eq!(result["ok"], true, "{result}");
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, "findFont");
assert!(app.placing.swatch_paste.is_none());
assert_eq!(app.placing.chains.values().map(|ch| ch.waiting).collect::<Vec<_>>(), [1]);
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
crate::dialogs::cancel(&mut app);
placed(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, crate::dialogs::swatch_conflict::KIND);
app.ui.dialog.as_mut().unwrap().fields.insert("choice".into(), json!("add"));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let st = app.session.doc().unwrap();
let pasted_id = st.selection.objects[0];
let pasted = st.doc.node(pasted_id).unwrap();
assert_eq!(pasted.geometric_bounds().unwrap().x0, 10.0, "{id}");
assert_eq!(pasted.appearance.fill_paint().color(), Some(vectorcraft_color::Color::from_hex("#ff0000").unwrap()));
let order: Vec<_> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
let anchor_pos = order.iter().position(|n| *n == NodeId(anchor)).unwrap();
let pasted_pos = order.iter().position(|n| *n == pasted_id).unwrap();
assert_eq!(pasted_pos, if id.ends_with("Front") { anchor_pos + 1 } else { anchor_pos - 1 }, "{id}");
assert!(st.doc.node(NodeId(later)).is_some());
assert!(app.session.clipboard == newer);
assert!(app.placing.chains.is_empty() && app.placing.latest_chain.is_empty());
}
}
#[test]
fn a_deferred_swatch_paste_rejects_a_changed_token_or_closed_document() {
for close_document in [false, true] {
let (mut app, _) = pasting_app(true, None, None, true);
app.run("swatch.new", json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
let source = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [source], "swatch": "Brand"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
let clipboard = app.session.clipboard.clone();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("swatch.new", json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInPlace", json!({}));
placed(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, crate::dialogs::swatch_conflict::KIND);
if close_document {
app.session.execute("file.close", &json!({})).unwrap();
} else {
let result = crate::control::handle(
&mut app,
&egui::Context::default(),
&crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__deferredPaste", "value": 999999})).0,
);
let crate::control::Outcome::Done(result) = result else { panic!("expected dialog result") };
assert_eq!(result["ok"], true, "{result}");
}
let error = crate::dialogs::confirm(&mut app).unwrap_err();
assert!(
error.contains(if close_document { "document it was pasted in was closed" } else { "deferred paste is no longer available" }),
"{error}"
);
assert!(app.ui.dialog.is_none());
assert!(app.placing.swatch_paste.is_none());
assert!(app.placing.chains.is_empty() && app.placing.latest_chain.is_empty());
assert!(app.session.clipboard == clipboard);
let st = app.session.doc().unwrap();
assert_eq!(st.doc.layers[0].children().unwrap().len(), if close_document { 1 } else { 0 });
}
}
#[test]
fn a_deferred_swatch_paste_uses_its_original_layer() {
let (mut app, _) = pasting_app(true, None, None, true);
app.run("swatch.new", json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
let source = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [source], "swatch": "Brand"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("swatch.new", json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
let first = app.session.doc().unwrap().doc.layers[0].id;
let selected = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [selected]})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInPlace", json!({}));
app.run("layer.new", json!({})).unwrap();
let second = app.session.doc().unwrap().current_layer().unwrap();
placed(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, crate::dialogs::swatch_conflict::KIND);
app.ui.dialog.as_mut().unwrap().fields.insert("choice".into(), json!("add"));
crate::dialogs::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(st.doc.layer_containing(st.selection.objects[0]), Some(first));
assert_eq!(st.current_layer(), Some(second));
}
#[test]
fn a_queued_paste_stacks_after_a_deferred_swatch_confirmation() {
for id in ["edit.pasteInFront", "edit.pasteInBack"] {
let (mut app, _) = pasting_app(true, None, None, true);
app.run("swatch.new", json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
let source = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [source], "swatch": "Brand"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("swatch.new", json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
let anchor = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [anchor]})).unwrap();
crate::menus::invoke(&mut app, id, json!({}));
crate::menus::invoke(&mut app, id, json!({}));
let start = std::time::Instant::now();
while app.ui.dialog.is_none() && start.elapsed().as_secs() < 60 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!(app.placing.pending.len(), 1);
app.ui.dialog.as_mut().unwrap().fields.insert("choice".into(), json!("add"));
crate::dialogs::confirm(&mut app).unwrap();
placed(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, crate::dialogs::swatch_conflict::KIND);
app.ui.dialog.as_mut().unwrap().fields.insert("choice".into(), json!("add"));
crate::dialogs::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
let xs: Vec<_> = st.doc.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap().x0).collect();
let expected = if id.ends_with("Front") { vec![100.0, 10.0, 10.0] } else { vec![10.0, 10.0, 100.0] };
assert_eq!(xs, expected, "{id}");
}
}
/// A paste given while a pasted bitmap is read waits for it, also when it pastes the internal
/// clipboard or another app's text at once: it is pasted above the picture, and what it pasted
/// stays on the internal clipboard.
#[test]
fn a_paste_given_while_a_pasted_bitmap_is_read_waits_for_it() {
for text in [None, Some("hello")] {
let (mut app, _) = pasting_app(true, None, None, true);
let (go, hold) = channel();
let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });
let clip = OtherApps { bitmap, text: None, converts: true, thread: Arc::new(Mutex::new(None)), holds: vec![Some(hold)] };
app.services.system_clipboard = Some(Box::new(clip));
crate::menus::invoke(&mut app, "edit.paste", json!({}));
assert_eq!(app.placing.pending.len(), 1);
// The internal clipboard: a square copied meanwhile. Text: another app's text.
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
app.run("edit.copy", json!({})).unwrap();
let text = text.map(|t| Flavour { mime: TEXT, data: t.as_bytes().to_vec() });
let clip = OtherApps { bitmap: None, text: text.clone(), converts: false, thread: Arc::new(Mutex::new(None)), holds: vec![] };
app.services.system_clipboard = Some(Box::new(clip));
crate::menus::invoke(&mut app, "edit.paste", json!({}));
let second = app.session.clipboard.clone();
assert_eq!(app.placing.pending.len(), 2, "text: {}: the second paste waits", text.is_some());
let _ = go.send(());
placed(&mut app);
let st = app.session.doc().unwrap();
let kinds: Vec<&str> = st.doc.layers[0]
.children()
.unwrap()
.iter()
.map(|n| match n.kind {
vectorcraft_doc::NodeKind::Image(_) => "picture",
vectorcraft_doc::NodeKind::Text(_) => "text",
_ => "path",
})
.collect();
let pasted = if text.is_some() { "text" } else { "path" };
assert_eq!(kinds, ["path", "picture", pasted], "the second paste is above the picture");
assert!(app.session.clipboard == second, "text: {}: what the second paste pasted stays on the internal clipboard", text.is_some());
}
}
/// Paste in Front and Paste in Back put a bitmap decoded off the UI thread next to the objects
/// selected when the paste was given, as at once, when another object was selected meanwhile;
/// a second one given before the first is pasted goes next to the first, as at once.
#[test]
fn a_paste_in_front_read_off_the_ui_thread_keeps_its_place() {
let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });
for id in ["edit.pasteInFront", "edit.pasteInBack"] {
for twice in [false, true] {
let (mut at_once, _) = pasting_app(false, bitmap.clone(), None, true);
let (mut app, _) = pasting_app(true, bitmap.clone(), None, true);
for app in [&mut at_once, &mut app] {
let rect = |app: &mut VectorcraftApp, x: u32| {
app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone()
};
let (a, b, c) = (rect(app, 10), rect(app, 40), rect(app, 70));
app.run("select.set", json!({"ids": [b]})).unwrap();
crate::menus::invoke(app, id, json!({}));
if twice {
crate::menus::invoke(app, id, json!({}));
} else {
let other = if id.ends_with("Front") { a } else { c };
app.run("select.set", json!({"ids": [other]})).unwrap();
}
placed(app);
}
// Each object in paint order: `None` for a square, the paste order for a picture.
let order = |app: &VectorcraftApp| {
let st = app.session.doc().unwrap();
let children = st.doc.layers[0].children().unwrap();
let mut pictures: Vec<NodeId> =
children.iter().filter(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))).map(|n| n.id).collect();
pictures.sort();
children.iter().map(|n| pictures.iter().position(|p| *p == n.id)).collect::<Vec<_>>()
};
assert_eq!(order(&app), order(&at_once), "{id}, twice: {twice}");
}
}
}
/// A second Paste in Front given before the first is pasted goes next to the first one's
/// picture, also when another object was selected between the two pastes' ends, and a third
/// given after the first was pasted, while the second waits, goes next to the second's.
#[test]
fn a_second_paste_in_front_follows_the_first() {
for third in [false, true] {
let (mut app, _) = pasting_app(true, None, None, true);
let (go, hold) = channel();
let thread = Arc::new(Mutex::new(None));
let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });
app.services.system_clipboard = Some(Box::new(OtherApps { bitmap, text: None, converts: true, thread, holds: vec![None, Some(hold)] }));
let rect =
|app: &mut VectorcraftApp, x: u32| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let (a, b) = (rect(&mut app, 10), rect(&mut app, 40));
app.run("select.set", json!({"ids": [b]})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
let t0 = std::time::Instant::now();
while app.placing.pending.len() > 1 && t0.elapsed().as_secs() < 60 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!(app.placing.pending.len(), 1, "the first paste ran");
if third {
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
} else {
app.run("select.set", json!({"ids": [a]})).unwrap();
}
let _ = go.send(());
placed(&mut app);
let st = app.session.doc().unwrap();
let kinds: Vec<Option<NodeId>> =
st.doc.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)).then_some(n.id)).collect();
let pictures: Vec<NodeId> = kinds.iter().flatten().copied().collect();
assert!(pictures.len() == 2 + usize::from(third) && pictures.is_sorted(), "each picture is above the one before: {kinds:?}");
assert_eq!(kinds.iter().position(Option::is_some), Some(2), "the pictures are above the square they were pasted on: {kinds:?}");
}
}
/// A paste given beside the picture an independent paste left, while another paste waits, goes
/// beside that picture.
#[test]
fn a_paste_beside_a_finished_picture_does_not_follow_another_chain() {
let (mut app, _) = pasting_app(true, None, None, true);
let (go, hold) = channel();
let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });
let clip = OtherApps { bitmap, text: None, converts: true, thread: Arc::new(Mutex::new(None)), holds: vec![None, Some(hold)] };
app.services.system_clipboard = Some(Box::new(clip));
let rect =
|app: &mut VectorcraftApp, x: u32| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let (x, y) = (rect(&mut app, 10), rect(&mut app, 40));
app.run("select.set", json!({"ids": [x]})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
app.run("select.set", json!({"ids": [y]})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
let t0 = std::time::Instant::now();
while app.placing.pending.len() > 1 && t0.elapsed().as_secs() < 60 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!(app.placing.pending.len(), 1, "the first paste ran");
// The first picture is selected.
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
let _ = go.send(());
placed(&mut app);
let st = app.session.doc().unwrap();
let ids: Vec<Option<NodeId>> =
st.doc.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)).then_some(n.id)).collect();
let last = ids.iter().flatten().max().copied();
assert_eq!(ids.iter().position(|id| *id == last && last.is_some()), Some(2), "the third picture is above the first: {ids:?}");
}
/// Pastes that follow each other in one document follow each other there, also when a paste in
/// another document runs between them.
#[test]
fn pastes_in_two_documents_follow_their_own() {
let (mut app, _) = pasting_app(true, None, None, true);
let (go, hold) = channel();
let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });
let clip = OtherApps { bitmap, text: None, converts: true, thread: Arc::new(Mutex::new(None)), holds: vec![None, Some(hold)] };
app.services.system_clipboard = Some(Box::new(clip));
let rect =
|app: &mut VectorcraftApp, x: u32| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let (_, b) = (rect(&mut app, 10), rect(&mut app, 40));
app.run("select.set", json!({"ids": [b]})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
let t0 = std::time::Instant::now();
while app.placing.pending.len() > 1 && t0.elapsed().as_secs() < 60 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!(app.placing.pending.len(), 1, "the first paste ran");
// A paste in another document, then one more in the first.
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let x = rect(&mut app, 10);
app.run("select.set", json!({"ids": [x]})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
app.session.set_active(0);
app.sync_views();
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
let _ = go.send(());
placed(&mut app);
let st = &app.session.documents()[0];
let kinds: Vec<Option<NodeId>> =
st.doc.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)).then_some(n.id)).collect();
let pictures: Vec<NodeId> = kinds.iter().flatten().copied().collect();
assert!(pictures.len() == 3 && pictures.is_sorted(), "each picture is above the one before: {kinds:?}");
assert_eq!(kinds.iter().position(Option::is_some), Some(2), "the pictures are above the square they were pasted on: {kinds:?}");
}
/// In Draw Inside in an isolated path, a bitmap decoded off the UI thread is pasted inside the path,
/// whose new clipping group stays isolated, when the user went back to Draw Normal meanwhile.
#[test]
fn a_bitmap_pasted_inside_an_isolated_path_stays_isolated() {
let (mut app, _) = pasting_app(true, Some(Flavour { mime: PNG, data: picture(Png, 60, 40) }), None, true);
let path = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 200, "height": 200})).unwrap()["id"].clone();
app.run("object.isolate", json!({"id": path})).unwrap();
app.run("select.set", json!({"ids": [path]})).unwrap();
app.run("view.drawMode", json!({"mode": "inside"})).unwrap();
crate::menus::invoke(&mut app, "edit.paste", json!({}));
app.run("view.drawMode", json!({"mode": "normal"})).unwrap();
placed(&mut app);
let st = app.session.doc().unwrap();
let pasted = st.selection.objects[0];
let group = st.doc.parent_of(pasted);
assert!(
group.is_some_and(|g| st.doc.node(g).is_some_and(|n| n.children().is_some_and(|c| c.iter().any(|n| n.id.0 == path.as_u64().unwrap())))),
"pasted inside the path"
);
assert_eq!(st.isolation, group, "the clipping group is isolated");
assert_eq!(app.session.draw_mode, vectorcraft_engine::DrawMode::Normal, "Draw Normal stays");
}
/// A bitmap decoded off the UI thread is pasted into the layer that was current when the paste
/// was given, and a layer made current meanwhile stays current.
#[test]
fn a_bitmap_read_off_the_ui_thread_is_pasted_into_its_layer() {
let (mut app, _) = pasting_app(true, Some(Flavour { mime: PNG, data: picture(Png, 60, 40) }), None, true);
let first = app.session.doc().unwrap().doc.layers[0].id;
app.run("layer.new", json!({})).unwrap();
let second = app.session.doc().unwrap().current_layer().unwrap();
app.run("layer.setCurrent", json!({"id": first.0})).unwrap();
crate::menus::invoke(&mut app, "edit.paste", json!({}));
app.run("layer.setCurrent", json!({"id": second.0})).unwrap();
placed(&mut app);
let st = app.session.doc().unwrap();
let pasted = st.selection.objects[0];
assert_eq!(st.doc.layer_containing(pasted), Some(first), "pasted into the layer current at the paste");
assert_eq!(st.current_layer(), Some(second), "the layer made current meanwhile stays current");
// With nothing selected, a Paste in Front given in another layer while the first waits
// goes into that layer.
app.run("layer.setCurrent", json!({"id": first.0})).unwrap();
app.run("select.none", json!({})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
app.run("layer.setCurrent", json!({"id": second.0})).unwrap();
app.run("select.none", json!({})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
assert_eq!(app.placing.pending.len(), 2);
placed(&mut app);
let st = app.session.doc().unwrap();
let tops: Vec<_> = [first, second]
.iter()
.map(|l| st.doc.node(*l).and_then(|n| n.children()).and_then(|c| c.last()).map(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))))
.collect();
assert_eq!(tops, [Some(true), Some(true)], "each layer has a picture on top");
// A group isolated while the paste waits keeps its isolation, and the paste goes into the
// layer, where it was given.
let squares: Vec<Value> =
(0..2).map(|i| app.run("shape.rectangle", json!({"x": 10 + 30 * i, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone()).collect();
app.run("select.set", json!({"ids": squares})).unwrap();
let group = NodeId(app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap());
app.run("select.none", json!({})).unwrap();
crate::menus::invoke(&mut app, "edit.paste", json!({}));
app.run("object.isolate", json!({"id": group.0})).unwrap();
placed(&mut app);
let st = app.session.doc().unwrap();
let pasted = st.selection.objects[0];
assert_eq!(st.doc.parent_of(pasted), Some(second), "pasted into the layer, not the group isolated meanwhile");
assert_eq!(st.isolation, Some(group), "the group stays isolated");
// An object selected in another layer while the paste waits makes that layer current, and
// it stays current after the paste.
app.run("object.exitIsolation", json!({})).unwrap();
let in_second = app.run("shape.rectangle", json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("layer.setCurrent", json!({"id": first.0})).unwrap();
let in_first = app.run("shape.rectangle", json!({"x": 300, "y": 100, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [in_first]})).unwrap();
crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));
app.run("select.set", json!({"ids": [in_second]})).unwrap();
assert_eq!(app.session.doc().unwrap().current_layer(), Some(second));
placed(&mut app);
let st = app.session.doc().unwrap();
assert_eq!(st.doc.layer_containing(st.selection.objects[0]), Some(first), "pasted next to the object selected at the paste");
assert_eq!(st.current_layer(), Some(second), "the layer of the object selected meanwhile stays current");
}
/// The Place dialog's OK with one file, with several (the place cursor and its thumbnails), a
/// drop on the canvas and [`crate::io::place_bytes`] read their files off the UI thread;
/// `file.place` and `file.place.queue` from MCP and scripts read them at once, with the replies
+92 -6
View File
@@ -12,7 +12,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::channel;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::clipboard::{AI, BITMAP, EMF, EPS, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};
use vectorcraft_engine::cmd::clipboard::{AI, BITMAP, EMF, EPS, Flavour, ImageRead, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};
use crate::VectorcraftApp;
use crate::background::{self, Writer};
@@ -27,6 +27,34 @@ pub trait SystemClipboard {
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour>;
/// Does the clipboard hold one of `mimes`? Cheap enough to ask a few times a second.
fn has(&mut self, mimes: &[&'static str]) -> bool;
/// [`Self::read`], except that a bitmap the host converts to PNG (Windows' device-independent
/// bitmap, the image Linux gives) comes back as the work that converts it
/// ([`ClipboardRead::Later`]), for a paste that runs it off the UI thread. By default every
/// flavour is read at once.
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {
self.read(mimes).map(ClipboardRead::Now)
}
}
/// What [`SystemClipboard::read_deferred`] read.
pub enum ClipboardRead {
/// The flavour.
Now(Flavour),
/// The work that makes the flavour from the bytes read (a bitmap decoded and encoded as PNG).
Later(MakeFlavour),
}
/// Makes a flavour from bytes read off the clipboard, on any thread; `None` when it can't be made.
pub type MakeFlavour = Box<dyn FnOnce() -> Option<Flavour> + Send>;
impl ClipboardRead {
/// The flavour, made at once when it is work.
pub fn flavour(self) -> Option<Flavour> {
match self {
Self::Now(f) => Some(f),
Self::Later(make) => make(),
}
}
}
/// Makes a second system-clipboard handle, to check off the UI thread whether Paste has something
@@ -50,6 +78,17 @@ impl Publishing {
pub(crate) fn is_pending(&self) -> bool {
self.pending
}
/// The number of the latest Copy or Cut: it changes with each Copy or Cut, and with each copy
/// through egui that drops the formats not written yet.
pub(crate) fn latest(&self) -> u64 {
self.latest.load(Ordering::SeqCst)
}
}
/// Does a flavour of type `mime` load with `clipboard.importImage` ([`import_command`])?
pub(crate) fn imports_as_image(mime: &str) -> bool {
![TEXT, SVG, PDF, AI, EPS, EMF].contains(&mime)
}
/// The command (and its params) that loads `f` into the internal clipboard, centred on `center`.
@@ -208,44 +247,91 @@ impl VectorcraftApp {
/// 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> {
self.adopt_system_clipboard_or_defer(false).map(drop)
}
/// [`Self::adopt_system_clipboard`]. With `defer`, a bitmap is not loaded here: the work that
/// makes its flavour comes back (a bitmap the host converts to PNG is converted by it,
/// [`SystemClipboard::read_deferred`]; text that names the picture is the flavour when the
/// picture can't be made), and the internal clipboard is left as it is.
pub(crate) fn adopt_system_clipboard_or_defer(&mut self, defer: bool) -> Result<Option<MakeFlavour>, String> {
// A paste command may run before the next tick consumes an image signal: this is that
// paste, so a queued `PasteImage` must not read the clipboard a second time.
self.clipboard_image_request = None;
let pasted = self.clipboard_in.take();
let center = self.view().map(|v| [v.center.x, v.center.y]);
let (cmd, params) = match (pasted, self.services.system_clipboard.as_mut()) {
(Some(f), _) if defer && imports_as_image(f.mime) => return Ok(Some(Box::new(move || Some(f)))),
// A picture or file the host read from the paste.
(Some(f), _) if f.mime != TEXT => import_command(&f, center),
(_, Some(cb)) => {
if self.clipboard_publishing.pending || cb.holds_ours() {
return Ok(());
return Ok(None);
}
let Some(f) = cb.read(&PASTE_ORDER) else { return Ok(()) };
let mut read = |mimes: &[&'static str]| if defer { cb.read_deferred(mimes) } else { cb.read(mimes).map(ClipboardRead::Now) };
let f = match read(&PASTE_ORDER) {
Some(ClipboardRead::Now(f)) => f,
Some(ClipboardRead::Later(make)) => return Ok(Some(make)),
None => return Ok(None),
};
// Text that only names a picture (its address, or its image markup) goes with a
// browser's copied picture: the picture.
let text = String::from_utf8_lossy(&f.data);
// Blank text is what some hosts put beside a copied picture. It is not the paste.
let f = if f.mime == TEXT && (text.trim().is_empty() || names_a_picture(&text)) { cb.read(&[BITMAP]).unwrap_or(f) } else { f };
let f = if f.mime == TEXT && (text.trim().is_empty() || names_a_picture(&text)) {
match read(&[BITMAP]) {
Some(ClipboardRead::Now(picture)) => picture,
// The text, when the picture can't be made.
Some(ClipboardRead::Later(make)) => return Ok(Some(Box::new(move || make().or(Some(f))))),
None => f,
}
} else {
f
};
if defer && imports_as_image(f.mime) {
return Ok(Some(Box::new(move || Some(f))));
}
import_command(&f, center)
}
// Text only: SVG markup is art, other text is left alone.
(pasted, None) => {
let text =
pasted.map(|f| String::from_utf8_lossy(&f.data).into_owned()).or_else(|| self.services.clipboard_read.as_mut().and_then(|f| f()));
let Some(text) = text.filter(|t| looks_like_svg(t)) else { return Ok(()) };
let Some(text) = text.filter(|t| looks_like_svg(t)) else { return Ok(None) };
if self.clipboard_published.as_deref() == Some(text.as_str()) {
return Ok(());
return Ok(None);
}
self.clipboard_published = Some(text.clone());
("clipboard.importSvg", json!({ "svg": text, "center": center }))
}
};
self.session.execute(cmd, &params).map(|_| None).map_err(|e| {
self.clipboard_published = None;
format!("Couldn't paste from the clipboard: {e}")
})
}
/// Before a paste whose flavour was made off the UI thread ([`crate::placing::adopt_for_paste`]):
/// `f` replaces the internal clipboard, centered on `center`, as
/// [`Self::adopt_system_clipboard`] loads it. An error means nothing should be pasted.
pub(crate) fn adopt_flavour(&mut self, f: &Flavour, center: Option<[f64; 2]>) -> Result<(), String> {
let (cmd, params) = import_command(f, center);
self.session.execute(cmd, &params).map(drop).map_err(|e| {
self.clipboard_published = None;
format!("Couldn't paste from the clipboard: {e}")
})
}
/// [`Self::adopt_flavour`] for a bitmap of type `mime` decoded off the UI thread: the image `read`
/// replaces the internal clipboard, centered on `center`.
pub(crate) fn adopt_image_read(&mut self, mime: &str, center: Option<[f64; 2]>, read: Result<ImageRead, String>) -> Result<(), String> {
let params = json!({ "mime": mime, "center": center });
read.and_then(|read| self.session.import_image_read(read, &params).map(drop).map_err(|e| e.to_string())).map_err(|e| {
self.clipboard_published = None;
format!("Couldn't paste from the clipboard: {e}")
})
}
/// 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> {
+3
View File
@@ -629,6 +629,9 @@ the Windows clipboard and, with SVG, from the macOS pasteboard, and an `.eps` fi
and pastes nothing. Cmd+V also pastes a bitmap or PDF with no text beside it (on the key's release, as egui sends no
Paste event for it). Screenshots paste on every platform: Windows reads `PNG`, else `CF_DIBV5`/`CF_DIB`, macOS the
pasteboard's PNG or TIFF, Linux `image/png`, and the web the picture the browser's paste event carries (#457).
In the desktop app a Paste from the menus or the keys (`ui.key`) decodes a bitmap off the UI thread: `ui.inspect` lists
`clipboard` in `placing` until it is pasted, and an import error then shows in the status bar. `engine.execute`
`edit.paste*` reads it at once.
File menu details: `file.openRecent1`…`file.openRecent30` open the recent files the `recentFilesCount` preference
lists (0 hides them all; `file.recentFiles` returns the listed paths), `file.reveal` shows the document's file in the
+9 -2
View File
@@ -4049,8 +4049,15 @@ also before its formats are published. While a text field or the Type tool has t
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
Paste takes SVG text and the pictures and files a paste carries (a screenshot pastes as an embedded image). A `.ai`
bitmaps (macOS the pasteboard's PNG, else TIFF, as screenshots copy them). A Paste from the menus or the keys decodes
a bitmap (a copied image file too) on the thread that reads placed files, with "Pasting" in the status bar and the
busy spinner, and pastes it once it is decoded, in the document it was given in: Windows' device-independent bitmap and
Linux's image are encoded as PNG there, and the bitmap is read as `clipboard.importImage` reads it. The paste is
centered where the view was and goes where new art went (the current layer, the isolated group, the object Draw
Inside drew into), and Paste in Front and Paste in Back paste next
to the objects selected when it was given (a second paste given before the first is pasted goes next to the first).
A Copy or Cut made meanwhile keeps the internal clipboard it made. `edit.paste*` over MCP and scripts reads the bitmap at once.
On the web Copy publishes SVG text, and Paste takes SVG text and the pictures and files a paste carries (a screenshot pastes as an embedded image). A `.ai`
or `.ait` file that holds a PDF, copied in a file manager or carried by a paste, is loaded with `clipboard.importPdf
{editingData: true}`, and its objects go into the active layer.
+1
View File
@@ -1141,6 +1141,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- File › Place reads Word 97-2003 `.doc` documents as area type with the same formatting, read from the main text's pieces, the style sheet's paragraph and character styles and direct formatting. Table rows are paragraphs with their cells separated by tabs and fields place as their results; fields without a result, hidden and deleted text, pictures, headers, footers, notes, comments and text boxes are left out, and `warnings` names each kind left out or changed. Encrypted documents, documents last saved with fast save and Word 6.0 and Word 95 documents are refused (`file.place`, `crates/engine/src/tests/textimport.rs`).
- Placing a Word 97-2003 `.doc` document works out each style's formatting once and each property run's once, and refuses property pages that are out of order, listed twice or overlap, a style sheet that holds fewer styles than it counts, styles based on each other in a loop, and more than 100,000 runs of character or of paragraph properties, 16 MiB of properties or 100,000 paragraphs, hidden ones included (`file.place`, `crates/engine/src/tests/textimport.rs`).
- On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before.
- On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once.
- The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`).
- A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284).
- File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place <name>: the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`).