From 4e00d84404d507165589808ca3d5bdf9c5df8a61 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 17:42:46 -0800 Subject: [PATCH] Type: a refused copy is dropped when a textless clipboard can't track it On a platform without a clipboard change count (macOS, Linux), a Type copy that the system clipboard refused is kept only when the clipboard held text when the write failed. A clipboard with no text, one that couldn't be read, or one holding a picture can't show that another app copied something later, so Paste no longer inserts the earlier copy there. --- crates/ui-egui/src/sysclip.rs | 11 ++++--- crates/ui-egui/src/tests/clipboard.rs | 46 +++++++++++++++++++++------ crates/ui-egui/src/tests/sysclip.rs | 19 ++++++++++- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index b68faa3cf..ac382547d 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -149,9 +149,9 @@ impl VectorcraftApp { /// The text of the latest Copy or Cut whose formats could not be written to the system /// clipboard, while nothing has been copied since that Copy or Cut. A paste into type or a text - /// field takes this text until another app copies something. Where the platform has no change count, a - /// copy is noticed only when it changes the clipboard's text, and once a clipboard whose text - /// could not be read when the write failed has text, Paste takes that text. + /// field takes this text until another app copies something. Where the platform has no change + /// count, a copy is noticed only when it changes the clipboard's text, so the copy is kept only + /// when the clipboard held text when the write failed. pub(crate) fn unwritten_clipboard_text(&mut self) -> Option { let cb = self.services.system_clipboard.as_deref_mut()?; let (held, text) = self.clipboard_publishing.unwritten.as_ref()?; @@ -226,7 +226,10 @@ impl VectorcraftApp { 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)); + // Without a change count, a clipboard with no text can't show a later copy of + // a picture, or tell an empty clipboard from one that couldn't be read. + let trackable = held != Held::Text(None); + app.clipboard_publishing.unwritten = text.filter(|_| current && left_by_write && trackable).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 cd28ffe67..77ff0149b 100644 --- a/crates/ui-egui/src/tests/clipboard.rs +++ b/crates/ui-egui/src/tests/clipboard.rs @@ -320,13 +320,16 @@ fn type_paste_keeps_a_copy_the_system_clipboard_refused_until_another_app_copies assert_eq!(old.style.size, 12.0, "another app's text takes the destination's style"); } -#[test] -fn type_paste_without_a_change_count_keeps_a_refused_copy_while_the_text_is_unchanged() { - let _guard = TEST_CLIP_LOCK.lock().unwrap(); +/// An app whose fake clipboard has no change count and holds `held`; "Hello" (36 pt) copied in the +/// Type tool with `refuse` set up by `before_copy`; and a paste at `at` into "Own: " (12 pt). +fn uncounted_refused_copy( + held: (&'static str, Vec), + before_copy: impl FnOnce(&std::rc::Rc>), +) -> (VectorcraftApp, std::rc::Rc>, impl Fn(&mut VectorcraftApp, usize) -> String) { let (mut app, board) = super::sysclip::app(); super::sysclip::without_change_count(&board); - super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]); - super::sysclip::lock(&board, true); + super::sysclip::copy_elsewhere(&board, vec![held]); + before_copy(&board); let source = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Hello", "size": 36, "color": "#ff0000"}))["id"].as_u64().unwrap(); let v = app.view_info(); app.session.select_tool("type", v).unwrap(); @@ -336,7 +339,7 @@ fn type_paste_without_a_change_count_keeps_a_refused_copy_while_the_text_is_unch let destination = run(&mut app, "text.create", json!({"x": 20, "y": 60, "text": "Own: ", "size": 12, "color": "#000000"}))["id"].as_u64().unwrap(); - let paste_at = |app: &mut VectorcraftApp, at: usize| { + let paste_at = move |app: &mut VectorcraftApp, at: usize| { app.session.select_tool("selection", v).unwrap(); app.session.select_tool("type", v).unwrap(); app.session.set_tool_option("textSelection", &json!({"id": destination, "start": at, "end": at})); @@ -345,11 +348,36 @@ fn type_paste_without_a_change_count_keeps_a_refused_copy_while_the_text_is_unch let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!() }; t.plain_text() }; - // The clipboard still can't be read, as when the write failed. + (app, board, paste_at) +} + +#[test] +fn type_paste_without_a_change_count_keeps_a_refused_copy_while_the_text_is_unchanged() { + use vectorcraft_engine::cmd::clipboard::TEXT; + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + let (mut app, board, paste_at) = uncounted_refused_copy((TEXT, b"Old".to_vec()), |b| super::sysclip::refuse_writes(b, true)); assert_eq!(paste_at(&mut app, 5), "Own: Hello"); - // Its text read later may be another app's copy: Paste takes it. + super::sysclip::copy_elsewhere(&board, vec![(TEXT, b"New".to_vec())]); + assert_eq!(paste_at(&mut app, 5), "Own: NewHello"); +} + +#[test] +fn type_paste_without_a_change_count_drops_a_refused_copy_it_cannot_track() { + use vectorcraft_engine::cmd::clipboard::{PNG, TEXT}; + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + // The clipboard can't be read when the write fails: its text read later may be another app's. + let (mut app, board, paste_at) = uncounted_refused_copy((TEXT, b"Old".to_vec()), |b| super::sysclip::lock(b, true)); + assert_eq!(paste_at(&mut app, 5), "Own: "); super::sysclip::lock(&board, false); - assert_eq!(paste_at(&mut app, 10), "Own: HelloOld"); + assert_eq!(paste_at(&mut app, 5), "Own: Old"); + + // The clipboard holds a picture, and another app copies a different picture: no text pastes. + let (mut app, board, paste_at) = uncounted_refused_copy((PNG, super::sysclip::blue_png()), |b| { + super::sysclip::has_text_only(b); + super::sysclip::refuse_writes(b, true); + }); + super::sysclip::copy_elsewhere(&board, vec![(PNG, super::sysclip::blue_png().into_iter().rev().collect())]); + assert_eq!(paste_at(&mut app, 5), "Own: "); } #[test] diff --git a/crates/ui-egui/src/tests/sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs index e5eca1e8d..595d12b51 100644 --- a/crates/ui-egui/src/tests/sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -38,6 +38,10 @@ pub(super) struct Board { race: Option, /// With `race`, the other app's copy also carries the app's formats other than text. race_with_our_formats: bool, + /// The app's writes fail and change nothing; reads still work. + refuse: bool, + /// `has` finds text only, as the Linux clipboard's does. + text_only_has: bool, } struct Fake(Rc>); @@ -56,7 +60,7 @@ impl Fake { impl SystemClipboard for Fake { fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { let mut b = self.0.borrow_mut(); - if b.locked { + if b.locked || b.refuse { return Err("another app has the clipboard open".into()); } if let Some(copied) = b.race.take() { @@ -87,6 +91,9 @@ impl SystemClipboard for Fake { self.find(mimes) } fn has(&mut self, mimes: &[&'static str]) -> bool { + if self.0.borrow().text_only_has { + return mimes.contains(&TEXT) && self.find(&[TEXT]).is_some(); + } self.find(mimes).is_some() } } @@ -119,6 +126,16 @@ pub(super) fn without_change_count(board: &Rc>) { board.borrow_mut().uncounted = true; } +/// The fake clipboard's `has` finds text only, as the Linux clipboard's does. +pub(super) fn has_text_only(board: &Rc>) { + board.borrow_mut().text_only_has = true; +} + +/// The app's writes fail (`true`) and change nothing, while reads still work. +pub(super) fn refuse_writes(board: &Rc>, refuse: bool) { + board.borrow_mut().refuse = refuse; +} + /// 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) {