mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
ui.dialog.set stored any field name it was given, so a misspelled field (`discrad` for `discard`) was kept without effect and the dialog then ran with its old value. It now answers such a field with an error that lists the dialog's fields, and leaves the dialog unchanged. A dialog accepts the fields it holds, `__` fields, `discard` with a discard button, `preview` with a live preview, the fields it reads before a control adds them (each spec's `inputs`, for example Copy and the object options of the Transform dialogs, Move's `distance` and `angle`, the reference point, `path` of the export options dialogs, `searchFolder`, Brush Options' `tips`, the Justification and Hyphenation values that open blank, the Corners `kind` and `radius` of corners that differ, the Gradient Stop popover's `color`, `opacity`, `location` and `spread`, and a new artboard's `color` and display marks), and the fields it passes to the command it runs: every parameter of the generic `command` dialog's command, and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects. Edit Selection now opens holding `deleted<i>` for each of its rows, so setting a row number it does not have returns an error, and Transparency Flattener Presets opens holding the selected preset's name and options instead of adding them on its first frame.
323 lines
15 KiB
Rust
323 lines
15 KiB
Rust
//! The linked files a document opened without, one at a time: Replace picks another file
|
||
//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its
|
||
//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the
|
||
//! rest. Find in Folder… looks for every missing file in a folder and its subfolders (dialog
|
||
//! `findLinks`, [`super::find_links`]). Afterwards, with Preferences › Update Links: Ask When
|
||
//! Modified, the modified links are offered for update (`links.update`). Files another app changes
|
||
//! while the document is open are offered the same way ([`ask_update_changed`], from
|
||
//! [`vectorcraft_engine::link_watch`]).
|
||
//!
|
||
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
|
||
//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty),
|
||
//! `discard` (Ignore) and `searchFolder` (set it and confirm for Find in Folder… on that folder).
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use super::DialogSpec;
|
||
use crate::state::Dialog;
|
||
use crate::theme::{self, Tokens};
|
||
use crate::{VectorcraftApp, widgets};
|
||
|
||
/// The dialog kind of a missing linked file.
|
||
pub const KIND: &str = "missingLinks";
|
||
|
||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||
heading: |_| tl!("Linked File Not Found").into(),
|
||
body,
|
||
confirm,
|
||
ok: Some("Replace…"),
|
||
discard: Some("Ignore"),
|
||
min_width: 380.0,
|
||
max_width: Some(460.0),
|
||
inputs: |_| &["searchFolder"],
|
||
..DialogSpec::FORM
|
||
};
|
||
|
||
/// The ids in `rows` (`[{ids}]`).
|
||
fn ids(rows: &[Value]) -> Vec<Value> {
|
||
rows.iter().flat_map(|r| r["ids"].as_array().cloned().unwrap_or_default()).collect()
|
||
}
|
||
|
||
/// After a document opened (`document.open`'s result `r`): ask about its missing linked files,
|
||
/// then about its modified ones, then about the fonts it opened without
|
||
/// ([`super::missing_fonts`]). The web reads no linked files, so missing ones just show their
|
||
/// previews there.
|
||
pub fn after_open(app: &mut VectorcraftApp, r: &Value) {
|
||
// The fonts' dialog waits for these questions (one dialog at a time).
|
||
super::missing_fonts::after_open(app, r);
|
||
let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default();
|
||
let (missing, modified) = (rows("missingLinks"), ids(&rows("modifiedLinks")));
|
||
if missing.is_empty() || cfg!(target_arch = "wasm32") {
|
||
if !missing.is_empty() {
|
||
app.status(format!("{} linked file(s) can't be read here: their previews show", missing.len()));
|
||
}
|
||
ask_update(app, modified);
|
||
} else {
|
||
open(app, missing, modified);
|
||
}
|
||
super::settle(app);
|
||
}
|
||
|
||
fn open(app: &mut VectorcraftApp, missing: Vec<Value>, modified: Vec<Value>) {
|
||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "missing": missing, "modified": modified, "applyToAll": false, "path": "" })));
|
||
}
|
||
|
||
/// Ask about the missing files `rest`, one at a time, then offer the modified ones `modified` for
|
||
/// update.
|
||
pub(super) fn ask_next(app: &mut VectorcraftApp, rest: Vec<Value>, modified: Vec<Value>) {
|
||
if rest.is_empty() {
|
||
app.ui.dialog = None;
|
||
ask_update(app, modified);
|
||
} else {
|
||
open(app, rest, modified);
|
||
}
|
||
}
|
||
|
||
/// With Update Links: Ask When Modified, offer to read the modified linked files `ids` again.
|
||
fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||
ask(app, ids, tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"));
|
||
}
|
||
|
||
/// The same offer for linked files another app changed while the document is open
|
||
/// ([`vectorcraft_engine::link_watch`]).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(crate) fn ask_update_changed(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||
ask(app, ids, tl!("{count} linked image(s) changed on disk. Show the new versions?"));
|
||
}
|
||
|
||
fn ask(app: &mut VectorcraftApp, ids: Vec<Value>, question: &str) {
|
||
if ids.is_empty() {
|
||
return;
|
||
}
|
||
if app.session.prefs.update_links != "askWhenModified" {
|
||
app.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", ids.len()));
|
||
return;
|
||
}
|
||
let detail = crate::i18n::fmt(question, &[("count", &ids.len().to_string())]);
|
||
super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids }));
|
||
}
|
||
|
||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
|
||
let Some(current) = missing.first() else { return true };
|
||
let rest = missing.len() - 1;
|
||
let name = current["name"].as_str().unwrap_or_default();
|
||
widgets::list_box(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.horizontal(|ui| {
|
||
ui.add_space(6.0);
|
||
ui.vertical(|ui| {
|
||
ui.add_space(3.0);
|
||
ui.label(egui::RichText::new(name).font(theme::semibold(12.0)).color(t.text_strong));
|
||
let path = current["path"].as_str().unwrap_or_default();
|
||
ui.add(egui::Label::new(egui::RichText::new(path).size(11.5).color(t.text_dim)).truncate()).on_hover_text(path);
|
||
ui.add_space(3.0);
|
||
});
|
||
});
|
||
});
|
||
ui.add_space(8.0);
|
||
widgets::dim_label(ui, tl!("Replace it with another file, or ignore it: its images keep showing the preview saved with the document."));
|
||
if crate::picks::can(app, &crate::picks::PickRequest::Folder) {
|
||
ui.add_space(8.0);
|
||
if ui.button(tl!("Find in Folder…")).clicked() {
|
||
let r = crate::picks::in_dialog(app, d, |app, d| match crate::picks::folder(app) {
|
||
Some(folder) => super::find_links::from_missing_links(app, d, &folder),
|
||
None => Ok(()),
|
||
});
|
||
if let Err(e) = r {
|
||
app.status(e);
|
||
}
|
||
}
|
||
}
|
||
if rest > 0 {
|
||
ui.add_space(10.0);
|
||
let all = d.bool("applyToAll");
|
||
let label = crate::i18n::fmt(tl!("Apply to All ({count} more missing)"), &[("count", &rest.to_string())]);
|
||
if widgets::check(ui, &label, all, true) {
|
||
d.fields.insert("applyToAll".into(), json!(!all));
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Replace (with `path`, else a picked file) or Ignore (`discard`) the first missing file, then
|
||
/// ask about the next. With `searchFolder`: Find in Folder… on that folder.
|
||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
let folder = d.str("searchFolder");
|
||
if !folder.is_empty() {
|
||
let mut d = d.clone();
|
||
// The folder is searched once: a search that doesn't start leaves the dialog without it.
|
||
if let Some(open) = app.ui.dialog.as_mut().filter(|open| open.kind == KIND) {
|
||
open.fields.remove("searchFolder");
|
||
}
|
||
super::find_links::from_missing_links(app, &mut d, &folder)?;
|
||
let state = d.fields.get("state").cloned().unwrap_or(Value::Null);
|
||
app.ui.dialog = Some(d);
|
||
return Ok(json!({ "state": state }));
|
||
}
|
||
let missing = d.fields.get("missing").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
let modified = d.fields.get("modified").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
let all = d.bool("applyToAll");
|
||
let Some((current, rest)) = missing.split_first() else {
|
||
app.ui.dialog = None;
|
||
return Ok(Value::Null);
|
||
};
|
||
let mut rest = rest.to_vec();
|
||
let mut out = Value::Null;
|
||
if d.bool("discard") {
|
||
if all {
|
||
rest.clear();
|
||
}
|
||
} else {
|
||
let path = match d.str("path") {
|
||
p if !p.is_empty() => p,
|
||
_ => {
|
||
let pick =
|
||
crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() };
|
||
crate::picks::open(app, &pick).ok_or("cancelled")?
|
||
}
|
||
};
|
||
out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?;
|
||
// Apply to All: the others by name in the same folder; any not there are asked about.
|
||
let folder = std::path::Path::new(&path).parent().map(|p| p.to_string_lossy().into_owned());
|
||
if let (true, false, Some(folder)) = (all, rest.is_empty(), folder) {
|
||
let r = app.run("links.relink", json!({ "ids": ids(&rest), "folder": folder }))?;
|
||
let done = r["relinked"].as_array().cloned().unwrap_or_default();
|
||
rest.retain(|row| row["ids"].as_array().is_none_or(|ids| ids.iter().any(|id| !done.contains(id))));
|
||
}
|
||
}
|
||
ask_next(app, rest, modified);
|
||
Ok(out)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use vectorcraft_engine::Session;
|
||
|
||
fn frame(app: &mut VectorcraftApp) -> String {
|
||
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
||
}
|
||
|
||
fn png(rgb: [u8; 3]) -> Vec<u8> {
|
||
let mut out = vec![];
|
||
let [r, g, b] = rgb;
|
||
image::RgbaImage::from_pixel(400, 300, image::Rgba([r, g, b, 255]))
|
||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||
.unwrap();
|
||
out
|
||
}
|
||
|
||
/// A document with two placed linked files, saved to a fresh folder; the files are then
|
||
/// deleted → (app, folder, document path).
|
||
fn opened_without_links(name: &str) -> (VectorcraftApp, std::path::PathBuf, String) {
|
||
let dir = std::env::temp_dir().join(format!("vectorcraft-missing-{name}-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
for n in ["a.png", "b.png"] {
|
||
let p = dir.join(n);
|
||
std::fs::write(&p, png([200, 0, 0])).unwrap();
|
||
app.run("file.place", json!({"path": p.to_string_lossy()})).unwrap();
|
||
}
|
||
let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||
app.run("document.save", json!({"path": doc})).unwrap();
|
||
for n in ["a.png", "b.png"] {
|
||
std::fs::remove_file(dir.join(n)).unwrap();
|
||
}
|
||
crate::io::open_path(&mut app, &doc).unwrap();
|
||
(app, dir, doc)
|
||
}
|
||
|
||
#[test]
|
||
fn opening_without_linked_files_asks_to_replace_or_ignore_each() {
|
||
let (mut app, dir, _) = opened_without_links("each");
|
||
let d = app.ui.dialog.as_ref().expect("the dialog");
|
||
assert_eq!((d.kind.as_str(), d.fields["missing"].as_array().unwrap().len()), (KIND, 2));
|
||
let text = frame(&mut app);
|
||
for label in ["Linked File Not Found", "a.png", "Apply to All (1 more missing)", "Replace…", "Ignore", "Cancel"] {
|
||
assert!(text.contains(label), "{label} in {text}");
|
||
}
|
||
// Replace the first with a file given by path (the file picker otherwise).
|
||
let blue = dir.join("new/blue.png");
|
||
std::fs::create_dir_all(blue.parent().unwrap()).unwrap();
|
||
std::fs::write(&blue, png([0, 0, 200])).unwrap();
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(blue.to_string_lossy()));
|
||
let r = super::super::confirm(&mut app).unwrap();
|
||
assert_eq!(r["relinked"].as_array().unwrap().len(), 1);
|
||
let d = app.ui.dialog.as_ref().expect("asks about the next");
|
||
assert_eq!(d.fields["missing"][0]["name"], "b.png");
|
||
assert!(!d.fields.contains_key("discard"));
|
||
assert!(frame(&mut app).contains("b.png"));
|
||
// Ignore: done, the image keeps its preview.
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||
super::super::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
let c = app.run("links.check", json!({})).unwrap();
|
||
let status: Vec<&str> = c["links"].as_array().unwrap().iter().map(|l| l["status"].as_str().unwrap()).collect();
|
||
assert_eq!((status, c["missing"].as_u64()), (vec!["ok", "missing"], Some(1)));
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn apply_to_all_replaces_the_others_from_the_same_folder() {
|
||
let (mut app, dir, _) = opened_without_links("all");
|
||
let found = dir.join("found");
|
||
std::fs::create_dir_all(&found).unwrap();
|
||
for n in ["a.png", "b.png"] {
|
||
std::fs::write(found.join(n), png([0, 0, 200])).unwrap();
|
||
}
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
d.fields.insert("applyToAll".into(), json!(true));
|
||
d.fields.insert("path".into(), json!(found.join("a.png").to_string_lossy()));
|
||
super::super::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none(), "both relinked");
|
||
let c = app.run("links.check", json!({})).unwrap();
|
||
assert_eq!(c["missing"], 0, "{c}");
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn modified_links_are_offered_for_update_when_the_preference_asks() {
|
||
let dir = std::env::temp_dir().join(format!("vectorcraft-modified-ui-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let pic = dir.join("a.png");
|
||
std::fs::write(&pic, png([200, 0, 0])).unwrap();
|
||
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
|
||
let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||
app.run("document.save", json!({"path": doc})).unwrap();
|
||
let original_stamp = std::fs::metadata(&pic).unwrap().modified().unwrap();
|
||
let mark_modified = |seconds| {
|
||
// Link stamps use milliseconds. Equal-size writes can finish within one tick;
|
||
// advance the fixture's mtime explicitly rather than relying on a sleep or load.
|
||
std::fs::OpenOptions::new()
|
||
.write(true)
|
||
.open(&pic)
|
||
.unwrap()
|
||
.set_times(std::fs::FileTimes::new().set_modified(original_stamp + std::time::Duration::from_secs(seconds)))
|
||
.unwrap();
|
||
};
|
||
std::fs::write(&pic, png([0, 0, 200])).unwrap();
|
||
mark_modified(2);
|
||
crate::io::open_path(&mut app, &doc).unwrap();
|
||
let d = app.ui.dialog.as_ref().expect("asks");
|
||
assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string()));
|
||
super::super::confirm(&mut app).unwrap();
|
||
assert_eq!(app.run("links.check", json!({})).unwrap()["modified"], 0);
|
||
// Manually: no question.
|
||
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
|
||
std::fs::write(&pic, png([0, 200, 0])).unwrap();
|
||
mark_modified(4);
|
||
crate::io::open_path(&mut app, &doc).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
assert_eq!(app.run("links.check", json!({})).unwrap()["modified"], 1);
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
}
|