From 8a81c956855e43380d23ead9f79bc06434c1a03b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 16:55:06 -0800 Subject: [PATCH] Type: a copy elsewhere during a refused write wins the paste When another app copies while VectorCraft's write is trying to open the clipboard and the write then fails, Paste takes the other app's copy. A change during the failed write counts as the write's own only when the clipboard then holds this copy's text, or no text and either nothing to paste or one of this copy's formats. --- crates/ui-egui/src/sysclip.rs | 14 ++++++-- crates/ui-egui/src/tests/clipboard.rs | 47 +++++++++++++++++++++++++-- crates/ui-egui/src/tests/sysclip.rs | 36 +++++++++++++++++--- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index 3cbdf2c9e..b68faa3cf 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -208,15 +208,25 @@ impl VectorcraftApp { let formats = r.and_then(|_| made.try_recv().map_err(|e| e.to_string())); let written = formats.and_then(|(f, warnings)| { let Some(cb) = app.services.system_clipboard.as_mut() else { return Ok(warnings) }; + let before_write = Held::now(cb.as_mut()); // Nothing copied elsewhere since the Copy or Cut. - let current = before.as_ref().is_some_and(|b| *b == Held::now(cb.as_mut())); + let current = before.as_ref() == Some(&before_write); if let Err(e) = cb.write(&f) { // Another app may hold the clipboard open. It keeps what it held (or what the // failed write left), and a paste into text takes this copy's text until // something else is copied. let held = Held::now(cb.as_mut()); let text = f.iter().find(|t| t.mime == TEXT).map(|t| String::from_utf8_lossy(&t.data).into_owned()); - app.clipboard_publishing.unwritten = text.filter(|_| current).map(|text| (held, text)); + // A change during the write is the failed write emptying the clipboard and + // writing some of the formats, or another app's copy: the clipboard then holds + // nothing to paste, this copy's text, or no text and one of this copy's + // formats only after the former. + let left_by_write = held == before_write + || match cb.read(&[TEXT]) { + Some(now) => text.as_deref().is_some_and(|ours| now.data == ours.as_bytes()), + None => !cb.has(&PASTE_ORDER) || f.iter().any(|ours| cb.read(&[ours.mime]).is_some_and(|now| now.data == ours.data)), + }; + app.clipboard_publishing.unwritten = text.filter(|_| current && left_by_write).map(|text| (held, text)); return Err(e); } Ok(warnings) diff --git a/crates/ui-egui/src/tests/clipboard.rs b/crates/ui-egui/src/tests/clipboard.rs index c4f0e3a74..cd28ffe67 100644 --- a/crates/ui-egui/src/tests/clipboard.rs +++ b/crates/ui-egui/src/tests/clipboard.rs @@ -411,9 +411,17 @@ fn a_copy_elsewhere_before_a_refused_write_wins_the_type_paste() { #[test] fn type_paste_keeps_a_copy_whose_write_failed_after_emptying_the_clipboard() { let _guard = TEST_CLIP_LOCK.lock().unwrap(); + assert_eq!(paste_after_a_write_that_failed_after_emptying(false), "Own: Hello"); + // The formats other than text were written before the failure. + assert_eq!(paste_after_a_write_that_failed_after_emptying(true), "Own: Hello"); +} + +/// Copy "Hello" in the Type tool with a write that empties the clipboard and fails (`keep_others`: +/// after writing the formats other than text), then paste into "Own: ". +fn paste_after_a_write_that_failed_after_emptying(keep_others: bool) -> String { let (mut app, board) = super::sysclip::app(); super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]); - super::sysclip::fail_writes_after_emptying(&board); + super::sysclip::fail_writes_after_emptying(&board, keep_others); let source = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Hello", "size": 36, "color": "#ff0000"}))["id"].as_u64().unwrap(); let destination = run(&mut app, "text.create", json!({"x": 20, "y": 60, "text": "Own: ", "size": 12, "color": "#000000"}))["id"].as_u64().unwrap(); @@ -429,7 +437,42 @@ fn type_paste_keeps_a_copy_whose_write_failed_after_emptying_the_clipboard() { crate::panels::character::intercept_text_command(&mut app, "edit.paste"); let node = app.session.doc().unwrap().doc.node(NodeId(destination)).unwrap(); let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!() }; - assert_eq!(t.plain_text(), "Own: Hello"); + t.plain_text() +} + +#[test] +fn a_copy_elsewhere_during_a_refused_write_wins_the_type_paste() { + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + use vectorcraft_engine::cmd::clipboard::{PNG, TEXT}; + assert_eq!(paste_after_a_copy_during_a_refused_write(TEXT, b"Race".to_vec(), false), "Own: Race"); + // Its other formats may match this copy's; its text still differs. + assert_eq!(paste_after_a_copy_during_a_refused_write(TEXT, b"Race".to_vec(), true), "Own: Race"); + // A picture has no text to paste into type. + assert_eq!(paste_after_a_copy_during_a_refused_write(PNG, super::sysclip::blue_png(), false), "Own: "); +} + +/// Copy in the Type tool while another app copies `data` of type `mime` (with `our_formats`, also +/// the copy's formats other than text) and the write fails, then paste into "Own: ". +fn paste_after_a_copy_during_a_refused_write(mime: &'static str, data: Vec, our_formats: bool) -> String { + let (mut app, board) = super::sysclip::app(); + super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]); + super::sysclip::copy_during_next_write(&board, mime, data, our_formats); + let source = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Hello", "size": 36, "color": "#ff0000"}))["id"].as_u64().unwrap(); + let destination = + run(&mut app, "text.create", json!({"x": 20, "y": 60, "text": "Own: ", "size": 12, "color": "#000000"}))["id"].as_u64().unwrap(); + let v = app.view_info(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": source, "start": 0, "end": 5})); + crate::panels::character::intercept_text_command(&mut app, "edit.copy"); + crate::background::wait_all(&mut app); + + app.session.select_tool("selection", v).unwrap(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": destination, "start": 5, "end": 5})); + crate::panels::character::intercept_text_command(&mut app, "edit.paste"); + let node = app.session.doc().unwrap().doc.node(NodeId(destination)).unwrap(); + let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!() }; + t.plain_text() } /// https://helpx.adobe.com/illustrator/using/format-text.html diff --git a/crates/ui-egui/src/tests/sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs index 40d81c4e8..e5eca1e8d 100644 --- a/crates/ui-egui/src/tests/sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -31,6 +31,13 @@ pub(super) struct Board { uncounted: bool, /// The app's writes empty the clipboard and then fail. partial: bool, + /// With `partial`, the formats other than text are written before the failure. + partial_keeps_others: bool, + /// Another app copies this while the app's write tries to open the clipboard, and the write + /// fails. + race: Option, + /// With `race`, the other app's copy also carries the app's formats other than text. + race_with_our_formats: bool, } struct Fake(Rc>); @@ -52,8 +59,17 @@ impl SystemClipboard for Fake { if b.locked { return Err("another app has the clipboard open".into()); } + if let Some(copied) = b.race.take() { + b.flavours = vec![copied]; + if b.race_with_our_formats { + b.flavours.extend(flavours.iter().filter(|f| f.mime != TEXT).cloned()); + } + b.ours = false; + b.changes += 1; + return Err("another app has the clipboard open".into()); + } if b.partial { - b.flavours.clear(); + b.flavours = if b.partial_keeps_others { flavours.iter().filter(|f| f.mime != TEXT).cloned().collect() } else { vec![] }; b.changes += 1; return Err("a format could not be written".into()); } @@ -103,9 +119,21 @@ pub(super) fn without_change_count(board: &Rc>) { board.borrow_mut().uncounted = true; } -/// The app's writes empty the clipboard and then fail, as when one format can't be written. -pub(super) fn fail_writes_after_emptying(board: &Rc>) { - board.borrow_mut().partial = true; +/// The app's writes empty the clipboard and then fail, as when one format can't be written; with +/// `keep_others`, the formats other than text are written. +pub(super) fn fail_writes_after_emptying(board: &Rc>, keep_others: bool) { + let mut b = board.borrow_mut(); + b.partial = true; + b.partial_keeps_others = keep_others; +} + +/// Another app copies `data` of type `mime` while the app's next write tries to open the +/// clipboard, and that write fails. With `our_formats`, its copy also carries the app's formats +/// other than text. +pub(super) fn copy_during_next_write(board: &Rc>, mime: &'static str, data: Vec, our_formats: bool) { + let mut b = board.borrow_mut(); + b.race = Some(Flavour { mime, data }); + b.race_with_our_formats = our_formats; } pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {