From 5a43337a423efd8935c08531d321223874916fab Mon Sep 17 00:00:00 2001 From: Fabian Grajales Date: Wed, 14 Oct 2026 19:12:34 -0500 Subject: [PATCH] Replace a selected image from the Control bar, with its own aspect ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Control bar's file name is a menu: Replace…, Go To Link, Edit Original, Update Link and Placement Options…. Replace… (also in the Properties panel, in place of Relink…) puts the picked file in the image's place fitted inside the old bounds and centred, keeping the new file's aspect ratio instead of stretching it into the old one. links.relink takes `preserve` (transforms, bounds, fileDimensions, fit or fill) to override the Placement Options for that read. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/links.rs | 37 +++++++++++++----------- crates/ui-egui/src/panels/links.rs | 12 ++++++++ crates/ui-egui/src/panels/properties.rs | 4 +-- crates/ui-egui/src/place.rs | 33 ++++++++++++++++++++- crates/ui-egui/src/tests_place.rs | 38 +++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 19 deletions(-) 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};