diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index 3cbdf2c9e..b68faa3cf 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -208,15 +208,25 @@ impl VectorcraftApp { let formats = r.and_then(|_| made.try_recv().map_err(|e| e.to_string())); let written = formats.and_then(|(f, warnings)| { let Some(cb) = app.services.system_clipboard.as_mut() else { return Ok(warnings) }; + let before_write = Held::now(cb.as_mut()); // Nothing copied elsewhere since the Copy or Cut. - let current = before.as_ref().is_some_and(|b| *b == Held::now(cb.as_mut())); + let current = before.as_ref() == Some(&before_write); 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)); + // A change during the write is the failed write emptying the clipboard and + // writing some of the formats, or another app's copy: the clipboard then holds + // nothing to paste, this copy's text, or no text and one of this copy's + // formats only after the former. + let left_by_write = held == before_write + || match cb.read(&[TEXT]) { + 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)); return Err(e); } Ok(warnings) diff --git a/crates/ui-egui/src/tests/clipboard.rs b/crates/ui-egui/src/tests/clipboard.rs index c4f0e3a74..cd28ffe67 100644 --- a/crates/ui-egui/src/tests/clipboard.rs +++ b/crates/ui-egui/src/tests/clipboard.rs @@ -411,9 +411,17 @@ fn a_copy_elsewhere_before_a_refused_write_wins_the_type_paste() { #[test] fn type_paste_keeps_a_copy_whose_write_failed_after_emptying_the_clipboard() { let _guard = TEST_CLIP_LOCK.lock().unwrap(); + assert_eq!(paste_after_a_write_that_failed_after_emptying(false), "Own: Hello"); + // The formats other than text were written before the failure. + assert_eq!(paste_after_a_write_that_failed_after_emptying(true), "Own: Hello"); +} + +/// Copy "Hello" in the Type tool with a write that empties the clipboard and fails (`keep_others`: +/// after writing the formats other than text), then paste into "Own: ". +fn paste_after_a_write_that_failed_after_emptying(keep_others: bool) -> String { 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); + super::sysclip::fail_writes_after_emptying(&board, keep_others); 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(); @@ -429,7 +437,42 @@ fn type_paste_keeps_a_copy_whose_write_failed_after_emptying_the_clipboard() { 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"); + t.plain_text() +} + +#[test] +fn a_copy_elsewhere_during_a_refused_write_wins_the_type_paste() { + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + use vectorcraft_engine::cmd::clipboard::{PNG, TEXT}; + assert_eq!(paste_after_a_copy_during_a_refused_write(TEXT, b"Race".to_vec(), false), "Own: Race"); + // Its other formats may match this copy's; its text still differs. + assert_eq!(paste_after_a_copy_during_a_refused_write(TEXT, b"Race".to_vec(), true), "Own: Race"); + // A picture has no text to paste into type. + assert_eq!(paste_after_a_copy_during_a_refused_write(PNG, super::sysclip::blue_png(), false), "Own: "); +} + +/// Copy in the Type tool while another app copies `data` of type `mime` (with `our_formats`, also +/// the copy's formats other than text) and the write fails, then paste into "Own: ". +fn paste_after_a_copy_during_a_refused_write(mime: &'static str, data: Vec, our_formats: bool) -> String { + let (mut app, board) = super::sysclip::app(); + super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]); + super::sysclip::copy_during_next_write(&board, mime, data, our_formats); + 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!() }; + t.plain_text() } /// https://helpx.adobe.com/illustrator/using/format-text.html diff --git a/crates/ui-egui/src/tests/sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs index 40d81c4e8..e5eca1e8d 100644 --- a/crates/ui-egui/src/tests/sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -31,6 +31,13 @@ pub(super) struct Board { uncounted: bool, /// The app's writes empty the clipboard and then fail. partial: bool, + /// With `partial`, the formats other than text are written before the failure. + partial_keeps_others: bool, + /// Another app copies this while the app's write tries to open the clipboard, and the write + /// fails. + race: Option, + /// With `race`, the other app's copy also carries the app's formats other than text. + race_with_our_formats: bool, } struct Fake(Rc>); @@ -52,8 +59,17 @@ impl SystemClipboard for Fake { if b.locked { return Err("another app has the clipboard open".into()); } + if let Some(copied) = b.race.take() { + b.flavours = vec![copied]; + if b.race_with_our_formats { + b.flavours.extend(flavours.iter().filter(|f| f.mime != TEXT).cloned()); + } + b.ours = false; + b.changes += 1; + return Err("another app has the clipboard open".into()); + } if b.partial { - b.flavours.clear(); + b.flavours = if b.partial_keeps_others { flavours.iter().filter(|f| f.mime != TEXT).cloned().collect() } else { vec![] }; b.changes += 1; return Err("a format could not be written".into()); } @@ -103,9 +119,21 @@ 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; +/// 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) { + let mut b = board.borrow_mut(); + b.partial = true; + b.partial_keeps_others = keep_others; +} + +/// Another app copies `data` of type `mime` while the app's next write tries to open the +/// clipboard, and that write fails. With `our_formats`, its copy also carries the app's formats +/// other than text. +pub(super) fn copy_during_next_write(board: &Rc>, mime: &'static str, data: Vec, our_formats: bool) { + let mut b = board.borrow_mut(); + b.race = Some(Flavour { mime, data }); + b.race_with_our_formats = our_formats; } pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {