Merge pull request #5709 from dotCooCoo/fix/type-paste-refused-copy

Type: Paste keeps a copy the system clipboard refused
This commit is contained in:
BFlat
2026-11-05 07:30:07 +07:00
committed by GitHub
4 changed files with 282 additions and 6 deletions
+4
View File
@@ -249,6 +249,10 @@ mod win {
self.seq.is_some() && raw::seq_num() == self.seq
}
fn change_count(&mut self) -> Option<u64> {
raw::seq_num().map(|n| u64::from(n.get()))
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
self.read_deferred(mimes)?.flavour()
}
+78 -4
View File
@@ -34,6 +34,29 @@ pub trait SystemClipboard {
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {
self.read(mimes).map(ClipboardRead::Now)
}
/// A number that changes each time any app copies something, read without opening the
/// clipboard; `None` where the platform has none (the default).
fn change_count(&mut self) -> Option<u64> {
None
}
}
/// What the system clipboard held when the formats of a Copy or Cut could not be written.
#[derive(PartialEq)]
enum Held {
/// Its [`SystemClipboard::change_count`].
Count(u64),
/// Its text, where the platform has no change count.
Text(Option<Vec<u8>>),
}
impl Held {
fn now(cb: &mut (dyn SystemClipboard + 'static)) -> Self {
match cb.change_count() {
Some(n) => Self::Count(n),
None => Self::Text(cb.read(&[TEXT]).map(|f| f.data)),
}
}
}
/// What [`SystemClipboard::read_deferred`] read.
@@ -71,6 +94,9 @@ pub(crate) struct Publishing {
latest: Arc<AtomicU64>,
/// The latest one's formats wait to be written.
pending: bool,
/// The latest one's formats could not be written: what the system clipboard held when the
/// write failed, and the text of those formats.
unwritten: Option<(Held, String)>,
}
impl Publishing {
@@ -116,7 +142,20 @@ impl VectorcraftApp {
/// A Type paste may reuse its saved styled runs only while our desktop copy still owns the
/// clipboard. Pending formats also belong to that copy; web hosts have no ownership service.
pub(crate) fn type_paste_has_our_clipboard(&mut self) -> bool {
self.clipboard_publishing.pending || self.services.system_clipboard.as_mut().is_none_or(|cb| cb.holds_ours())
self.clipboard_publishing.pending
|| self.unwritten_clipboard_text().is_some()
|| self.services.system_clipboard.as_mut().is_none_or(|cb| cb.holds_ours())
}
/// 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.
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()?;
(Held::now(cb) == *held).then(|| text.clone())
}
/// After Copy or Cut: offer the copied objects to other apps. With the system clipboard
@@ -148,6 +187,9 @@ impl VectorcraftApp {
let latest = self.clipboard_publishing.latest.clone();
let n = latest.fetch_add(1, Ordering::SeqCst).wrapping_add(1);
self.clipboard_publishing.pending = true;
self.clipboard_publishing.unwritten = None;
// What the clipboard holds at the Copy or Cut: a copy made elsewhere before the write is newer.
let before = self.services.system_clipboard.as_deref_mut().map(Held::now);
let (tx, made) = channel();
let work = move |_: Writer| {
// These formats are not made when a later Copy or Cut, or a copy through egui, dropped them.
@@ -165,7 +207,18 @@ impl VectorcraftApp {
publishing.pending = false;
let formats = r.and_then(|_| made.try_recv().map_err(|e| e.to_string()));
let written = formats.and_then(|(f, warnings)| {
app.services.system_clipboard.as_mut().map_or(Ok(()), |cb| cb.write(&f))?;
let Some(cb) = app.services.system_clipboard.as_mut() else { return Ok(warnings) };
// Nothing copied elsewhere since the Copy or Cut.
let current = before.as_ref().is_some_and(|b| *b == Held::now(cb.as_mut()));
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));
return Err(e);
}
Ok(warnings)
});
match written {
@@ -196,6 +249,7 @@ impl VectorcraftApp {
let publishing = &mut self.clipboard_publishing;
publishing.latest.fetch_add(1, Ordering::SeqCst);
publishing.pending = false;
publishing.unwritten = None;
match self.services.system_clipboard.as_mut() {
Some(cb) => {
if let Err(e) = cb.write(&[]) {
@@ -217,6 +271,9 @@ impl VectorcraftApp {
pub(crate) fn drop_formats_after_egui_copy(&mut self, ctx: &egui::Context) {
let copied = ctx.output(|o| o.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_))));
let publishing = &mut self.clipboard_publishing;
if copied {
publishing.unwritten = None;
}
if copied && publishing.pending {
publishing.latest.fetch_add(1, Ordering::SeqCst);
publishing.pending = false;
@@ -241,6 +298,22 @@ impl VectorcraftApp {
let waited = ctx.data(|d| d.get_temp::<bool>(egui::Id::unique("clipboard.pending-at-tick"))).unwrap_or(false);
ctx.data_mut(|d| d.remove::<bool>(egui::Id::unique("clipboard.pending-at-tick")));
if !(self.clipboard_publishing.pending || waited) {
// A copy whose formats could not be written: a paste takes its text, not the text the
// clipboard kept ([`Self::unwritten_clipboard_text`]).
let pasting = textless || ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Paste(_))));
if pasting && let Some(text) = self.unwritten_clipboard_text() {
ctx.input_mut(|i| {
for e in &mut i.events {
if let egui::Event::Paste(t) = e {
t.clone_from(&text);
}
}
if textless {
i.events.insert(0, egui::Event::Paste(text));
}
});
return;
}
// The formats the clipboard still holds were written after the chord's key went down.
if textless
&& self.services.system_clipboard.as_mut().is_some_and(|cb| cb.holds_ours())
@@ -368,11 +441,12 @@ impl VectorcraftApp {
}
/// The text on the system clipboard once the formats a Copy or Cut is making are written, for a
/// paste into a text field or type.
/// paste into a text field or type. When they could not be written, their text
/// ([`Self::unwritten_clipboard_text`]).
pub(crate) fn written_clipboard_text(&mut self) -> Option<String> {
// Only other copies' formats are ahead of them on the worker ([`Self::publish_clipboard`]).
background::wait_until(self, |app| !app.clipboard_publishing.pending);
self.system_clipboard_text()
self.unwritten_clipboard_text().or_else(|| self.system_clipboard_text())
}
/// The text on the system clipboard, if any.
+159
View File
@@ -273,6 +273,165 @@ fn external_plain_text_matching_an_old_copy_uses_destination_style() {
assert!(t.runs.iter().all(|run| run.style.size == 12.0 && run.style.fill == Paint::solid(Color::rgb8(0, 0, 0))));
}
#[test]
fn type_paste_keeps_a_copy_the_system_clipboard_refused_until_another_app_copies() {
let _guard = TEST_CLIP_LOCK.lock().unwrap();
let (mut app, board) = super::sysclip::app();
super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]);
super::sysclip::lock(&board, true);
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();
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);
super::sysclip::lock(&board, false);
assert_eq!(app.system_clipboard_text().as_deref(), Some("Old"), "the write was refused");
let destination =
run(&mut app, "text.create", json!({"x": 20, "y": 60, "text": "Own: ", "size": 12, "color": "#000000"}))["id"].as_u64().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!() };
assert_eq!(t.plain_text(), "Own: Hello");
assert_eq!(t.runs.last().unwrap().style.size, 36.0, "our copy keeps its style");
// A paste event carries the text the clipboard kept.
app.session.select_tool("selection", v).unwrap();
app.session.select_tool("type", v).unwrap();
app.session.set_tool_option("textSelection", &json!({"id": destination, "start": 10, "end": 10}));
super::sysclip::frame(&mut app, vec![egui::Event::Paste("Old".into())]);
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: HelloHello");
// Another app copies, even the same text: Paste takes it, in the destination's style.
super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]);
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!() };
assert_eq!(t.plain_text(), "Own: OldHelloHello");
let old = t.runs.iter().find(|run| run.text.contains("Old")).unwrap();
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();
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);
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();
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);
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| {
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}));
crate::panels::character::intercept_text_command(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()
};
// The clipboard still can't be read, as when the write failed.
assert_eq!(paste_at(&mut app, 5), "Own: Hello");
// Its text read later may be another app's copy: Paste takes it.
super::sysclip::lock(&board, false);
assert_eq!(paste_at(&mut app, 10), "Own: HelloOld");
}
#[test]
fn a_paste_event_waiting_on_a_refused_copy_pastes_that_copy() {
let _guard = TEST_CLIP_LOCK.lock().unwrap();
// Another copy's formats hold the worker, so this Copy's formats wait.
let (mut app, board, hold) = super::sysclip::app_with_busy_worker("");
super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]);
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");
assert!(app.clipboard_publishing.is_pending());
// The paste comes while the formats wait, and the write then fails with the clipboard open.
super::sysclip::lock(&board, true);
drop(hold);
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}));
super::sysclip::frame(&mut app, vec![egui::Event::Paste("Old".into())]);
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");
}
#[test]
fn a_copy_elsewhere_before_a_refused_write_wins_the_type_paste() {
let _guard = TEST_CLIP_LOCK.lock().unwrap();
let (mut app, board, hold) = super::sysclip::app_with_busy_worker("");
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");
assert!(app.clipboard_publishing.is_pending());
// Another app copies while the formats wait, and keeps the clipboard open for the write.
super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"New".to_vec())]);
super::sysclip::lock(&board, true);
drop(hold);
crate::background::wait_all(&mut app);
super::sysclip::lock(&board, false);
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!() };
assert_eq!(t.plain_text(), "Own: New");
}
#[test]
fn type_paste_keeps_a_copy_whose_write_failed_after_emptying_the_clipboard() {
let _guard = TEST_CLIP_LOCK.lock().unwrap();
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);
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!() };
assert_eq!(t.plain_text(), "Own: Hello");
}
/// https://helpx.adobe.com/illustrator/using/format-text.html
#[test]
fn copying_single_character_carries_formatting_across_documents() {
+41 -2
View File
@@ -22,6 +22,15 @@ pub(super) struct Board {
ours: bool,
/// How many times the app wrote it.
writes: usize,
/// Another app has the clipboard open: the app's writes fail and change nothing, and reads
/// find nothing.
locked: bool,
/// Raised by every write and every copy elsewhere.
changes: u64,
/// The platform has no change count.
uncounted: bool,
/// The app's writes empty the clipboard and then fail.
partial: bool,
}
struct Fake(Rc<RefCell<Board>>);
@@ -29,6 +38,9 @@ struct Fake(Rc<RefCell<Board>>);
impl Fake {
fn find(&self, mimes: &[&'static str]) -> Option<Flavour> {
let b = self.0.borrow();
if b.locked {
return None;
}
let matches = |m: &str, f: &Flavour| f.mime == m || (m == BITMAP && f.mime.starts_with("image/") && f.mime != SVG);
mimes.iter().find_map(|m| b.flavours.iter().find(|f| matches(m, f)).cloned())
}
@@ -37,9 +49,21 @@ impl Fake {
impl SystemClipboard for Fake {
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
let mut b = self.0.borrow_mut();
*b = Board { flavours: flavours.to_vec(), ours: true, writes: b.writes + 1 };
if b.locked {
return Err("another app has the clipboard open".into());
}
if b.partial {
b.flavours.clear();
b.changes += 1;
return Err("a format could not be written".into());
}
*b = Board { flavours: flavours.to_vec(), ours: true, writes: b.writes + 1, changes: b.changes + 1, ..std::mem::take(&mut *b) };
Ok(())
}
fn change_count(&mut self) -> Option<u64> {
let b = self.0.borrow();
(!b.uncounted).then_some(b.changes)
}
fn holds_ours(&mut self) -> bool {
self.0.borrow().ours
}
@@ -66,7 +90,22 @@ pub(super) fn app() -> (VectorcraftApp, Rc<RefCell<Board>>) {
pub(super) fn copy_elsewhere(board: &Rc<RefCell<Board>>, flavours: Vec<(&'static str, Vec<u8>)>) {
let flavours = flavours.into_iter().map(|(mime, data)| Flavour { mime, data }).collect();
let mut b = board.borrow_mut();
*b = Board { flavours, ours: false, writes: b.writes };
*b = Board { flavours, ours: false, changes: b.changes + 1, ..std::mem::take(&mut *b) };
}
/// Another app opens the clipboard (`true`) or closes it.
pub(super) fn lock(board: &Rc<RefCell<Board>>, locked: bool) {
board.borrow_mut().locked = locked;
}
/// The fake clipboard reports no change count, as on platforms that have none.
pub(super) fn without_change_count(board: &Rc<RefCell<Board>>) {
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<RefCell<Board>>) {
board.borrow_mut().partial = true;
}
pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {