Guide clipboard: Paste after a guides-only Copy takes the guides, not older system clipboard content

A Copy of ruler guides alone left the system clipboard as it was, so the
next Paste took whatever another app had copied before instead of the
guides. The guides-only Copy now empties the system clipboard (on the
web, empty text through egui with Copy As SVG on), as any Copy replaces
what the clipboard held, and drops an earlier Copy's formats not written
yet. What another app copies afterwards still pastes.
This commit is contained in:
Don Omondi
2026-11-04 20:12:01 +07:00
committed by bflatastic
parent ad64852255
commit ed634a78f4
8 changed files with 137 additions and 9 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ impl Clipboard {
}
/// Does it hold art (objects or artboards) for other apps? Ruler guides stay in VectorCraft:
/// guides alone put nothing on the system clipboard (#3226).
/// a Copy of guides alone empties the system clipboard (#3226).
pub fn has_art(&self) -> bool {
!self.nodes.is_empty() || !self.artboards.is_empty()
}
+1 -1
View File
@@ -101,7 +101,7 @@ fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() {
run(&mut s, "edit.copy", json!({}));
// Guides alone: no art for the system clipboard's SVG.
assert!(s.clipboard_svg().is_none(), "guides alone put no SVG on the system clipboard");
assert!(s.clipboard.flavour_types(true, true).is_empty(), "nor any other format (it keeps what other apps put there)");
assert!(s.clipboard.flavour_types(true, true).is_empty(), "nor any other format (the app empties the system clipboard)");
let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
run(&mut s, "edit.paste", json!({}));
assert_eq!(s.doc().unwrap().selection.guides, [1], "the first paste selects its own guide");
+26 -2
View File
@@ -124,9 +124,11 @@ impl VectorcraftApp {
/// ([`Self::write_formats_before_text_input`]). Once they are written, the status bar shows
/// the first of the PNG's and PDF's warnings.
pub(crate) fn publish_clipboard(&mut self) {
// Ruler guides copied alone stay in VectorCraft: the system clipboard keeps what other
// apps put there (#3226).
// Ruler guides copied alone stay in VectorCraft, but the Copy still replaces what the
// system clipboard held: it is emptied, so the next Paste takes the guides rather than
// what another app copied before, and what another app copies after still wins (#3226).
if !self.session.clipboard.has_art() {
self.clear_system_clipboard();
return;
}
if self.services.system_clipboard.is_none() {
@@ -181,6 +183,28 @@ impl VectorcraftApp {
}
}
/// A Copy with nothing for other apps (ruler guides alone): the formats of an earlier Copy or
/// Cut not written yet are dropped and the system clipboard is emptied. On the web, empty text
/// goes through egui when Copy As SVG is on, in place of the SVG objects would put there.
fn clear_system_clipboard(&mut self) {
let publishing = &mut self.clipboard_publishing;
publishing.latest.fetch_add(1, Ordering::SeqCst);
publishing.pending = false;
match self.services.system_clipboard.as_mut() {
Some(cb) => {
if let Err(e) = cb.write(&[]) {
self.ui.status = format!("Couldn't copy to the system clipboard: {e}");
}
}
None => {
if self.session.prefs.copy_as_svg {
self.clipboard_out = Some(String::new());
self.clipboard_published = None;
}
}
}
}
/// End of a frame: when something copied text or a picture through egui this frame (a text
/// field, the Type tool), the formats of an earlier Copy or Cut that are not written yet are
/// dropped, and the system clipboard keeps that copy.
+1
View File
@@ -48,6 +48,7 @@ mod svgsave;
pub(super) mod synthetic;
pub(super) mod sysclip;
mod sysclip_emf;
mod sysclip_guides;
mod sysclip_probe;
mod tab_overflow;
mod taskbar;
+4 -4
View File
@@ -80,7 +80,7 @@ pub(super) fn copy_rect(app: &mut VectorcraftApp) {
run(app, "edit.copy", json!({}));
}
fn written(board: &Rc<RefCell<Board>>) -> Vec<&'static str> {
pub(super) fn written(board: &Rc<RefCell<Board>>) -> Vec<&'static str> {
board.borrow().flavours.iter().map(|f| f.mime).collect()
}
@@ -90,7 +90,7 @@ pub(super) fn pasted(app: &VectorcraftApp) -> Vec<Node> {
st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect()
}
fn count(app: &VectorcraftApp) -> usize {
pub(super) fn count(app: &VectorcraftApp) -> usize {
app.session.active().unwrap().doc.layers[0].children().unwrap().len()
}
@@ -152,7 +152,7 @@ pub(crate) fn blue_png() -> Vec<u8> {
/// One headless frame of app logic (shortcuts too, from the second frame on) delivering `events`,
/// in the test's one context (in a new one the app starts its fonts over).
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
pub(super) fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
thread_local! {
static CTX: egui::Context = egui::Context::default();
}
@@ -175,7 +175,7 @@ fn app_with_worker() -> (VectorcraftApp, Rc<RefCell<Board>>) {
/// [`app_with_worker`], its worker busy with a job labeled `label` (none: another Copy's formats)
/// until the sender is dropped, or for 10 s.
fn app_with_busy_worker(label: &str) -> (VectorcraftApp, Rc<RefCell<Board>>, Sender<()>) {
pub(super) fn app_with_busy_worker(label: &str) -> (VectorcraftApp, Rc<RefCell<Board>>, Sender<()>) {
let (mut app, board) = app_with_worker();
let hold = hold_worker(&mut app, label);
(app, board, hold)
+102
View File
@@ -0,0 +1,102 @@
//! Ruler guides copied alone and the system clipboard (#3226 review): a guides-only Copy empties
//! the system clipboard, as any Copy replaces what it held, so the next Paste takes the guides and
//! not what another app copied before. What another app copies after it still wins.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::clipboard::{PNG, TEXT};
use crate::tests_sysclip::{app, app_with_busy_worker, copy_elsewhere, copy_rect, count, frame, pasted, run, written};
use crate::{Services, VectorcraftApp, background};
fn guides(app: &VectorcraftApp) -> usize {
app.session.active().unwrap().doc.guides.len()
}
/// Add a vertical guide at `pos`, select only it and copy it.
fn copy_guide(app: &mut VectorcraftApp, pos: f64) {
run(app, "guide.add", json!({"vertical": true, "pos": pos}));
let last = guides(app) - 1;
run(app, "select.none", json!({}));
run(app, "guide.select", json!({"indexes": [last]}));
run(app, "edit.copy", json!({}));
}
#[test]
fn a_guides_only_copy_pastes_the_guides_not_what_another_app_copied_before() {
let (mut app, board) = app();
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec()), (PNG, vec![1, 2, 3])]);
copy_guide(&mut app, 120.0);
assert!(written(&board).is_empty(), "the Copy emptied the system clipboard: {:?}", written(&board));
run(&mut app, "edit.paste", json!({}));
assert_eq!(guides(&app), 2, "the copied guide was pasted");
assert_eq!(count(&app), 0, "and not the other app's text");
assert_eq!(app.session.active().unwrap().selection.guides, [1]);
// A second Paste takes the guides again.
run(&mut app, "edit.paste", json!({}));
assert_eq!((guides(&app), count(&app)), (3, 0));
}
#[test]
fn what_another_app_copies_after_a_guides_only_copy_is_pasted() {
let (mut app, board) = app();
copy_guide(&mut app, 120.0);
copy_elsewhere(&board, vec![(TEXT, b"Copied later".to_vec())]);
run(&mut app, "edit.paste", json!({}));
let NodeKind::Text(t) = &pasted(&app)[0].kind else { panic!("not type") };
assert_eq!(t.plain_text(), "Copied later");
assert_eq!(guides(&app), 1, "no guide pasted");
}
#[test]
fn objects_copied_after_guides_paste_as_objects() {
let (mut app, board) = app();
copy_guide(&mut app, 120.0);
run(&mut app, "select.none", json!({}));
copy_rect(&mut app);
assert!(!written(&board).is_empty(), "objects go to the system clipboard");
run(&mut app, "edit.paste", json!({}));
assert!(matches!(pasted(&app)[0].kind, NodeKind::Path { .. }));
assert_eq!(guides(&app), 1, "the earlier guides-only copy is replaced");
}
/// A guides-only Copy right after an object Copy whose formats are still being made: those formats
/// are dropped, so they don't land on the system clipboard after it and replace the guides.
#[test]
fn a_guides_only_copy_drops_an_earlier_copys_formats_not_written_yet() {
let (mut app, board, hold) = app_with_busy_worker("");
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec())]);
copy_rect(&mut app);
copy_guide(&mut app, 120.0);
drop(hold);
background::wait_all(&mut app);
assert!(written(&board).is_empty(), "{:?}", written(&board));
let objects = count(&app);
run(&mut app, "edit.paste", json!({}));
assert_eq!((guides(&app), count(&app)), (2, objects), "the guide, not the rectangle or the text");
}
/// The web (no clipboard service): a guides-only Copy puts empty text on the clipboard through
/// egui, in place of the SVG an object Copy puts there, so another app's SVG copied before is not
/// pasted instead of the guides.
#[test]
fn on_the_web_a_guides_only_copy_empties_the_clipboard_text() {
let browser = Rc::new(RefCell::new(r#"<svg xmlns="http://www.w3.org/2000/svg"><rect width="9" height="9"/></svg>"#.to_string()));
let read = browser.clone();
let services = Services { clipboard_read: Some(Box::new(move || Some(read.borrow().clone()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services);
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
copy_guide(&mut app, 120.0);
let out = frame(&mut app, vec![]);
let copied: Vec<&String> =
out.platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t) } else { None }).collect();
assert_eq!(copied, [""], "empty text replaces the browser's clipboard");
// The browser now holds that empty text.
browser.borrow_mut().clear();
run(&mut app, "edit.paste", json!({}));
assert_eq!((guides(&app), count(&app)), (2, 0), "the guide, not the other app's SVG");
}
+1 -1
View File
@@ -1596,7 +1596,7 @@ With area type selected, the Properties panel's Paragraph section and the Contro
## Copy and paste ruler guides (2026-11-01)
Edit › Copy and Cut put the selected ruler guides on the clipboard, with the selected objects or on their own, so they paste into another artboard or document (#3226). Pasted guides go on the layer new art goes to and are selected; with objects they move as the objects do, and alone they keep their places, while Paste in Place, in Front and in Back put them on the active artboard as they lay on theirs and Paste on All Artboards on every artboard. Tests: `tests::guide_clipboard`.
Edit › Copy and Cut put the selected ruler guides on the clipboard, with the selected objects or on their own, so they paste into another artboard or document (#3226). Pasted guides go on the layer new art goes to and are selected; with objects they move as the objects do, and alone they keep their places, while Paste in Place, in Front and in Back put them on the active artboard as they lay on theirs and Paste on All Artboards on every artboard. Guides have no format other apps read, so a Copy of guides alone empties the system clipboard, and the next Paste takes the guides rather than what another app copied before. Tests: `tests::guide_clipboard`, `tests::sysclip_guides` (ui-egui).
## Revision history
+1
View File
@@ -430,6 +430,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| 2026-11-01 | minor | Live Corners: while a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the corners' kind (round, inverted round, chamfer) with the radius, as one undo step (#2898); scores and estimates unchanged |
| 2026-11-01 | minor | Keyboard Shortcuts: every Effect menu item (Distort & Transform › Transform…, Stylize › Drop Shadow…, …) is listed under its menu path and takes a shortcut of its own, which the menu shows and which opens that effect's dialog (or applies an effect without options) (#3296); scores and estimates unchanged |
| 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; scores and estimates unchanged |
| 2026-11-01 | minor | Edit › Copy and Cut take the selected ruler guides, with the objects or alone; pasting puts them on the layer new art goes to, moved as the objects are (alone: where they were), Paste in Place onto the active artboard and Paste on All Artboards onto each; a Copy of guides alone empties the system clipboard, so Paste takes the guides, not what another app copied before (#3226); scores and estimates unchanged |
| 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); scores and estimates unchanged |
| 2026-11-01 | minor | Artboards panel: a row dragged above or below another reorders the artboards (#4687); scores and estimates unchanged |
| 2026-11-01 | minor | Object › Plug-ins › Color Effects › Create Swatch Legend…: a dialog that draws labelled chips of the colour swatches (all, or the ones selected in the Swatches panel) as one group (#4919); scores and estimates unchanged |