From 8555ac42c467fb76efb256f29511a2c6448b8765 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 13:03:12 -0800 Subject: [PATCH] UI-1661: Use Windows clipboard ownership for Type paste --- apps/vectorcraft/src/clipboard.rs | 16 +++------- crates/ui-egui/src/panels/character.rs | 20 +++++++++---- crates/ui-egui/src/sysclip.rs | 6 ++++ crates/ui-egui/src/tests/clipboard.rs | 41 ++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 59b30b764..71d057215 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -176,8 +176,7 @@ mod win { use clipboard_win::{formats, options::NoClear, raw}; use super::{ - BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, - windows_formats, + BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, windows_formats, }; /// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the @@ -211,8 +210,6 @@ mod win { pub struct Clipboard { /// The clipboard's sequence number right after our last write. seq: Option, - /// The text of our last write. - text: Option, } impl SystemClipboard for Clipboard { @@ -243,18 +240,13 @@ mod win { } } self.seq = raw::seq_num(); - self.text = text_of(flavours); result } fn holds_ours(&mut self) -> bool { - if self.seq.is_some() && raw::seq_num() == self.seq { - return true; - } - // A clipboard manager may have copied our data again as its own. - let Some(ours) = self.text.as_deref() else { return false }; - let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false }; - read_one(TEXT).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes()) + // The same text copied later by another app is a different clipboard entry. A + // clipboard manager that republishes our data is also a new entry. + self.seq.is_some() && raw::seq_num() == self.seq } fn read(&mut self, mimes: &[&'static str]) -> Option { diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 3ae45588b..495ce2f11 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -590,18 +590,28 @@ fn text_key(app: &mut VectorcraftApp, k: ToolKey, m: Mods) { } fn text_paste(app: &mut VectorcraftApp, s: Option, plain: bool) { - let text_from_clip = CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone(); - let s = s.unwrap_or(text_from_clip); + let s = match s { + Some(text) => text, + None if app.services.system_clipboard.is_some() => app.written_clipboard_text().unwrap_or_default(), + None => CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone(), + }; if !s.is_empty() { - if plain { - app.session.set_tool_option("copy", &json!([])); - } else if let Some(runs) = CLIP_RUNS.lock().unwrap_or_else(|e| e.into_inner()).clone() { + let internal = app.type_paste_has_our_clipboard(); + let mut styled = false; + if !plain + && internal + && let Some(runs) = CLIP_RUNS.lock().unwrap_or_else(|e| e.into_inner()).clone() + { let clip_text: String = serde_json::from_value::>(runs.clone()).unwrap_or_default().into_iter().map(|r| r.text).collect(); if clip_text == s { app.session.set_tool_option("copy", &runs); + styled = true; } } + if !styled { + app.session.set_tool_option("copy", &json!([])); + } let v = app.view_info(); let target_before = crate::ime::target(&mut app.session, v); let before = crate::ime::stamp(&mut app.session, v); diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index f72339a3e..1f9f1f473 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -113,6 +113,12 @@ pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static } 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()) + } + /// After Copy or Cut: offer the copied objects to other apps. With the system clipboard /// service, the background worker makes the formats from a copy of the clipboard /// ([`background::run`]) and they are written when they are ready. While the worker has a diff --git a/crates/ui-egui/src/tests/clipboard.rs b/crates/ui-egui/src/tests/clipboard.rs index 9a7719430..1ab2a4dd8 100644 --- a/crates/ui-egui/src/tests/clipboard.rs +++ b/crates/ui-egui/src/tests/clipboard.rs @@ -232,6 +232,47 @@ fn paste_without_formatting_takes_surrounding_style() { assert!(t2.runs.iter().all(|r| r.style.size == 10.0), "all runs take 10 pt size: {:?}", t2.runs); } +#[test] +fn external_plain_text_matching_an_old_copy_uses_destination_style() { + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + let (mut app, board) = super::sysclip::app(); + 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 own_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": own_destination, "start": 5, "end": 5})); + crate::panels::character::intercept_text_command(&mut app, "edit.paste"); + let node = app.session.doc().unwrap().doc.node(NodeId(own_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"); + + // Another app later copies the same characters as plain text. Their style is unrelated. + super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Hello".to_vec())]); + let destination = + run(&mut app, "text.create", json!({"x": 20, "y": 80, "text": "Before ", "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": 7, "end": 7})); + super::sysclip::frame(&mut app, vec![egui::Event::Paste("Hello".into())]); + assert_eq!(app.system_clipboard_text().as_deref(), Some("Hello")); + app.session.select_tool("selection", v).unwrap(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": destination, "start": 12, "end": 12})); + assert!(app.session.tool_wants_text()); + assert!(crate::panels::character::intercept_text_command(&mut app, "edit.paste").is_some()); + + 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(), "Before HelloHello"); + assert!(t.runs.iter().all(|run| run.style.size == 12.0 && run.style.fill == Paint::solid(Color::rgb8(0, 0, 0)))); +} + /// https://helpx.adobe.com/illustrator/using/format-text.html #[test] fn copying_single_character_carries_formatting_across_documents() {