From 609d8836a247d4ca0a02e85631555230c87938e2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:06:20 -0800 Subject: [PATCH] Place: import options a cursor click asks for place within the cursor's roots The OK of the import options a click of the place cursor asks for now places the file within the automation roots the cursor was loaded in, or within the roots of a caller that confirms with roots of its own. Options from a click before the cursor was loaded again are refused. A cursor loaded again (a queue set with tool.setOption, a script) no longer asks for import options or keeps the queued sizes of the cursor the Place dialog loaded. The engine counts cursor loads (Session::place_queue_loads) so the app can tell. --- crates/engine/src/lib.rs | 2 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/tooling.rs | 8 +++ crates/ui-egui/src/place.rs | 52 +++++++++++++- crates/ui-egui/src/tests/place.rs | 95 ++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 890f8622f8..df62ddb318 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -244,6 +244,8 @@ pub struct Session { /// keyboard: those files, and the files they link to, are read under these roots, so a queue /// an agent loaded never reads beyond what it was given. pub(crate) place_queue_roots: Option>, + /// How many times the place cursor has been loaded ([`Session::place_queue_loads`]). + pub(crate) place_queue_loads: u64, /// While [`Session::import_image_read`] runs `clipboard.importImage`: the image read for it, /// which the command loads instead of reading `dataBase64`; `None` otherwise. pub(crate) image_hand_off: Option, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b54333206f..b2836d159c 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -113,6 +113,7 @@ impl Session { place_hand_off: None, queue_hand_off: None, place_queue_roots: None, + place_queue_loads: 0, image_hand_off: None, trace_hand_off: None, place_cursor_requests: false, diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index f6bc163e65..4f7e85cd99 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1256,6 +1256,14 @@ impl Session { /// is placed under ([`Session::place_queue_roots`]). fn note_place_queue_roots(&mut self) { self.place_queue_roots = crate::file_access::current(); + self.place_queue_loads = self.place_queue_loads.wrapping_add(1); + } + + /// How many times the place cursor has been loaded (`file.place.queue`, or a `queue` set on + /// the Place tool): a frontend that keeps its own state about the loaded cursor compares it to + /// tell a newer load. + pub fn place_queue_loads(&self) -> u64 { + self.place_queue_loads } /// The automation roots the place cursor's files are placed under: those in force when it diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 3205446c89..82a5d7c6f7 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -74,8 +74,20 @@ pub struct PlaceState { queue_asks: bool, /// The loaded cursor's files by name, with the size a click places them at. queue_sizes: Vec<(String, f64, f64)>, + /// The engine's count of cursor loads ([`vectorcraft_engine::Session::place_queue_loads`]) when + /// `queue_asks` and `queue_sizes` were set: a later load (`tool.setOption`, a script) clears them. + queue_load: u64, + /// The import options dialog a click of the cursor opened (the token in its params' + /// [`ASKED`]), with the automation roots the cursor was loaded in: its OK, pressed outside any + /// roots, places the file within them. Cleared when the cursor is loaded again. + asked: Option<(u64, std::sync::Arc)>, } +/// The `file.place` param that ties an import options dialog's OK to the click of the place cursor +/// that opened it ([`PlaceState::asked`]); hidden from the dialog's fields and removed before the +/// file is placed. +const ASKED: &str = "__placeCursorAsk"; + /// A place cursor thumbnail: decoded when the cursor is loaded, uploaded when first drawn (and /// again in a new graphics context). struct Thumb { @@ -137,6 +149,20 @@ enum Read { } fn place_file(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result { + // The OK of import options a click of the place cursor asked for places within the roots the + // cursor was loaded in. A caller with roots of its own places within those. + let Some(token) = p.get(ASKED) else { return place_file_in(app, p, read) }; + forget_older_queue(app); + let Some(roots) = app.place.asked.as_ref().filter(|(t, _)| Some(*t) == token.as_u64()).map(|(_, r)| r.clone()) else { + return Err("the place cursor that asked for these import options is no longer loaded".into()); + }; + if vectorcraft_engine::file_access::current().is_some() { + return place_file_in(app, p, read); + } + vectorcraft_engine::file_access::confine(Some(&roots), || place_file_in(app, p, read)) +} + +fn place_file_in(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result { let show = match p.get("showImportOptions") { None | Some(Value::Null) => None, Some(Value::Bool(b)) => Some(*b), @@ -188,6 +214,9 @@ fn place_file(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result { let r = crate::placing::start(app, &p, dropped); @@ -425,10 +454,19 @@ pub(crate) fn place_from_cursor(app: &mut VectorcraftApp, p: &Value) { // Read within the automation roots of whoever loaded the cursor, even on a click of the person // at the keyboard: an agent's queue reaches no file it wasn't given. let roots = app.session.place_queue_roots(); + forget_older_queue(app); vectorcraft_engine::file_access::confine(roots.as_ref(), || { if app.place.queue_asks { + let mut p = clicked_at_full_size(app, p); + // The import options it may ask for keep the token, and their OK places within the + // same roots ([`place_file`]). + app.place.asked = roots.clone().map(|r| { + static TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let token = TOKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + p[ASKED] = json!(token); + (token, r) + }); // Placing shows its own errors in the status bar. - let p = clicked_at_full_size(app, p); let _ = place_file(app, &p, Read::ForUser { dropped: false }); } else if crate::placing::available(app) { if let Err(e) = crate::placing::start(app, p, false) { @@ -440,6 +478,16 @@ pub(crate) fn place_from_cursor(app: &mut VectorcraftApp, p: &Value) { }); } +/// The cursor was loaded again since [`show_thumbnails`] (`tool.setOption` with a `queue`, a +/// script): its files no longer ask for their import options, and their queued sizes are unknown. +fn forget_older_queue(app: &mut VectorcraftApp) { + if app.place.queue_load != app.session.place_queue_loads() { + app.place.queue_asks = false; + app.place.queue_sizes.clear(); + app.place.asked = None; + } +} + /// The params of a cursor click (`rect` at the file's queued size) on a PDF or DXF file, whose /// import options may change its size (another page, another scale): placed as its options place /// it at the click (`at`, the center of the box the click gave), as File › Place of that one file @@ -467,7 +515,9 @@ fn clicked_at_full_size(app: &VectorcraftApp, p: &Value) -> Value { /// The cursor's thumbnails and its files' sizes from `file.place.queue`'s reply `r` once the cursor /// is loaded; `asks`: its files ask for their import options when placed. pub(crate) fn show_thumbnails(app: &mut VectorcraftApp, r: &Value, asks: bool) { + app.place.queue_load = app.session.place_queue_loads(); app.place.queue_asks = asks; + app.place.asked = None; app.place.queue_sizes = r["files"] .as_array() .into_iter() diff --git a/crates/ui-egui/src/tests/place.rs b/crates/ui-egui/src/tests/place.rs index cbddfbf2c1..f470ef49bc 100644 --- a/crates/ui-egui/src/tests/place.rs +++ b/crates/ui-egui/src/tests/place.rs @@ -1071,6 +1071,101 @@ fn the_place_cursors_files_ask_for_their_import_options_when_show_import_options assert_eq!(dialog_kind(&app), None); } +/// A cursor loaded again after the Place dialog loaded it with Show Import Options on (a queue set +/// on the Place tool) places its files at once. +#[test] +fn a_place_cursor_loaded_again_no_longer_asks_for_import_options() { + let mut app = app(); + let dir = Folder::new("ui-place-cursor-reloaded"); + let a = dir.file("a.png", &png(10, 10, 72.0)); + let notes = dir.file("notes.txt", NOTES); + app.run("file.place", json!({"paths": [a, notes]})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + let entries: Vec = [(&a, "a.png"), (¬es, "notes.txt")] + .iter() + .map(|(path, name)| json!({"params": {"path": path}, "name": name, "width": 190, "height": 90})) + .collect(); + assert_eq!(app.session.tool_id(), "place"); + app.run("tool.setOption", json!({"values": {"queue": {"entries": entries}}})).unwrap(); + crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 190, 90]})); + assert_eq!(dialog_kind(&app), None); + assert!(selected_text(&app).contains("three")); +} + +/// The import options a click of a cursor loaded within automation roots asks for place within +/// those roots when OK is pressed, by the person at the keyboard too: a file outside them is not +/// read. One inside them is placed. +#[test] +fn the_import_options_a_place_cursor_asks_for_place_within_its_roots() { + use vectorcraft_engine::file_access::{AutomationRoots, confine, unconfined}; + let mut app = app(); + let dir = Folder::new("ui-place-cursor-ask-roots"); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let a = dir.file("in/a.png", &png(10, 10, 72.0)); + let notes = dir.file("in/notes.txt", NOTES); + let pages = [PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), PdfPage::new(300.0, 100.0, "1 0 0 rg 0 0 30 30 re f")]; + let two = dir.file("in/two.pdf", &pdf(&pages, None)); + let outside_notes = dir.file("outside.txt", NOTES); + let outside_pdf = dir.file("outside.pdf", &pdf(&pages, None)); + let roots = AutomationRoots::new(&[&dir.join("in")], &[]).unwrap(); + confine(roots.as_ref(), || { + app.run("file.place", json!({"paths": [a, notes, two]})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + }); + assert_eq!(app.session.tool_options()["count"], 3); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = undo(&app); + // The text file's OK, its params naming a file outside the roots: nothing is placed. + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + app.ui.dialog.as_mut().unwrap().fields.get_mut("__params").unwrap()["path"] = json!(outside_notes); + let _ = unconfined(|| crate::dialogs::confirm(&mut app)); + assert_eq!(undo(&app), before, "the text file outside the roots"); + // The PDF's OK, its file outside the roots: nothing is placed. + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": two, "link": false, "rect": [10, 10, 100, 100]}))); + assert_eq!(dialog_kind(&app), Some("importPdf")); + app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(outside_pdf)); + let _ = unconfined(|| crate::dialogs::confirm(&mut app)); + assert_eq!(undo(&app), before, "the PDF outside the roots"); + app.ui.dialog = None; + // A caller with roots of its own confirms within those: the file in the cursor's roots but + // outside the caller's is not placed. + std::fs::create_dir_all(dir.join("b")).unwrap(); + let narrower = AutomationRoots::new(&[&dir.join("b")], &[]).unwrap(); + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + let _ = confine(narrower.as_ref(), || crate::dialogs::confirm(&mut app)); + assert_eq!(undo(&app), before, "outside the caller's roots"); + // The text file inside the roots is placed. + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + unconfined(|| crate::dialogs::confirm(&mut app)).unwrap(); + assert!(selected_text(&app).contains("three")); + // Once the cursor is loaded again, the earlier click's import options place nothing. + let after = undo(&app); + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + app.run("file.place.queue", json!({"paths": [a]})).unwrap(); + assert!(unconfined(|| crate::dialogs::confirm(&mut app)).is_err()); + assert_eq!(undo(&app), after); + // So do those of a click before a queue set on the Place tool. + confine(roots.as_ref(), || { + app.run("file.place", json!({"paths": [a, notes]})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + }); + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + let entry = json!({"params": {"path": a}, "name": "a.png", "width": 10, "height": 10}); + app.run("tool.setOption", json!({"values": {"queue": {"entries": [entry]}}})).unwrap(); + let saved = app.ui.dialog.clone(); + assert!(unconfined(|| crate::dialogs::confirm(&mut app)).is_err()); + assert_eq!(undo(&app), after); + // Also when confirmed by a caller with roots that hold the file. + app.ui.dialog = saved; + assert!(confine(roots.as_ref(), || crate::dialogs::confirm(&mut app)).is_err()); + assert_eq!(undo(&app), after); +} + #[test] fn a_file_placed_while_idle_follows_its_changes() { let mut app = app();