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.
This commit is contained in:
dotCooCoo
2026-11-05 08:50:33 +07:00
committed by bflatastic
parent 2b9c7995a5
commit 4e00d84404
3 changed files with 62 additions and 14 deletions
+7 -4
View File
@@ -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<String> {
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)
+37 -9
View File
@@ -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<u8>),
before_copy: impl FnOnce(&std::rc::Rc<std::cell::RefCell<super::sysclip::Board>>),
) -> (VectorcraftApp, std::rc::Rc<std::cell::RefCell<super::sysclip::Board>>, 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]
+18 -1
View File
@@ -38,6 +38,10 @@ pub(super) struct Board {
race: Option<Flavour>,
/// 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<RefCell<Board>>);
@@ -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<RefCell<Board>>) {
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<RefCell<Board>>) {
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<RefCell<Board>>, 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<RefCell<Board>>, keep_others: bool) {