diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 3bf8c766..c3e92758 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent"] } diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 6015bcd1..d3f966d7 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -1,15 +1,21 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. +use std::cell::Cell; +use std::rc::Rc; use std::sync::{Arc, Mutex}; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; -use vectorcraft_ui_egui::place::{PlaceArrival, PlaceInbox}; +use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; use vectorcraft_ui_egui::{Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; +/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and +/// whether Shift was held: where dropped files land. +type DragPos = Rc>>; + const CANVAS_ID: &str = "vectorcraft_canvas"; const LOADING_ID: &str = "vectorcraft_loading"; @@ -24,6 +30,7 @@ pub fn start() { log::error!("missing "); return; }; + let drag = track_drag(&canvas); let mut options = eframe::WebOptions::default(); if query().contains("webgl") && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup @@ -39,8 +46,9 @@ pub fn start() { log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); } let inbox: Inbox = Arc::default(); - let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), Arc::default(), cc.egui_ctx.clone())); - Ok(Box::new(WebShell { app, inbox })) + let place_inbox: PlaceInbox = Arc::default(); + let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), place_inbox.clone(), cc.egui_ctx.clone())); + Ok(Box::new(WebShell { app, inbox, place_inbox, drag })) }), ) .await; @@ -57,29 +65,58 @@ fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } +/// Follow file drags over the canvas: drag events carry the pointer position, which egui doesn't +/// get during a drag. +fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos { + let pos = DragPos::default(); + let p = pos.clone(); + let on_drag = wasm_bindgen::closure::Closure::::new(move |e: web_sys::DragEvent| { + p.set(Some((e.offset_x() as f32, e.offset_y() as f32, e.shift_key()))); + }); + for kind in ["dragover", "drop"] { + if let Err(e) = canvas.add_event_listener_with_callback(kind, on_drag.as_ref().unchecked_ref()) { + log::error!("couldn't follow {kind} events: {e:?}"); + } + } + // The listener lives as long as the page. + on_drag.forget(); + pos +} + /// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously) -/// and feed them through the inbox. +/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened. struct WebShell { app: VectorcraftApp, inbox: Inbox, + place_inbox: PlaceInbox, + drag: DragPos, } impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { - let inbox = self.inbox.clone(); - let ctx = ctx.clone(); - wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes_async().await { - Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); - ctx.request_repaint(); + if !dropped.is_empty() { + let at = self.drag.take(); + let z = ctx.zoom_factor(); + let target = self.app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2)); + for f in dropped { + let (inbox, place_inbox, ctx) = (self.inbox.clone(), self.place_inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + match f.bytes_async().await { + Ok(bytes) => { + match target { + DropTarget::Place { at, embed } => { + place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) }) + } + DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)), + } + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), - } - }); + }); + } } self.app.logic(ctx); } @@ -98,17 +135,20 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Servic let picked = place_inbox.clone(); let place_ctx = ctx.clone(); Services { - // File → Place…: the picked file goes to the Place dialog. + // File → Place…: the picked files go to the Place dialog. place_async: Some(Box::new(move || { let inbox = picked.clone(); let ctx = place_ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let dialog = fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)); - let Some(file) = dialog.pick_file().await else { + let Some(files) = dialog.pick_files().await else { return; }; - let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name: file.file_name(), bytes }); + let mut arrived = vec![]; + for f in files { + arrived.push(PlaceArrival { name: f.file_name(), bytes: f.read().await, drop: None }); + } + inbox.lock().unwrap_or_else(|e| e.into_inner()).extend(arrived); ctx.request_repaint(); }); })), diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index ae8fbcc0..1aa4dc7d 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -104,6 +104,15 @@ fn services() -> Services { .pick_file() .map(|p| p.to_string_lossy().to_string()) })), + pick_open_multi: Some(Box::new(|| { + fileio::place_filters() + .fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 8be6d3e7..02785957 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -10,7 +10,8 @@ //! //! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was, //! with its transform; Template puts it on a new template layer. The images, symbols, patterns and -//! swatches it uses join the document ([`adopt`]). +//! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the +//! `place` tool) with several files, which emits one `file.place` per click or drag. mod adopt; @@ -26,6 +27,9 @@ use super::*; use crate::{EngineError, MAX_COORD}; const PLACE: &str = "file.place"; +const QUEUE: &str = "file.place.queue"; +/// The most files one `file.place.queue` loads. +const MAX_QUEUE: usize = 100; /// The longest thumbnail side `thumbnail` may ask for (px). const MAX_THUMBNAIL: f64 = 512.0; @@ -58,6 +62,15 @@ pub fn specs() -> Vec { has_doc, image_info ), + cmd!( + query "file.place.queue", + "Load Place Cursor", + [], + None, + "{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}", + has_doc, + queue + ), ] } @@ -311,6 +324,66 @@ fn template_layer(d: &mut Document, below: Option, name: &str) -> Result Ok(id) } +// ---------- file.place.queue ---------- + +/// The files `file.place.queue` names, as `file.place` params. +fn queued_files(p: &Value) -> Result> { + let list = |k: &str| p.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut out = vec![]; + for v in list("paths") { + let path = v.as_str().ok_or_else(|| bad(QUEUE, "paths must be strings"))?; + out.push(json!({ "path": path })); + } + for f in list("files") { + let (Some(name), Some(data)) = (str_param(f, "name"), str_param(f, "dataBase64")) else { + return Err(bad(QUEUE, "files must be {name, dataBase64}")); + }; + out.push(json!({ "name": name, "dataBase64": data })); + } + match out.len() { + 0 => Err(bad(QUEUE, "give paths or files")), + n if n > MAX_QUEUE => Err(bad(QUEUE, format!("at most {MAX_QUEUE} files"))), + _ => Ok(out), + } +} + +fn queue(s: &mut Session, p: &Value) -> Result { + let thumbnail = p.get("thumbnail").and_then(Value::as_f64); + let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]); + for mut q in queued_files(p)? { + for k in ["link", "template", "page", "crop"] { + if let Some(v) = p.get(k) { + q[k] = v.clone(); + } + } + match load(&q, QUEUE) { + Ok(l) => { + let (w, h) = (l.natural.width(), l.natural.height()); + let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h }); + entries.push(json!({ "params": q, "name": l.name, "width": w, "height": h })); + if let Some(px) = thumbnail { + f["thumbnailBase64"] = json!(thumbnail_png(l, px)?); + } + files.push(f); + } + Err(e) => { + let name = str_param(&q, "path").or(str_param(&q, "name")).map(fileio::file_name).unwrap_or_default(); + skipped.push(json!({ "name": name, "error": e.to_string() })); + } + } + } + if entries.is_empty() { + let why = skipped.first().and_then(|e| e["error"].as_str()).unwrap_or("nothing to place"); + return Err(bad(QUEUE, why)); + } + // Reloading the cursor keeps the tool to return to. + let prev = Some(s.tool_id()).filter(|t| *t != "place"); + let view = s.last_view; + s.select_tool("place", view)?; + s.set_tool_option("queue", &json!({ "entries": entries, "prev": prev })); + Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })) +} + // ---------- queries ---------- /// The colour mode an image file stores its pixels in. diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests_place.rs index eb4b935f..dfa85e46 100644 --- a/crates/engine/src/tests_place.rs +++ b/crates/engine/src/tests_place.rs @@ -1,4 +1,5 @@ -//! File → Place (`file.place`, `file.place.info`, `image.info`). +//! File → Place (`file.place`, `file.place.info`, `image.info`) and the place cursor +//! (`file.place.queue` and the `place` tool). use std::sync::Arc; @@ -6,6 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::Rect; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use super::*; @@ -261,6 +263,37 @@ fn info_reports_size_resolution_colour_mode_and_link() { let _ = std::fs::remove_file(&path); } +#[test] +fn a_queue_of_3_with_click_drag_and_esc_places_2() { + let mut s = session(); + let view = ViewInfo::default(); + let files: Vec = ["a.png", "b.png", "c.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect(); + let r = s.execute("file.place.queue", &json!({"files": files, "thumbnail": 8})).unwrap(); + assert_eq!(r["count"], 3); + assert!(r["files"][0]["thumbnailBase64"].is_string()); + assert_eq!(s.tool_id(), "place"); + assert_eq!(s.tool_options()["name"], "a.png"); + let ev = |kind, x, y| PointerEvent::new(kind, x, y); + // Click: 100% with its top-left corner at the click. + s.pointer(&ev(PointerKind::Down, 10.0, 10.0), view).unwrap(); + s.pointer(&ev(PointerKind::Up, 10.0, 10.0), view).unwrap(); + let a = s.doc().unwrap().selection.objects[0]; + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds(), Some(Rect::new(10.0, 10.0, 50.0, 30.0))); + // Drag: the dragged size, aspect kept. + s.pointer(&ev(PointerKind::Down, 100.0, 100.0), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, 180.0, 110.0), view).unwrap(); + s.pointer(&ev(PointerKind::Up, 180.0, 110.0), view).unwrap(); + let b = s.doc().unwrap().selection.objects[0]; + assert_eq!(s.doc().unwrap().doc.node(b).unwrap().name.as_deref(), Some("b.png")); + assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds(), Some(Rect::new(100.0, 100.0, 180.0, 140.0))); + // Esc discards the last one and asks for the previous tool back. + assert!(s.tool_claims_key(ToolKey::Escape, view)); + let reqs = s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap(); + assert_eq!(reqs, vec![UiRequest::SwitchTool("selection".into())]); + assert_eq!(top_children(&s).len(), 2); + assert_eq!(s.doc().unwrap().history.undo.len(), 2, "one undo step per placement"); +} + #[test] fn bad_place_params_are_errors() { let mut s = session(); @@ -277,5 +310,8 @@ fn bad_place_params_are_errors() { ] { assert!(s.execute("file.place", &p).is_err(), "{p}"); } + assert!(s.execute("file.place.queue", &json!({})).is_err()); + assert!(s.execute("file.place.queue", &json!({"files": [{"name": "notes.txt", "dataBase64": ""}]})).is_err()); + assert_eq!(s.tool_id(), "selection", "a failed queue leaves the tool alone"); assert!(top_children(&s).is_empty()); } diff --git a/crates/mcp/src/tests_place.rs b/crates/mcp/src/tests_place.rs index 84ee3d6b..367ceaab 100644 --- a/crates/mcp/src/tests_place.rs +++ b/crates/mcp/src/tests_place.rs @@ -1,4 +1,5 @@ -//! File → Place over MCP, headless: `file.place` through `run_command`. +//! File → Place over MCP, headless: `file.place` through `run_command`, and the place cursor driven +//! by pointer gestures and keys. use serde_json::{Value, json}; @@ -38,3 +39,20 @@ fn headless_run_command_file_place() { assert_eq!(sel.len(), 1); assert_eq!(sel[0].0, r["ids"][0].as_u64().unwrap()); } + +#[test] +fn headless_place_cursor_places_on_clicks_and_esc_ends_it() { + let (mut h, png) = setup(); + let files = json!([{"name": "a.png", "dataBase64": png}, {"name": "b.png", "dataBase64": png}, {"name": "c.png", "dataBase64": png}]); + let r = text(&call_tool(&mut h, "run_command", &json!({"command": "file.place.queue", "params": {"files": files}}))); + assert_eq!(r["count"], 3); + assert_eq!(h.session.tool_id(), "place"); + let click = |x: f64| json!({"events": [{"kind": "down", "x": x, "y": 10}, {"kind": "up", "x": x, "y": 10}]}); + text(&call_tool(&mut h, "pointer_gesture", &click(10.0))); + text(&call_tool(&mut h, "pointer_gesture", &click(60.0))); + assert_eq!(images(&h), 2); + let r = text(&call_tool(&mut h, "press_key", &json!({"key": "Escape"}))); + assert_eq!(r["handledBy"], "tool"); + assert_eq!(h.session.tool_id(), "selection", "the last file discarded: back to the previous tool"); + assert_eq!(images(&h), 2); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c3d7ace9..19de5eea 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -19,6 +19,7 @@ pub mod guides; pub mod meshblend; pub mod params; pub mod pen; +pub mod place; pub mod select; pub mod shape; pub mod symbolism; @@ -334,6 +335,8 @@ pub fn create(id: &str) -> Box { "pen" => Box::new(pen::PenTool::default()), "type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)), + // Not in the toolbar: `file.place.queue` loads it. + "place" => Box::new(place::PlaceTool::default()), other => symbolism::create(other) .or_else(|| builder::create(other)) .or_else(|| draw2::create(other)) diff --git a/crates/tools/src/place.rs b/crates/tools/src/place.rs new file mode 100644 index 00000000..a885b0ca --- /dev/null +++ b/crates/tools/src/place.rs @@ -0,0 +1,245 @@ +//! The place cursor (`file.place.queue` loads it with files): a click places the current file at +//! 100% with its top-left corner at the click, a drag places it at the dragged size (its aspect +//! kept), ←/→ and ↑/↓ cycle the files and Esc discards the current one. Each placement is one +//! `file.place`; after the last file the previous tool comes back. + +use serde_json::{Value, json}; +use vectorcraft_geom::{Point, Rect}; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// One loaded file. +#[derive(Clone, Debug, PartialEq)] +struct Entry { + /// `file.place` params (the file and its options). + params: Value, + name: String, + /// Its size at 100% (pt). + width: f64, + height: f64, +} + +#[derive(Default)] +pub struct PlaceTool { + entries: Vec, + current: usize, + /// The tool to return to when the last file is placed or discarded. + prev: Option, + /// Pressed at, and the current drag point. + drag: Option<(Point, Point)>, +} + +impl PlaceTool { + fn entry(&self) -> Option<&Entry> { + self.entries.get(self.current) + } + + /// The box the current file goes into for a press at `a` released at `b`: its 100% size with + /// its top-left corner at `a` for a click (under `tol`), else grown from `a` towards `b` with + /// its aspect kept. + fn target(&self, a: Point, b: Point, tol: f64) -> Option { + let e = self.entry()?; + let (dx, dy) = (b.x - a.x, b.y - a.y); + if dx.abs().max(dy.abs()) < tol || e.width <= 0.0 || e.height <= 0.0 { + return Some(Rect::new(a.x, a.y, a.x + e.width, a.y + e.height)); + } + let k = (dx.abs() / e.width).max(dy.abs() / e.height); + let (w, h) = (e.width * k, e.height * k); + let x = if dx < 0.0 { a.x - w } else { a.x }; + let y = if dy < 0.0 { a.y - h } else { a.y }; + Some(Rect::new(x, y, x + w, y + h)) + } + + /// Drop the current file; after the last one, back to the previous tool. + fn discard(&mut self) -> Vec { + if self.current < self.entries.len() { + self.entries.remove(self.current); + } + if self.entries.is_empty() { + self.current = 0; + return vec![Action::SwitchTool(self.prev.clone().unwrap_or_else(|| "selection".into()))]; + } + self.current %= self.entries.len(); + vec![] + } + + fn cycle(&mut self, forward: bool) { + let n = self.entries.len(); + if n > 0 { + self.current = if forward { (self.current + 1) % n } else { (self.current + n - 1) % n }; + } + } +} + +impl Tool for PlaceTool { + fn id(&self) -> &'static str { + "place" + } + + fn busy(&self) -> bool { + self.drag.is_some() + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down if self.entry().is_some() => self.drag = Some((ev.pos, ev.pos)), + PointerKind::Drag => { + if let Some((a, _)) = self.drag { + self.drag = Some((a, ev.pos)); + } + } + PointerKind::Up => { + let Some((a, _)) = self.drag.take() else { return vec![] }; + let (Some(r), Some(e)) = (self.target(a, ev.pos, cx.tol(3.0)), self.entry()) else { return vec![] }; + let mut p = e.params.clone(); + p["rect"] = json!([r.x0, r.y0, r.width(), r.height()]); + let mut acts = vec![Action::Exec("file.place".into(), p)]; + acts.extend(self.discard()); + return acts; + } + _ => {} + } + vec![] + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + match key { + ToolKey::Escape if self.drag.take().is_some() => vec![], + ToolKey::Escape => self.discard(), + ToolKey::Left | ToolKey::Up => { + self.cycle(false); + vec![] + } + ToolKey::Right | ToolKey::Down => { + self.cycle(true); + vec![] + } + _ => vec![], + } + } + + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + !self.entries.is_empty() && matches!(key, ToolKey::Escape | ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down) + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some((a, b)) = self.drag else { return vec![] }; + self.target(a, b, cx.tol(3.0)).map(Overlay::Marquee).into_iter().collect() + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Crosshair + } + + /// `{count, current, name, width, height, names}`: what the cursor holds (no file data). + fn options(&self) -> Value { + let e = self.entry(); + json!({ + "count": self.entries.len(), + "current": self.current, + "name": e.map(|e| &e.name), + "width": e.map(|e| e.width), + "height": e.map(|e| e.height), + "names": self.entries.iter().map(|e| &e.name).collect::>(), + }) + } + + /// `queue`: `{entries: [{params, name, width, height}], prev?}` loads the cursor (replacing what + /// it held); `current`: index of the file to place next. + fn set_option(&mut self, key: &str, value: &Value) { + match key { + "queue" => { + let entries: Vec = value["entries"] + .as_array() + .into_iter() + .flatten() + .filter_map(|e| { + Some(Entry { + params: e.get("params").filter(|p| p.is_object())?.clone(), + name: e["name"].as_str().unwrap_or_default().to_string(), + width: e["width"].as_f64().filter(|v| v.is_finite())?, + height: e["height"].as_f64().filter(|v| v.is_finite())?, + }) + }) + .collect(); + self.entries = entries; + self.current = 0; + self.drag = None; + if let Some(prev) = value["prev"].as_str() { + self.prev = Some(prev.to_string()); + } + } + "current" => { + if let Some(i) = value.as_u64().map(|i| i as usize).filter(|i| *i < self.entries.len()) { + self.current = i; + } + } + _ => {} + } + } + + /// Switching tools discards what the cursor holds. + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.entries.clear(); + self.drag = None; + vec![] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::{cx, doc_with_rect, paint}; + + fn loaded(n: usize) -> PlaceTool { + let mut t = PlaceTool::default(); + let entries: Vec = + (0..n).map(|i| json!({"params": {"path": format!("f{i}.png")}, "name": format!("f{i}.png"), "width": 40.0, "height": 20.0})).collect(); + t.set_option("queue", &json!({"entries": entries, "prev": "pen"})); + t + } + + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) + } + + #[test] + fn click_places_at_100_percent_and_drag_keeps_the_aspect() { + let (d, _) = doc_with_rect(); + let (s, p) = (Default::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = loaded(3); + t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0)); + let acts = t.pointer(&c, &ev(PointerKind::Up, 11.0, 10.0)); + assert_eq!(acts, vec![Action::Exec("file.place".into(), json!({"path": "f0.png", "rect": [10.0, 10.0, 40.0, 20.0]}))]); + // A drag up and to the left grows the box from the press point, aspect kept. + t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); + t.pointer(&c, &ev(PointerKind::Drag, 20.0, 90.0)); + assert_eq!(t.overlays(&c), vec![Overlay::Marquee(Rect::new(20.0, 60.0, 100.0, 100.0))]); + let acts = t.pointer(&c, &ev(PointerKind::Up, 20.0, 90.0)); + assert_eq!(acts[0], Action::Exec("file.place".into(), json!({"path": "f1.png", "rect": [20.0, 60.0, 80.0, 40.0]}))); + assert_eq!(t.options()["count"], 1); + // Placing the last file returns to the previous tool. + t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0)); + let acts = t.pointer(&c, &ev(PointerKind::Up, 0.0, 0.0)); + assert_eq!(acts.last(), Some(&Action::SwitchTool("pen".into()))); + assert!(!t.claims_key(&c, ToolKey::Escape), "an empty cursor takes no keys"); + } + + #[test] + fn arrows_cycle_and_escape_discards() { + let (d, _) = doc_with_rect(); + let (s, p) = (Default::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = loaded(3); + assert!(t.claims_key(&c, ToolKey::Left) && t.claims_key(&c, ToolKey::Escape)); + t.key(&c, ToolKey::Left, Mods::default()); + assert_eq!(t.options()["name"], "f2.png"); + t.key(&c, ToolKey::Down, Mods::default()); + assert_eq!(t.options()["name"], "f0.png"); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty()); + assert_eq!(t.options()["names"], json!(["f1.png", "f2.png"])); + t.key(&c, ToolKey::Escape, Mods::default()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("pen".into())]); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 621c0b5f..435ebced 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -303,6 +303,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let overlays = app.session.overlays(view_info); draw_overlays(&painter, &xf, &overlays, &t); } + crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect); if app.ui.view.rulers && app.ui.screen_mode < 3 { rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t); @@ -324,6 +325,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else if let Some(p) = app.hover_doc { let c = app.session.cursor(p, mods(m, space), view_info); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor"))); + // The loaded place cursor carries the file's thumbnail. + if app.session.tool_id() == "place" + && let Some(hp) = ui.input(|i| i.pointer.hover_pos()) + { + crate::place::paint_cursor(app, ui.ctx(), &painter, hp); + } // Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors. let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none(); match ui.input(|i| i.pointer.hover_pos()) { @@ -530,7 +537,13 @@ fn drag_art_out(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { - match app.session.pointer(ev, view) { + let r = app.session.pointer(ev, view); + apply_requests(app, r); +} + +/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. +pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result>) { + match r { Ok(reqs) => { for r in reqs { match r { diff --git a/crates/ui-egui/src/dialogs/place.rs b/crates/ui-egui/src/dialogs/place.rs index 96a284da..20878e72 100644 --- a/crates/ui-egui/src/dialogs/place.rs +++ b/crates/ui-egui/src/dialogs/place.rs @@ -1,9 +1,9 @@ -//! File → Place: the file picked, a line about what it is, and the Place options row (Link, -//! Template, Replace). OK places it centred in the view, or in place of the selected object -//! ([`crate::place::confirm`]). +//! File → Place: the files picked, one line each about what they are, and the Place options row +//! (Link, Template, Replace). OK places one file centred in the view (or in place of the selected +//! object) or loads the place cursor with several ([`crate::place::confirm`]). //! -//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one -//! selected object: Replace applies), `__info` (a line per file). +//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file +//! and one selected object: Replace applies), `__info` (a line per file). use serde_json::{Value, json}; @@ -61,6 +61,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); }); + if files.len() > 1 { + ui.add_space(4.0); + widgets::dim_label(ui, "Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one."); + } ui.add_space(10.0); // The options row. ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1dfa60a2..8f6c1d39 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,9 +88,12 @@ pub struct Services { pub clipboard_read: Option Option>>, /// Open a URL in the system browser (desktop). Without it, egui opens it (a new tab on the web). pub open_url: Option, - /// Web: start an async pick of a file to place (it arrives via `place_inbox`). + /// Show an open dialog for several files (File → Place…); returns their paths (none: cancelled). + pub pick_open_multi: Option Vec>>, + /// Web: start an async pick of files to place (they arrive via `place_inbox`). pub place_async: Option>, - /// Files that arrived asynchronously to be placed (web: picked for Place). + /// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the + /// canvas). pub place_inbox: Option, } @@ -167,7 +170,7 @@ pub struct VectorcraftApp { /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. pub custom_titlebar: bool, - /// File → Place: picked files, the Control bar's image details. + /// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details. pub place: place::PlaceState, } @@ -491,21 +494,31 @@ impl VectorcraftApp { if self.fonts_ready { shortcuts::handle(self, ctx); } - // Native only: the web host reads dropped files asynchronously and feeds `Services::inbox`. + // Native only: the web host reads dropped files asynchronously and feeds the inboxes. #[cfg(not(target_arch = "wasm32"))] - for f in ctx.input(|i| i.raw.dropped_files.clone()) { - let path = f.path().to_path_buf(); - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes() { - Ok(b) => { - let p = Some(path.to_string_lossy().to_string()).filter(|s| !s.is_empty()); - if let Err(e) = io::open_bytes(self, &name, &b, p) { - self.status(format!("Couldn't open {name}: {e}")); - } - } + self.take_dropped_files(ctx); + } + + /// Files dropped on the window: placed where they were dropped on the canvas, else opened. + #[cfg(not(target_arch = "wasm32"))] + fn take_dropped_files(&mut self, ctx: &egui::Context) { + let (dropped, pos, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos(), i.modifiers.shift)); + if dropped.is_empty() { + return; + } + let target = self.drop_target(pos, shift); + let mut files = vec![]; + for f in dropped { + let path = Some(f.path().to_string_lossy().to_string()).filter(|s| !s.is_empty()); + let name = path.as_deref().map_or_else(|| "dropped".into(), vectorcraft_engine::cmd::fileio::file_name); + // A file placed by its path is read by the engine. + let bytes = if path.is_some() && target != place::DropTarget::Open { Ok(vec![]) } else { f.bytes() }; + match bytes { + Ok(b) => files.push((name, path, b)), Err(e) => self.status(format!("Couldn't read {name}: {e}")), } } + place::drop_files(self, files, target); } /// Inject synthetic events (one press/release step per frame). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 44b85615..95c4826e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -62,7 +62,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.place", "Place…", "Cmd+Shift+P", - "{path?} no file: pick one, then the Place dialog (Link, Template, Replace); path (or name+dataBase64, …file.place params): file.place, centred in the view", + "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view", ), ("file.openRecent1", "Open Recent File 1", "", "{}"), ("file.openRecent2", "Open Recent File 2", "", "{}"), @@ -440,6 +440,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(json!(app.ui.recent_files)), "file.place" => crate::place::run(app, p), + "file.place.queue" => crate::place::queue(app, p), "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None); Ok(Value::Null) diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index f35e867d..24910d8f 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -1,32 +1,58 @@ -//! File → Place in the app: picking the file, the Place dialog's options and the Control bar's -//! image details. The engine places (`file.place`); this gathers the file and options. +//! File → Place in the app: picking the files, the Place dialog's options, files dropped on the +//! window, the loaded place cursor and the Control bar's image details. The engine places +//! (`file.place`, `file.place.queue`); this gathers the files and options. use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use vectorcraft_engine::cmd::fileio; +use vectorcraft_geom::Point; -use crate::VectorcraftApp; -use crate::theme::Tokens; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, io}; -/// A file to place that arrived asynchronously (web): picked for File → Place. +/// The loaded place cursor's thumbnail size (px on the longer side). +const THUMB: u32 = 64; +/// Where the thumbnail sits from the pointer (points). +const THUMB_OFFSET: egui::Vec2 = egui::vec2(14.0, 14.0); + +/// A file to place that arrived asynchronously (web): picked for File → Place (`drop: None`), or +/// dropped on the canvas at a point, embedded or not. pub struct PlaceArrival { pub name: String, pub bytes: Vec, + pub drop: Option<(Point, bool)>, } pub type PlaceInbox = Arc>>; +/// Where files dropped on the window go. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DropTarget { + /// The active document's canvas at `at` (document coordinates); `embed` when Shift is held. + Place { at: Point, embed: bool }, + /// Opened as documents: no document is open, or the drop missed the canvas (the tab bar). + Open, +} + /// The app's Place state (not saved with the preferences). #[derive(Default)] pub struct PlaceState { /// Web: the bytes of the files picked for the Place dialog, by name. picked: Vec<(String, Vec)>, + /// The loaded cursor's thumbnails by file name: decoded when the cursor is loaded, uploaded + /// when first drawn. + thumbs: Vec<(String, Thumb)>, /// The Control bar's `image.info`, for (document uid, revision, image id). info: Option<((u64, u64, u64), Value)>, } +enum Thumb { + Decoded(egui::ColorImage), + Uploaded(egui::TextureHandle), +} + /// Run engine command `id` itself (past the UI's handling of the same id); an error shows in the /// status bar. fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result { @@ -41,10 +67,19 @@ fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result Result { if p.get("path").is_none() && p.get("dataBase64").is_none() { - return pick(app).map(|_| Value::Null); + let files: Vec = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(|path| json!({ "path": path })).collect(), + None => return pick(app).map(|_| Value::Null), + }; + if files.is_empty() { + return Err("paths: give at least one file".into()); + } + open_dialog(app, files); + return Ok(Value::Null); } let mut p = p.clone(); if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none()) @@ -55,14 +90,21 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { engine(app, "file.place", &p) } -/// Pick the file to place: the Place dialog opens with it (on the web once it arrives). +/// Pick files to place: the Place dialog opens with them (on the web once they arrive). fn pick(app: &mut VectorcraftApp) -> Result<(), String> { if let Some(f) = app.services.place_async.as_mut() { f(); return Ok(()); } - let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; - open_dialog(app, vec![json!({ "path": path })]); + let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) { + (Some(f), _) => f(), + (None, Some(f)) => f().into_iter().collect(), + (None, None) => vec![], + }; + if paths.is_empty() { + return Err("cancelled".into()); + } + open_dialog(app, paths.into_iter().map(|path| json!({ "path": path })).collect()); Ok(()) } @@ -118,38 +160,171 @@ fn fmt_ppi(v: f64) -> String { s.strip_suffix(".0").map(str::to_string).unwrap_or(s) } -/// Place (the dialog's OK): the file is placed centred in the view (or replaces the selection). +/// Place (the dialog's OK): one file is placed centred in the view (or replaces the selection); +/// several load the place cursor. pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result { let files: Vec = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default(); - let Some(mut p) = files.first().and_then(|f| source(app, f)) else { return Err("no file to place".into()) }; - let link = d.bool("link"); + let sources: Vec = files.iter().filter_map(|f| source(app, f)).collect(); + if sources.is_empty() { + return Err("no files to place".into()); + } + let (link, template) = (d.bool("link"), d.bool("template")); app.ui.place_link = link; app.ui.dialog = None; - p["link"] = json!(link); - p["template"] = json!(d.bool("template")); - if d.bool("replace") && d.bool("__replace") { - p["replace"] = json!(true); - } - let r = run(app, &p); + let r = match &sources[..] { + [one] => { + let mut p = one.clone(); + p["link"] = json!(link); + p["template"] = json!(template); + if d.bool("replace") && d.bool("__replace") { + p["replace"] = json!(true); + } + run(app, &p) + } + many => { + let paths: Vec<&Value> = many.iter().filter_map(|s| s.get("path")).collect(); + let files: Vec<&Value> = many.iter().filter(|s| s.get("path").is_none()).collect(); + queue(app, &json!({ "paths": paths, "files": files, "link": link, "template": template })) + } + }; app.place.picked.clear(); r } -/// Files picked for File → Place that arrived (web): the Place dialog for them. +// ---------- the place cursor ---------- + +/// `file.place.queue` with thumbnails for the cursor (unless `thumbnail` is given). +pub fn queue(app: &mut VectorcraftApp, p: &Value) -> Result { + let mut p = p.clone(); + if p.get("thumbnail").is_none() { + p["thumbnail"] = json!(THUMB); + } + let r = engine(app, "file.place.queue", &p)?; + app.place.thumbs = r["files"] + .as_array() + .into_iter() + .flatten() + .filter_map(|f| { + let png = vectorcraft_format::base64_decode(f["thumbnailBase64"].as_str()?)?; + let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()?.to_rgba8(); + let color = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw()); + Some((f["name"].as_str()?.to_string(), Thumb::Decoded(color))) + }) + .collect(); + Ok(r) +} + +/// The loaded place cursor at `pos`: the current file's thumbnail with the number of files loaded. +pub fn paint_cursor(app: &mut VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, pos: egui::Pos2) { + let opts = app.session.tool_options(); + let count = opts["count"].as_u64().unwrap_or(0); + let name = opts["name"].as_str().unwrap_or_default(); + let t = Tokens::get(ctx); + let tex = app.place.thumbs.iter_mut().find(|(n, _)| n == name).map(|(n, th)| { + if let Thumb::Decoded(img) = th { + *th = Thumb::Uploaded(ctx.load_texture(format!("place-thumb-{n}"), std::mem::take(img), egui::TextureOptions::LINEAR)); + } + match th { + Thumb::Uploaded(tex) => Some((tex.id(), tex.size_vec2())), + Thumb::Decoded(_) => None, + } + }); + let (id, size) = tex.flatten().unzip(); + let size = size.unwrap_or(egui::vec2(THUMB as f32, THUMB as f32 * 0.75)); + let k = THUMB as f32 / size.x.max(size.y).max(1.0); + let r = egui::Rect::from_min_size(pos + THUMB_OFFSET, size * k); + painter.rect_filled(r.expand(1.0), 0.0, t.panel); + match id { + Some(id) => { + painter.image(id, r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::from_white_alpha(200)); + } + None => { + painter.text(r.center(), egui::Align2::CENTER_CENTER, name, theme::semibold(10.0), t.text_dim); + } + } + painter.rect_stroke(r.expand(1.0), 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + if count > 1 { + // The badge: how many files are loaded. + let c = r.right_top() + egui::vec2(-2.0, 2.0); + painter.circle_filled(c, 9.0, t.accent); + painter.text(c, egui::Align2::CENTER_CENTER, count.to_string(), theme::semibold(10.5), egui::Color32::WHITE); + } +} + +// ---------- drops ---------- + +impl VectorcraftApp { + /// Where files dropped with the pointer at `pos` (screen points; `None` when the platform + /// doesn't tell) go: onto the canvas of the open document (Shift embeds them), else opened. An + /// unknown position counts as the middle of the view. + pub fn drop_target(&self, pos: Option, shift: bool) -> DropTarget { + let (Some(rect), Some(v)) = (self.canvas_rect.filter(|_| self.session.active().is_some()), self.view()) else { return DropTarget::Open }; + match pos { + Some(p) if !rect.contains(p) => DropTarget::Open, + Some(p) => DropTarget::Place { at: crate::canvas::Xf::new(rect, v).to_doc(p), embed: shift }, + None => DropTarget::Place { at: v.center, embed: shift }, + } + } +} + +/// Files dropped on the window (`(name, path, bytes)`): placed on the canvas or opened (and added +/// to Open Recent Files), as `target` says. +pub fn drop_files(app: &mut VectorcraftApp, files: Vec<(String, Option, Vec)>, target: DropTarget) { + for (name, path, bytes) in files { + let r = match target { + DropTarget::Place { at, embed } => { + let mut p = match &path { + Some(path) => json!({ "path": path }), + None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes) }), + }; + p["at"] = json!([at.x, at.y]); + p["link"] = json!(!embed); + run(app, &p).map(|_| ()) + } + DropTarget::Open => io::open_bytes(app, &name, &bytes, path.clone()).map(|_| { + if let Some(path) = &path { + io::note_recent(app, path); + } + }), + }; + if let Err(e) = r { + app.status(format!("Couldn't {} {name}: {e}", if target == DropTarget::Open { "open" } else { "place" })); + } + } +} + +/// Files that arrived to be placed (web): drops go where they were dropped, picked files to the +/// Place dialog. pub fn drain(app: &mut VectorcraftApp) { let arrived: Vec = app.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); let mut picked = vec![]; for a in arrived { - picked.push(json!({ "name": a.name })); - app.place.picked.retain(|(n, _)| *n != a.name); - app.place.picked.push((a.name, a.bytes)); + match a.drop { + Some((at, embed)) => drop_files(app, vec![(a.name, None, a.bytes)], DropTarget::Place { at, embed }), + None => { + picked.push(json!({ "name": a.name })); + app.place.picked.retain(|(n, _)| *n != a.name); + app.place.picked.push((a.name, a.bytes)); + } + } } if !picked.is_empty() { open_dialog(app, picked); } } +/// Files dragged over the window: the canvas is outlined where they would be placed. +pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, rect: egui::Rect) { + let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty()); + if !hovering || matches!(app.drop_target(ctx.input(|i| i.pointer.latest_pos()), false), DropTarget::Open) { + return; + } + let t = Tokens::get(ctx); + painter.rect_filled(rect, 0.0, t.accent.gamma_multiply(0.08)); + painter.rect_stroke(rect.shrink(1.5), 0.0, egui::Stroke::new(3.0, t.accent), egui::StrokeKind::Inside); +} + // ---------- the Control bar ---------- /// The Control bar's details for the one selected image: Linked File or Embedded, its file name, diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index eaf5f988..fbe2e235 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -86,8 +86,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { let busy = app.session.tool_busy(); for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)] { if !typing && ctx.input(|i| i.key_pressed(k)) { - let _ = app.session.tool_key(tk, Mods::default(), view); - if k == Key::Escape && !busy { + // A key the tool claims (Esc with a loaded place cursor) is only the tool's. + let claimed = app.session.tool_claims_key(tk, view); + let r = app.session.tool_key(tk, Mods::default(), view); + if claimed { + crate::canvas::apply_requests(app, r); + } + if k == Key::Escape && !busy && !claimed { if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { let _ = app.run("object.pattern.done", json!({})); } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { @@ -147,13 +152,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } // Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop: - // Delete/Backspace remove it, ←/→ nudge it). + // Delete/Backspace remove it, ←/→ nudge it; the loaded place cursor: the arrows cycle files). let m = ctx.input(|i| i.modifiers); - for (k, tk) in - [(Key::Delete, ToolKey::Delete), (Key::Backspace, ToolKey::Backspace), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)] - { + for (k, tk) in [ + (Key::Delete, ToolKey::Delete), + (Key::Backspace, ToolKey::Backspace), + (Key::ArrowLeft, ToolKey::Left), + (Key::ArrowRight, ToolKey::Right), + (Key::ArrowUp, ToolKey::Up), + (Key::ArrowDown, ToolKey::Down), + ] { if ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) { - let _ = app.session.tool_key(tk, crate::canvas::mods(m, false), view); + let r = app.session.tool_key(tk, crate::canvas::mods(m, false), view); + crate::canvas::apply_requests(app, r); return; } } diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 5e394aa6..f5864163 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -1,10 +1,15 @@ -//! File → Place in the app: the Place dialog and the Control bar's image details. +//! File → Place in the app: the Place dialog, files dropped on the window, the loaded place +//! cursor and the Control bar's image details. +use std::sync::Arc; + +use egui::{Pos2, Shape, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use vectorcraft_engine::Session; use crate::VectorcraftApp; +use crate::canvas::Xf; /// A `w`×`h` red PNG declaring `ppi`. fn png(w: u32, h: u32, ppi: f64) -> Vec { @@ -37,6 +42,34 @@ fn place_picked(app: &mut VectorcraftApp, path: &str) { app.run("file.place", json!({})).unwrap(); } +#[derive(Debug)] +struct Dropped(std::path::PathBuf); + +impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + &self.0 + } + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } +} + +/// One headless frame of the whole window (800×600) with `events` and `dropped` files. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, mut events: Vec, dropped: &[&str], shift: bool) { + events.push(egui::Event::ModifiersChanged(egui::Modifiers { shift, ..Default::default() })); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events, + dropped_files: dropped.iter().map(|p| Arc::new(Dropped(p.into())) as egui::DroppedFileHandle).collect(), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); +} + fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node { let st = app.session.active().unwrap(); let n = st.doc.node(st.selection.objects[0]).unwrap().clone(); @@ -44,6 +77,84 @@ fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node { n } +#[test] +fn a_dropped_png_is_centred_at_the_pointer() { + let mut app = app(); + let ctx = egui::Context::default(); + // Two frames: fonts, then the canvas lays out. + frame(&mut app, &ctx, vec![], &[], false); + frame(&mut app, &ctx, vec![], &[], false); + let rect = app.canvas_rect.expect("the canvas is laid out"); + let pos = rect.center() + vec2(60.0, -40.0); + let path = temp_file("drop.png", &png(20, 10, 72.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], false); + let n = selected_image(&app); + let want = Xf::new(rect, app.view().unwrap()).to_doc(pos); + let c = n.geometric_bounds().unwrap().center(); + assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "{c:?} vs {want:?}"); + assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_deref() == Some(path.as_str())), "linked by default"); + assert_eq!(app.session.documents().len(), 1, "placed, not opened"); + // Shift embeds. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], true); + assert!(matches!(&selected_image(&app).kind, NodeKind::Image(im) if im.link.is_none())); +} + +#[test] +fn files_dropped_off_the_canvas_or_with_no_document_open_and_are_recent() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let path = temp_file("open-me.png", &png(8, 8, 72.0)); + frame(&mut app, &ctx, vec![], &[], false); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))], &[&path], false); + assert_eq!(app.session.documents().len(), 1, "no document: the drop opens"); + assert_eq!(app.ui.recent_files.first(), Some(&path)); + // Over the tab bar (above the canvas): opened too. + frame(&mut app, &ctx, vec![], &[], false); + let top = app.canvas_rect.unwrap().top(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(300.0, top - 10.0))], &[&path], false); + assert_eq!(app.session.documents().len(), 2); +} + +#[test] +fn the_place_dialog_lists_the_files_and_loads_the_cursor_with_several() { + let mut app = app(); + let a = temp_file("a.png", &png(300, 150, 300.0)); + let b = temp_file("b.png", &png(10, 10, 72.0)); + app.run("file.place", json!({"paths": [a, b]})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the Place dialog"); + assert_eq!(d.kind, crate::dialogs::place::KIND); + assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one file and one selected object"); + assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)"); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for label in ["Place", "a.png", "b.png", "Link", "Template", "Replace", "Cancel"] { + assert!(text.contains(label), "{label} in {text}"); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.tool_id(), "place"); + assert_eq!(app.session.tool_options()["count"], 2); + // The cursor carries the current file's thumbnail and the number of files. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut shapes = vec![]; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::place::paint_cursor(&mut app, ui.ctx(), ui.painter(), Pos2::new(50.0, 50.0))); + out.textures_delta.clear(); + shapes = out.shapes; + } + let mut text = String::new(); + let mut textured = false; + for s in shapes { + match s.shape { + Shape::Text(t) => text.push_str(t.galley.text()), + Shape::Mesh(m) => textured |= m.texture_id != egui::TextureId::default(), + _ => {} + } + } + assert!(textured, "the thumbnail"); + assert_eq!(text, "2", "the badge"); +} + #[test] fn one_file_places_centred_in_the_view_and_replace_swaps_the_selection() { let mut app = app(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a60f5ac2..ad54da24 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -22,7 +22,7 @@ | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | -| `engine.execute` `file.place` | `{}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks one and opens the `place` dialog (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes the file); `ui.dialog.confirm` places it centred in the view, or in place of the selected object. With a file, it runs `file.place` centred in the view | +| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch (fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object, diff --git a/docs/mcp.md b/docs/mcp.md index 1d5baeab..1aec39ac 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -815,9 +815,13 @@ unless `link: false`), an SVG as one group, a PDF/.ai page or a native document' clipped group, with the images, symbols, patterns and swatches it uses. `at` centres it, `rect` fits it, `replace` swaps the selected object (keeping its place and transform), `template` puts it on a new template layer. `file.place.info` describes a file without placing it and `image.info` reports a placed image's link, colour mode and -effective ppi. +effective ppi. `file.place.queue` loads the place cursor (the `place` tool) with several files: headless, drive it +with `pointer_gesture` (a click places at 100% with the top-left corner there, a drag at the dragged size) and +`press_key` (Left/Right/Up/Down cycle, Escape discards the current file). ```json {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/photo.jpg","at":[300,200]}}} {"name":"run_command","arguments":{"command":"file.place","params":{"name":"logo.svg","dataBase64":"PHN2Zy…","rect":[0,0,100,100]}}} +{"name":"run_command","arguments":{"command":"file.place.queue","params":{"paths":["/tmp/a.png","/tmp/b.pdf"]}}} +{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":40,"y":40},{"kind":"up","x":40,"y":40}]}} ```