From 59a3cad1dc4fa36126783d04726ab258ad6542ae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 14:24:38 -0800 Subject: [PATCH] Type: Paste keeps a copy the system clipboard refused When a Copy in the Type tool cannot be written to the system clipboard (another app holds it open, or a format fails after the clipboard was emptied), Paste into type or a text field now inserts that copy, with its style in type, instead of the text the clipboard kept. This covers Edit > Paste, the paste key, and a paste that arrives while the copy is still being written. When another app copies something, before or after the failed write, Paste takes that. The new SystemClipboard::change_count reports the clipboard's change count (Windows' sequence number). Where a platform has none, a change of the clipboard's text marks a new copy. --- apps/vectorcraft/src/clipboard.rs | 4 + crates/ui-egui/src/sysclip.rs | 82 ++++++++++++- crates/ui-egui/src/tests/clipboard.rs | 159 ++++++++++++++++++++++++++ crates/ui-egui/src/tests/sysclip.rs | 43 ++++++- 4 files changed, 282 insertions(+), 6 deletions(-) diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 71d057215..1c0b4cd3c 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -249,6 +249,10 @@ mod win { self.seq.is_some() && raw::seq_num() == self.seq } + fn change_count(&mut self) -> Option { + raw::seq_num().map(|n| u64::from(n.get())) + } + fn read(&mut self, mimes: &[&'static str]) -> Option { self.read_deferred(mimes)?.flavour() } diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index 1f9f1f473..3cbdf2c9e 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -34,6 +34,29 @@ pub trait SystemClipboard { fn read_deferred(&mut self, mimes: &[&'static str]) -> Option { 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 { + 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>), +} + +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, /// 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 { + 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::(egui::Id::unique("clipboard.pending-at-tick"))).unwrap_or(false); ctx.data_mut(|d| d.remove::(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 { // 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. diff --git a/crates/ui-egui/src/tests/clipboard.rs b/crates/ui-egui/src/tests/clipboard.rs index 1ab2a4dd8..c4f0e3a74 100644 --- a/crates/ui-egui/src/tests/clipboard.rs +++ b/crates/ui-egui/src/tests/clipboard.rs @@ -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() { diff --git a/crates/ui-egui/src/tests/sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs index 3266e6ba1..40d81c4e8 100644 --- a/crates/ui-egui/src/tests/sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -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>); @@ -29,6 +38,9 @@ struct Fake(Rc>); impl Fake { fn find(&self, mimes: &[&'static str]) -> Option { 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 { + 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>) { pub(super) fn copy_elsewhere(board: &Rc>, flavours: Vec<(&'static str, Vec)>) { 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>, 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>) { + 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; } pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {