diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 667d66dab..9f2070f4d 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used)} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", has_doc, relink ), @@ -371,21 +371,21 @@ fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result { } /// Objects `ids` show what was read (see [`install`], [`install_document`]). -fn install_read(d: &mut Document, ids: &[NodeId], read: Read, keep_bounds: bool) -> Result<()> { +fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option) -> Result<()> { match read { - Read::Image(r) => install(d, ids, r, keep_bounds), - Read::Document(link, src) => install_document(d, ids, &link, &src, keep_bounds), + Read::Image(r) => install(d, ids, r, force), + Read::Document(link, src) => install_document(d, ids, &link, &src, force), } } /// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its -/// Placement Options say (`keep_bounds`: in its bounds, whatever they say). -fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, keep_bounds: bool) -> Result<()> { +/// Placement Options say (`force`: as that Preserve choice says, whatever they say). +fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, force: Option) -> Result<()> { let mut clips = vec![]; let (w, h) = (src.frame.width(), src.frame.height()); for id in ids { let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue }; - let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..p.placement } } else { p.placement }; + let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement }; let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o); if let Some(n) = d.node_mut(*id) && let NodeKind::PlacedDocument(p) = &mut n.kind @@ -480,13 +480,13 @@ fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> { } /// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement -/// Options say (`keep_bounds`: in its bounds, whatever they say). -fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, keep_bounds: bool) -> Result<()> { +/// Options say (`force`: as that Preserve choice says, whatever they say). +fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option) -> Result<()> { let new_pt = super::place::pt_per_px(img.ppi); let mut clips = vec![]; for id in ids { let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue }; - let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..im.placement } } else { im.placement }; + let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; let size = |w: u32, h: u32| (w as f64, h as f64); let (xf, clip) = placed_xf(im.xf, size(im.width, im.height), file_pt(d, &im.key), size(img.width, img.height), new_pt, o); if let Some(n) = d.node_mut(*id) @@ -540,7 +540,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv match probe(&g, folder(doc_path)) { (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { - match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread)) { + match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) { Ok(()) if unread => {} Ok(()) => out.updated.extend(row), Err(_) => out.missing.extend(row), @@ -552,7 +552,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None }; match read { Some(Ok(read)) => { - if install_read(d, &g.ids, read, true).is_err() { + if install_read(d, &g.ids, read, Some(Preserve::Bounds)).is_err() { out.missing.extend(row); } } @@ -737,13 +737,18 @@ fn update(s: &mut Session, p: &Value) -> Result { } let updated: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); if !reads.is_empty() { - s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?; + s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, None)))?; } Ok(json!({ "updated": updated, "missing": missing })) } fn relink(s: &mut Session, p: &Value) -> Result { const C: &str = "links.relink"; + // How the new file takes the image's place this time (default: its Placement Options). + let preserve = match str_param(p, "preserve") { + None => None, + Some(v) => Some(Preserve::from_id(v).ok_or_else(|| bad(C, format!("preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill")))?), + }; let st = s.doc()?; let folder_param = str_param(p, "folder"); // With copy: the saved document's folder, which the file is copied into. @@ -852,7 +857,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { } let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); if !reads.is_empty() { - s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?; + s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve)))?; } not_found.sort(); not_found.dedup(); @@ -1109,7 +1114,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { if !done.is_empty() || !documents.is_empty() { s.edit("Embed", |d, _| { for (ids, read) in reads { - install(d, &ids, read, true)?; + install(d, &ids, read, Some(Preserve::Bounds))?; } for id in &done { if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) { @@ -1164,7 +1169,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result { let path = absolute_path(&if fileio::extension(path).is_empty() { format!("{path}.{ext}") } else { path.to_string() }); write_file(&path, &bytes)?; let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?); - s.edit("Unembed", |d, _| install(d, &[id], read, true))?; + s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds)))?; Ok(json!({ "id": id.0, "path": path })) } diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 329d9d885..dcf9f024c 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -127,6 +127,18 @@ fn relink_picked(app: &mut VectorcraftApp, ids: Vec) { report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len))); } +/// Replace images `ids` with a picked file (Control bar, Properties): it keeps its own aspect +/// ratio, fitted inside the old bounds (Placement Options: Fit), whatever their Placement Options +/// say. +pub(crate) fn replace(app: &mut VectorcraftApp, ids: Vec) { + crate::picks::button(app, move |app| { + let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; + let Some(path) = crate::picks::open(app, &pick) else { return }; + let r = app.run("links.relink", json!({ "ids": ids.clone(), "path": path, "preserve": "fit" })); + report(app, r, |v| format!("Replaced {} image(s)", v["relinked"].as_array().map_or(0, Vec::len))); + }); +} + /// Relink images `ids` (none: every missing one) to the files of their names in a picked folder. fn relink_to_folder(app: &mut VectorcraftApp, ids: Vec) { crate::picks::button(app, move |app| relink_to_picked_folder(app, ids.clone())); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 3aa66e384..9bc1985c0 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -153,8 +153,8 @@ fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) { let links = crate::panels::links::ID; ui.horizontal(|ui| crate::place::link_buttons(app, ui, id, linked, &name, Some(w))); ui.horizontal(|ui| { - if app.services.pick_open.is_some() && widgets::flat_button(ui, tl!("Relink…"), w).clicked() { - crate::panels::links::relink(app, vec![id]); + if app.services.pick_open.is_some() && widgets::flat_button(ui, tl!("Replace…"), w).clicked() { + crate::panels::links::replace(app, vec![id]); } if widgets::flat_button(ui, tl!("Links"), w).clicked() { app.ui.open_panel = Some(links.into()); diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 4daa5388a..cc3022f14 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -442,7 +442,12 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool let (name, details) = image_summary(i); let (Some(id), linked) = (i["id"].as_u64(), i["linked"] == true) else { return false }; let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(&name).size(12.0).color(t.text_strong)); + // The file name is a link: its menu relinks (replaces) the image and reaches the Links + // panel's other actions for it. + let resp = ui + .add(egui::Label::new(egui::RichText::new(&name).size(12.0).color(t.accent).underline()).sense(egui::Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand); + egui::Popup::menu(&resp).show(|ui| link_menu(app, ui, id, linked)); ui.separator(); ui.label(egui::RichText::new(details).size(12.0).color(t.text_dim)); ui.separator(); @@ -457,6 +462,32 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool true } +/// The menu of the Control bar's file name for image `id`: Replace… (another file takes the +/// image's place with its own aspect ratio; an embedded image becomes linked), Go To Link, Edit +/// Original, Update Link and Placement Options…. +fn link_menu(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool) { + ui.set_min_width(200.0); + if widgets::menu_item(ui, tl!("Replace…"), app.services.pick_open.is_some(), false) { + crate::panels::links::replace(app, vec![id]); + } + if widgets::menu_item(ui, tl!("Go To Link"), true, false) { + crate::panels::links::go_to(app, id); + } + if widgets::menu_item(ui, tl!("Edit Original"), linked, false) { + crate::menus::invoke(app, "links.editOriginal", json!({ "id": id })); + } + if widgets::menu_item(ui, tl!("Update Link"), linked, false) { + let msg = match app.run("links.update", json!({ "ids": [id] })) { + Ok(v) => format!("Updated {} image(s)", v["updated"].as_array().map_or(0, Vec::len)), + Err(e) => e, + }; + app.status(msg); + } + if widgets::menu_item(ui, tl!("Placement Options…"), true, false) { + crate::menus::invoke(app, "ui.placementOptionsDialog", json!({ "ids": [id] })); + } +} + /// Embed (and Edit Original) for linked image `id`, Unembed… for an embedded one named `name` /// (Control bar, Properties). The buttons are `width` wide, or fit their labels. pub fn link_buttons(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool, name: &str, width: Option) { diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 91f0fc253..410ea86fb 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -494,6 +494,44 @@ fn click_label(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { click_control(app, ctx, p); } +/// The Control bar's file name opens the link menu: Replace… puts the picked file in the image's +/// place (an embedded image becomes linked) with the new file's own aspect ratio, fitted inside the +/// old bounds; Placement Options… opens its dialog. +#[test] +fn the_control_bars_file_name_relinks_the_image() { + use crate::tests_removeanchors::{click_control, control_frame}; + // Menu items are drawn after the space a check mark takes. + let item = |app: &mut VectorcraftApp, ctx: &egui::Context, label: &str| { + let texts = control_frame(app, ctx, vec![]); + texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")) + }; + let mut app = app(); + let data = vectorcraft_format::base64_encode(&png(30, 30, 72.0)); + app.run("file.place", json!({"name": "red.png", "dataBase64": data, "link": false})).unwrap(); + let before = selected_image(&app).geometric_bounds().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + click_label(&mut app, &ctx, "red.png"); + for s in ["Replace…", "Go To Link", "Edit Original", "Update Link", "Placement Options…"] { + item(&mut app, &ctx, s); + } + let other = temp_file("blue.png", &png(60, 30, 72.0)); + let picked = other.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + let p = item(&mut app, &ctx, "Replace…"); + click_control(&mut app, &ctx, p); + let image = selected_image(&app); + let NodeKind::Image(im) = &image.kind else { panic!("an image") }; + assert!(im.link.as_ref().is_some_and(|l| l.name() == "blue.png"), "relinked to the picked file"); + let after = image.geometric_bounds().unwrap(); + assert!((after.width() - before.width()).abs() < 1e-6 && (after.height() - before.height() / 2.0).abs() < 1e-6, "2:1, fitted: {after:?}"); + assert!((after.center() - before.center()).hypot() < 1e-6, "centred where the old image was"); + click_label(&mut app, &ctx, "blue.png"); + let p = item(&mut app, &ctx, "Placement Options…"); + click_control(&mut app, &ctx, p); + assert!(app.ui.dialog.is_some(), "Placement Options opens"); +} + #[test] fn the_control_bar_and_properties_trace_a_selected_image() { use crate::panels::image_trace::{TRACE_BUTTON_W, selected_trace};