mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -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.
754 lines
37 KiB
Rust
754 lines
37 KiB
Rust
//! The fonts a document opened without (`text.missingFonts`), listed once the missing linked file
|
||
//! questions (dialog `missingLinks`) are answered, one document at a time ([`super::settle`]), with
|
||
//! Find Fonts (Type › Find Font on the first of them, to replace it) and Close. Neither brings the
|
||
//! dialog back for that open. Search Fonts Folder (the `Fonts` folder next to the document, as
|
||
//! File › Package writes it) and Find in Folder… look for their files (`text.findFontFiles`, on
|
||
//! separate threads), and Add Fonts copies the files the user chooses into VectorCraft's own Fonts
|
||
//! folder (`text.addFontFiles`). Type › Find Font's Find in Folder… opens it too
|
||
//! (`ui.findFontsInFolder`), and so does `ui.missingFontsDialog`.
|
||
//!
|
||
//! Fields: `document` (the document's uid), `fonts` (`text.missingFonts`'s rows),
|
||
//! `fontsNextToDocument` (or empty), `state` (empty, `searching`, `done`, `stopped` or `failed`),
|
||
//! `search` (the search's `id`), `folder`, `found` (`text.findFontFiles`'s `fonts`, with the files
|
||
//! found), `searched`, `skipped`, `unreadable`, `stopped`, `error`, `chosen` (the files to add; a
|
||
//! font's first file is chosen when it is found), `searchFolder` (set it and confirm to search that
|
||
//! folder) and `discard` (set it and confirm for Find Fonts; confirming without either adds the
|
||
//! chosen files).
|
||
|
||
use std::time::Duration;
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use super::DialogSpec;
|
||
use crate::picks::PickRequest;
|
||
use crate::state::Dialog;
|
||
use crate::theme::{self, Tokens};
|
||
use crate::{VectorcraftApp, widgets};
|
||
|
||
/// The dialog kind of the fonts a document opened without.
|
||
pub const KIND: &str = "missingFonts";
|
||
|
||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||
heading: |_| tl!("Missing Fonts").into(),
|
||
body,
|
||
confirm,
|
||
// Close, with Find Fonts beside it; Add Fonts is a button of the body.
|
||
ok: None,
|
||
discard: Some("Find Fonts"),
|
||
min_width: 440.0,
|
||
max_width: Some(540.0),
|
||
inputs: |_| &["searchFolder"],
|
||
..DialogSpec::FORM
|
||
};
|
||
|
||
/// After a document opened (`document.open`'s result `r`): its Missing Fonts dialog waits for the
|
||
/// dialog slot. On the web the status line shows how many fonts are missing instead, unless it
|
||
/// shows the open's notes.
|
||
pub fn after_open(app: &mut VectorcraftApp, r: &Value) {
|
||
after_open_for(app, r, cfg!(target_arch = "wasm32"));
|
||
}
|
||
|
||
fn after_open_for(app: &mut VectorcraftApp, r: &Value, web: bool) {
|
||
let Some(st) = app.session.active() else { return };
|
||
let uid = st.uid;
|
||
if web {
|
||
let n = vectorcraft_engine::cmd::fontfiles::missing_fonts(&st.doc).len();
|
||
if n > 0 && r["warnings"].as_array().is_none_or(Vec::is_empty) {
|
||
app.status(format!("{n} font(s) aren't available here: their text shows in another font"));
|
||
}
|
||
return;
|
||
}
|
||
if !app.ui.pending_fonts.contains(&uid) {
|
||
app.ui.pending_fonts.push(uid);
|
||
}
|
||
}
|
||
|
||
/// The Missing Fonts dialog of the open document `uid`, when fonts it uses aren't available.
|
||
fn dialog_for(app: &VectorcraftApp, uid: u64) -> Option<Dialog> {
|
||
let st = app.session.documents().iter().find(|d| d.uid == uid)?;
|
||
let r = vectorcraft_engine::cmd::fontfiles::missing_fonts_of(st, app.session.search_rules.as_ref());
|
||
let fonts = r["fonts"].as_array().filter(|f| !f.is_empty())?;
|
||
let next = r["fontsNextToDocument"].as_str().unwrap_or_default();
|
||
Some(Dialog::new(KIND, json!({ "document": uid, "fonts": fonts, "fontsNextToDocument": next, "state": "", "chosen": [] })))
|
||
}
|
||
|
||
/// With no dialog open: the dialog of the first queued document that is still open and still
|
||
/// misses fonts.
|
||
pub(super) fn open_next(app: &mut VectorcraftApp) {
|
||
while app.ui.dialog.is_none() && !app.ui.pending_fonts.is_empty() {
|
||
let uid = app.ui.pending_fonts.remove(0);
|
||
app.ui.dialog = dialog_for(app, uid);
|
||
if app.ui.dialog.is_some() {
|
||
app.ui.fonts_dialog = Some(uid);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Put the document `uid` at the front of the queue.
|
||
fn queue_first(app: &mut VectorcraftApp, uid: u64) {
|
||
app.ui.pending_fonts.retain(|u| *u != uid);
|
||
app.ui.pending_fonts.insert(0, uid);
|
||
}
|
||
|
||
/// Once the Missing Fonts dialog on show is gone: when another dialog took its place (Import PDF
|
||
/// for a file opened after the document, for example), its document goes back to the front of the
|
||
/// queue, and the dialog opens again once no dialog is open. A dialog closed by Cancel, Add Fonts
|
||
/// or Escape stays closed.
|
||
pub(super) fn requeue_replaced(app: &mut VectorcraftApp) {
|
||
let Some(uid) = app.ui.fonts_dialog else { return };
|
||
let Some(d) = &app.ui.dialog else {
|
||
app.ui.fonts_dialog = None;
|
||
return;
|
||
};
|
||
if d.kind != KIND || d.fields.get("document").and_then(Value::as_u64) != Some(uid) {
|
||
app.ui.fonts_dialog = None;
|
||
queue_first(app, uid);
|
||
}
|
||
}
|
||
|
||
/// Stop the search the dialog started once the dialog is gone (closed, or another in its place).
|
||
pub(super) fn stop_when_closed(app: &mut VectorcraftApp) {
|
||
let Some(id) = app.ui.dialog_search else { return };
|
||
let open = app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND && d.fields.get("search").and_then(Value::as_u64) == Some(id));
|
||
if !open {
|
||
app.ui.dialog_search = None;
|
||
vectorcraft_engine::cmd::fontfiles::stop(&mut app.session, id);
|
||
}
|
||
}
|
||
|
||
/// `ui.missingFontsDialog`: the dialog for the active document; with `folder`, searching it.
|
||
pub(crate) fn open_command(app: &mut VectorcraftApp, folder: Option<String>) -> Result<Value, String> {
|
||
let uid = app.session.active().map(|d| d.uid).ok_or("no document open")?;
|
||
let mut d = dialog_for(app, uid).ok_or("No fonts are missing in this document")?;
|
||
if let Some(folder) = folder {
|
||
start_search(app, &mut d, &folder)?;
|
||
}
|
||
show_for(app, uid, d);
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// Show `d`, the dialog of the document `uid`, in place of the open dialog (another document's
|
||
/// Missing Fonts dialog asks again later).
|
||
fn show_for(app: &mut VectorcraftApp, uid: u64, d: Dialog) {
|
||
requeue_replaced(app);
|
||
if let Some(shown) = app.ui.fonts_dialog.filter(|shown| *shown != uid) {
|
||
queue_first(app, shown);
|
||
}
|
||
app.ui.pending_fonts.retain(|u| *u != uid);
|
||
app.ui.fonts_dialog = Some(uid);
|
||
app.ui.dialog = Some(d);
|
||
}
|
||
|
||
/// `ui.findFontsInFolder` (Type › Find Font's Find in Folder…): ask for a folder (or take
|
||
/// `folder`), then open the dialog for the active document, searching it.
|
||
pub(crate) fn find_in_folder_command(app: &mut VectorcraftApp, folder: Option<String>) -> Result<Value, String> {
|
||
let uid = app.session.active().map(|d| d.uid).ok_or("no document open")?;
|
||
// Nothing to look for: no folder is asked for.
|
||
let mut d = dialog_for(app, uid).ok_or("No fonts are missing in this document")?;
|
||
let folder = match folder {
|
||
Some(f) => f,
|
||
None => crate::picks::folder(app).ok_or("cancelled")?,
|
||
};
|
||
start_search(app, &mut d, &folder)?;
|
||
show_for(app, uid, d);
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// Start looking in `folder` for the files of the dialog's fonts; what an earlier search found is
|
||
/// dropped.
|
||
fn start_search(app: &mut VectorcraftApp, d: &mut Dialog, folder: &str) -> Result<(), String> {
|
||
let fonts: Vec<Value> = d
|
||
.fields
|
||
.get("fonts")
|
||
.and_then(Value::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
.map(|f| json!({ "family": f["family"], "style": f["style"] }))
|
||
.collect();
|
||
let r = app.run("text.findFontFiles", json!({ "folder": folder, "fonts": fonts }))?;
|
||
for k in ["found", "searched", "skipped", "unreadable", "stopped", "error"] {
|
||
d.fields.remove(k);
|
||
}
|
||
d.fields.insert("chosen".into(), json!([]));
|
||
d.fields.insert("search".into(), r["id"].clone());
|
||
d.fields.insert("folder".into(), json!(folder));
|
||
update(d, &r);
|
||
app.ui.dialog_search = r["id"].as_u64();
|
||
Ok(())
|
||
}
|
||
|
||
/// The files a search found for the font `f` (`{family, style}`): the answer's `fonts` row with
|
||
/// the same family and style.
|
||
fn files_for<'a>(found: &'a [Value], f: &Value) -> &'a [Value] {
|
||
found.iter().find(|r| r["family"] == f["family"] && r["style"] == f["style"]).and_then(|r| r["files"].as_array()).map_or(&[], Vec::as_slice)
|
||
}
|
||
|
||
/// A search's answer `r` into the dialog. A font that gets its first file has that file chosen; a
|
||
/// file the user unchecked stays unchecked.
|
||
fn update(d: &mut Dialog, r: &Value) {
|
||
let before = d.fields.get("found").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
let mut chosen = d.fields.get("chosen").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
for font in r["fonts"].as_array().into_iter().flatten() {
|
||
let had = !files_for(&before, font).is_empty();
|
||
if let Some(first) = font["files"].as_array().and_then(|f| f.first()).filter(|f| !had && !chosen.contains(f)) {
|
||
chosen.push(first.clone());
|
||
}
|
||
}
|
||
d.fields.insert("chosen".into(), json!(chosen));
|
||
d.fields.insert("found".into(), r["fonts"].clone());
|
||
for k in ["state", "searched", "skipped", "unreadable", "stopped", "error"] {
|
||
match r.get(k) {
|
||
Some(v) => d.fields.insert(k.into(), v.clone()),
|
||
None => d.fields.remove(k),
|
||
};
|
||
}
|
||
}
|
||
|
||
/// While searching: the search's progress into the dialog. A search started elsewhere since
|
||
/// stopped this one.
|
||
fn follow(app: &mut VectorcraftApp, d: &mut Dialog) {
|
||
// Polled through `session.execute`.
|
||
let Ok(r) = app.session.execute("text.findFontFiles", &json!({})) else { return };
|
||
if d.fields.get("search") != Some(&r["id"]) {
|
||
d.fields.insert("state".into(), json!("stopped"));
|
||
return;
|
||
}
|
||
update(d, &r);
|
||
if let Some(e) = r["error"].as_str() {
|
||
app.status(e);
|
||
}
|
||
}
|
||
|
||
/// A button of the dialog's.
|
||
enum Act {
|
||
Search(String),
|
||
Pick,
|
||
Stop,
|
||
Add,
|
||
}
|
||
|
||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
if d.str("state") == "searching" {
|
||
follow(app, d);
|
||
ui.ctx().request_repaint_after(Duration::from_millis(100));
|
||
}
|
||
let state = d.str("state");
|
||
let searching = state == "searching";
|
||
widgets::dim_label(ui, tl!("These fonts aren't available. Their text shows in another font."));
|
||
ui.add_space(8.0);
|
||
let fonts = d.fields.get("fonts").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
let found = d.fields.get("found").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
let mut chosen = d.fields.get("chosen").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
widgets::list_box(ui, |ui| {
|
||
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
for f in &fonts {
|
||
ui.horizontal(|ui| {
|
||
ui.add_space(6.0);
|
||
ui.vertical(|ui| {
|
||
ui.add_space(3.0);
|
||
let (note, color) = crate::find_font::font_note(f);
|
||
let name = format!("{} {}{note}", f["family"].as_str().unwrap_or_default(), f["style"].as_str().unwrap_or_default());
|
||
ui.label(egui::RichText::new(name).font(theme::semibold(12.0)).color(color.unwrap_or(t.text_strong)));
|
||
let files = files_for(&found, f);
|
||
for file in files {
|
||
let path = file.as_str().unwrap_or_default();
|
||
let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned());
|
||
let on = chosen.contains(file);
|
||
if widgets::check(ui, &name, on, true) {
|
||
if on {
|
||
chosen.retain(|c| c != file);
|
||
} else {
|
||
chosen.push(file.clone());
|
||
}
|
||
}
|
||
ui.add(egui::Label::new(egui::RichText::new(path).size(11.0).color(t.text_dim)).truncate()).on_hover_text(path);
|
||
}
|
||
// A failed search (a folder it refuses) didn't look: `progress` says why.
|
||
if files.is_empty() && !state.is_empty() && state != "failed" {
|
||
widgets::dim_label(ui, if searching { tl!("Searching…") } else { tl!("Not found") });
|
||
}
|
||
ui.add_space(3.0);
|
||
});
|
||
});
|
||
}
|
||
});
|
||
});
|
||
d.fields.insert("chosen".into(), json!(chosen));
|
||
ui.add_space(10.0);
|
||
let mut act = None;
|
||
let can_pick = crate::picks::can(app, &PickRequest::Folder);
|
||
ui.horizontal(|ui| {
|
||
let next = d.str("fontsNextToDocument");
|
||
if !next.is_empty() && ui.add_enabled(!searching, egui::Button::new(tl!("Search Fonts Folder"))).on_hover_text(&next).clicked() {
|
||
act = Some(Act::Search(next));
|
||
}
|
||
if can_pick && ui.add_enabled(!searching, egui::Button::new(tl!("Find in Folder…"))).clicked() {
|
||
act = Some(Act::Pick);
|
||
}
|
||
if searching {
|
||
if ui.button(tl!("Stop")).clicked() {
|
||
act = Some(Act::Stop);
|
||
}
|
||
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
|
||
}
|
||
});
|
||
progress(ui, d);
|
||
// Once files are found: what copying them means, and Add Fonts.
|
||
if found.iter().any(|f| f["files"].as_array().is_some_and(|files| !files.is_empty())) {
|
||
ui.add_space(8.0);
|
||
widgets::dim_label(ui, tl!("Copy only fonts you own or are licensed to install."));
|
||
if let Some(dir) = vectorcraft_text::app_font_dir() {
|
||
widgets::dim_name(ui, &crate::i18n::fmt(tl!("VectorCraft copies the fonts to {folder}."), &[("folder", &dir.to_string_lossy())]));
|
||
}
|
||
ui.add_space(6.0);
|
||
if ui.add_enabled(!searching && !chosen.is_empty(), egui::Button::new(tl!("Add Fonts"))).clicked() {
|
||
act = Some(Act::Add);
|
||
}
|
||
}
|
||
let r = match act {
|
||
Some(Act::Search(folder)) => start_search(app, d, &folder),
|
||
Some(Act::Pick) => crate::picks::in_dialog(app, d, |app, d| match crate::picks::folder(app) {
|
||
Some(folder) => start_search(app, d, &folder),
|
||
None => Ok(()),
|
||
}),
|
||
Some(Act::Stop) => app.run("text.findFontFiles", json!({ "stop": true })).map(|_| follow(app, d)),
|
||
// Added: the dialog closes.
|
||
Some(Act::Add) => match add_fonts(app, d) {
|
||
Ok(_) => return true,
|
||
Err(e) => Err(e),
|
||
},
|
||
None => Ok(()),
|
||
};
|
||
if let Err(e) = r {
|
||
app.status(e);
|
||
}
|
||
false
|
||
}
|
||
|
||
/// A folder search's progress under the dialog's buttons (fields `state`, `folder`, `searched`,
|
||
/// `skipped`, `unreadable`, `stopped` and `error`), in Find Linked Files too.
|
||
pub(super) fn progress(ui: &mut egui::Ui, d: &Dialog) {
|
||
if d.str("state") == "searching" {
|
||
widgets::dim_name(ui, &d.str("folder"));
|
||
}
|
||
if let Some(e) = d.fields.get("error").and_then(Value::as_str) {
|
||
widgets::dim_name(ui, &crate::i18n::message(crate::i18n::current(), e));
|
||
}
|
||
if let Some(s) = d.fields.get("searched") {
|
||
let (folders, files) = (s["folders"].to_string(), s["files"].to_string());
|
||
widgets::dim_name(
|
||
ui,
|
||
&crate::i18n::fmt(tl!("{folders} folder(s) and {files} file(s) searched"), &[("folders", &folders), ("files", &files)]),
|
||
);
|
||
}
|
||
let count = |k: &str| d.fields.get(k).and_then(Value::as_u64).filter(|n| *n > 0).map(|n| n.to_string());
|
||
if let Some(n) = count("skipped") {
|
||
let text = tl!("{count} folder(s) skipped, such as other apps' and the system's folders and hidden ones");
|
||
widgets::dim_name(ui, &crate::i18n::fmt(text, &[("count", &n)]));
|
||
}
|
||
if let Some(n) = count("unreadable") {
|
||
widgets::dim_name(ui, &crate::i18n::fmt(tl!("{count} folder(s) or file(s) couldn't be read"), &[("count", &n)]));
|
||
}
|
||
if matches!(d.fields.get("stopped").and_then(Value::as_str), Some("time" | "limit")) {
|
||
widgets::dim_label(ui, tl!("The search stopped at its limit: search a smaller folder."));
|
||
}
|
||
}
|
||
|
||
/// Find Fonts (`discard`): Type › Find Font on the first font the document misses, to replace it;
|
||
/// this Missing Fonts dialog doesn't come back. Otherwise (agents), with `searchFolder`: search that
|
||
/// folder, and stay open; else Add Fonts.
|
||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
if d.bool("discard") {
|
||
app.ui.fonts_dialog = None;
|
||
// Find Font works on the active document; the dialog's may be another tab.
|
||
let uid = d.fields.get("document").and_then(Value::as_u64);
|
||
let index = app.session.documents().iter().position(|doc| Some(doc.uid) == uid).ok_or("that document is no longer open")?;
|
||
app.run("document.activate", json!({ "index": index }))?;
|
||
crate::find_font::open_on_missing(app);
|
||
return Ok(Value::Null);
|
||
}
|
||
let folder = d.str("searchFolder");
|
||
if !folder.is_empty() {
|
||
let mut d = d.clone();
|
||
d.fields.remove("searchFolder");
|
||
// 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");
|
||
}
|
||
start_search(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 }));
|
||
}
|
||
add_fonts(app, d)
|
||
}
|
||
|
||
/// Add Fonts: copy the chosen files into VectorCraft's Fonts folder and close.
|
||
fn add_fonts(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
let chosen = d.fields.get("chosen").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
if chosen.is_empty() {
|
||
return Err("choose the font files to add".into());
|
||
}
|
||
let r = app.run("text.addFontFiles", json!({ "files": chosen }))?;
|
||
app.ui.dialog = None;
|
||
let len = |k: &str| r[k].as_array().map_or(0, Vec::len);
|
||
let n = len("copied") + len("kept");
|
||
let folder = r["folder"].as_str().unwrap_or_default();
|
||
match r["skipped"].as_array().and_then(|s| s.first()) {
|
||
None => app.status(format!("{n} font file(s) are now in {folder}")),
|
||
Some(first) => {
|
||
let failed = len("skipped");
|
||
let file =
|
||
first["file"].as_str().map(std::path::Path::new).and_then(std::path::Path::file_name).map(|f| f.to_string_lossy().into_owned());
|
||
let reason = format!("{}: {}", file.unwrap_or_default(), first["reason"].as_str().unwrap_or_default());
|
||
app.status(format!("{n} font file(s) are now in {folder}; {failed} couldn't be added: {reason}"));
|
||
}
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::Instant;
|
||
|
||
use vectorcraft_engine::Session;
|
||
use vectorcraft_engine::cmd::findfiles::Rules;
|
||
|
||
use super::*;
|
||
|
||
fn frame(app: &mut VectorcraftApp) -> String {
|
||
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
||
}
|
||
|
||
/// A fresh temporary folder (canonical, as a search reports it).
|
||
fn folder(tag: &str) -> PathBuf {
|
||
let dir = vectorcraft_testkit::temp_dir(&format!("missing-fonts-{tag}"));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap())
|
||
}
|
||
|
||
/// An app that reads files and whose searches may go anywhere in `root`, on two threads.
|
||
fn app_for(root: &Path) -> VectorcraftApp {
|
||
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.session.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() });
|
||
app.session.search_threads = Some(2);
|
||
app
|
||
}
|
||
|
||
fn png() -> Vec<u8> {
|
||
let mut out = vec![];
|
||
image::RgbaImage::from_pixel(40, 30, image::Rgba([200, 0, 0, 255]))
|
||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||
.unwrap();
|
||
out
|
||
}
|
||
|
||
/// Save a new document with type in `font` at `path`, with a linked image that is then deleted
|
||
/// when `link`.
|
||
fn save_doc(app: &mut VectorcraftApp, path: &Path, font: &str, link: bool) {
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let pic = path.with_extension("png");
|
||
if link {
|
||
std::fs::write(&pic, png()).unwrap();
|
||
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
|
||
}
|
||
app.run("text.create", json!({"x": 10, "y": 40, "text": "Type", "font": font})).unwrap();
|
||
app.run("document.save", json!({"path": path.to_string_lossy()})).unwrap();
|
||
if link {
|
||
std::fs::remove_file(&pic).unwrap();
|
||
}
|
||
}
|
||
|
||
fn open(app: &mut VectorcraftApp, path: &Path) {
|
||
crate::io::open_path(app, &path.to_string_lossy()).unwrap();
|
||
}
|
||
|
||
fn kind(app: &VectorcraftApp) -> Option<&str> {
|
||
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
|
||
}
|
||
|
||
#[test]
|
||
fn missing_fonts_are_asked_about_after_the_missing_links() {
|
||
let root = folder("after-links");
|
||
let mut app = app_for(&root);
|
||
let doc = root.join("doc.vectorcraft");
|
||
save_doc(&mut app, &doc, "No Such Font Family", true);
|
||
open(&mut app, &doc);
|
||
assert_eq!(kind(&app), Some(super::super::missing_links::KIND));
|
||
super::super::cancel(&mut app);
|
||
let d = app.ui.dialog.as_ref().expect("Missing Fonts after the linked files");
|
||
assert_eq!((d.kind.as_str(), d.fields["fonts"][0]["family"].as_str()), (KIND, Some("No Such Font Family")));
|
||
let text = frame(&mut app);
|
||
for label in
|
||
["Missing Fonts", "No Such Font Family Regular", "These fonts aren't available. Their text shows in another font.", "Find Fonts", "Close"]
|
||
{
|
||
assert!(text.contains(label), "{label} in {text}");
|
||
}
|
||
assert!(!text.contains("Search Fonts Folder"), "no Fonts folder next to the document");
|
||
assert!(!text.contains("Add Fonts") && !text.contains("Copy only fonts"), "no file found yet: {text}");
|
||
assert_eq!(super::super::confirm(&mut app).err().as_deref(), Some("choose the font files to add"));
|
||
assert_eq!(kind(&app), Some(KIND), "nothing to add: the dialog stays open");
|
||
super::super::cancel(&mut app);
|
||
assert!(app.ui.dialog.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn the_documents_fonts_folder_is_offered_and_searched_off_the_ui_thread() {
|
||
let root = folder("package");
|
||
let font = root.join("pkg").join("Fonts").join("sub").join("Findme.otf");
|
||
std::fs::create_dir_all(font.parent().unwrap()).unwrap();
|
||
std::fs::write(&font, vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap();
|
||
let mut app = app_for(&root);
|
||
let doc = root.join("pkg/doc.vectorcraft");
|
||
save_doc(&mut app, &doc, "Findme Sans 3", false);
|
||
open(&mut app, &doc);
|
||
let next = app.ui.dialog.as_ref().filter(|d| d.kind == KIND).expect("Missing Fonts at once: no links missing").str("fontsNextToDocument");
|
||
assert_eq!(next, root.join("pkg").join("Fonts").to_string_lossy());
|
||
assert!(frame(&mut app).contains("Search Fonts Folder"));
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(next));
|
||
let r = super::super::confirm(&mut app).unwrap();
|
||
assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}");
|
||
assert_eq!(kind(&app), Some(KIND), "the dialog stays open while it searches");
|
||
let until = Instant::now() + Duration::from_secs(10);
|
||
while app.ui.dialog.as_ref().is_some_and(|d| d.str("state") == "searching") && Instant::now() < until {
|
||
frame(&mut app);
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
}
|
||
let d = app.ui.dialog.as_ref().unwrap();
|
||
assert_eq!((d.str("state"), &d.fields["found"][0]["files"]), ("done".to_string(), &json!([font])));
|
||
assert_eq!(d.fields["chosen"], json!([font]), "the font's file is chosen");
|
||
let text = frame(&mut app);
|
||
for label in ["Findme.otf", "Copy only fonts you own or are licensed to install.", "Add Fonts"] {
|
||
assert!(text.contains(label), "{label} in {text}");
|
||
}
|
||
// Adding needs VectorCraft's Fonts folder, which only the apps set.
|
||
let e = super::super::confirm(&mut app).unwrap_err();
|
||
assert!(e.contains("isn't set here"), "{e}");
|
||
assert_eq!(kind(&app), Some(KIND));
|
||
}
|
||
|
||
/// Find Fonts opens Type › Find Font on the first missing font, to replace it, and the Missing
|
||
/// Fonts dialog doesn't come back for that open.
|
||
#[test]
|
||
fn find_fonts_opens_find_font_on_the_missing_font() {
|
||
let root = folder("find-fonts");
|
||
let mut app = app_for(&root);
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("text.create", json!({"x": 10, "y": 40, "text": "Here", "font": "Source Sans 3"})).unwrap();
|
||
app.run("text.create", json!({"x": 10, "y": 80, "text": "Gone", "font": "Zz Missing Family"})).unwrap();
|
||
let doc = root.join("doc.vectorcraft");
|
||
app.run("document.save", json!({"path": doc.to_string_lossy()})).unwrap();
|
||
open(&mut app, &doc);
|
||
assert_eq!(kind(&app), Some(KIND));
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||
super::super::confirm(&mut app).unwrap();
|
||
let d = app.ui.dialog.as_ref().expect("Find Font");
|
||
assert_eq!(d.kind, "findFont");
|
||
let fonts = app.session.execute("text.fonts", &json!({})).unwrap();
|
||
let selected = d.fields["selected"].as_u64().unwrap() as usize;
|
||
assert!(selected > 0 && fonts[selected]["family"] == "Zz Missing Family", "{selected} in {fonts}");
|
||
// Find Font closed: no Missing Fonts dialog follows.
|
||
app.ui.dialog = None;
|
||
frame(&mut app);
|
||
assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty());
|
||
}
|
||
|
||
/// The dialog of a document in another tab: Find Fonts makes that document the active one, so
|
||
/// Find Font lists and replaces its fonts.
|
||
#[test]
|
||
fn find_fonts_works_on_the_dialog_s_own_document() {
|
||
let root = folder("find-fonts-tab");
|
||
let mut app = app_for(&root);
|
||
let (a, b) = (root.join("a.vectorcraft"), root.join("b.vectorcraft"));
|
||
save_doc(&mut app, &a, "Alpha Missing Family", false);
|
||
save_doc(&mut app, &b, "Beta Missing Family", true);
|
||
open(&mut app, &a);
|
||
open(&mut app, &b);
|
||
// B's linked files are asked about first; then A's fonts, with B the active tab.
|
||
super::super::cancel(&mut app);
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
assert_eq!(d.fields["fonts"][0]["family"], "Alpha Missing Family");
|
||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*b.to_string_lossy()));
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||
super::super::confirm(&mut app).unwrap();
|
||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*a.to_string_lossy()));
|
||
let fonts = app.session.execute("text.fonts", &json!({})).unwrap();
|
||
let selected = app.ui.dialog.as_ref().unwrap().fields["selected"].as_u64().unwrap() as usize;
|
||
assert_eq!(fonts[selected]["family"], "Alpha Missing Family", "{fonts}");
|
||
}
|
||
|
||
#[test]
|
||
fn closing_the_dialog_stops_its_search() {
|
||
let root = folder("stop");
|
||
// Enough folders that the search usually still runs when the dialog closes.
|
||
for a in 0..40 {
|
||
for b in 0..40 {
|
||
std::fs::create_dir_all(root.join(format!("f{a}/g{b}"))).unwrap();
|
||
}
|
||
}
|
||
let mut app = app_for(&root);
|
||
app.session.search_threads = Some(1);
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap();
|
||
app.run("ui.missingFontsDialog", json!({"folder": root})).unwrap();
|
||
let id = app.ui.dialog_search.expect("the dialog's search");
|
||
super::super::cancel(&mut app);
|
||
assert!(app.ui.dialog.is_none() && app.ui.dialog_search.is_none());
|
||
// Stopped by the close, unless the walker finished the folders first.
|
||
let r = app.session.execute("text.findFontFiles", &json!({})).unwrap();
|
||
assert_eq!(r["id"].as_u64(), Some(id), "{r}");
|
||
match r["state"].as_str() {
|
||
Some("stopped") => assert_eq!(r["stopped"], "stop", "{r}"),
|
||
state => assert_eq!(state, Some("done"), "{r}"),
|
||
}
|
||
// Without missing fonts there is nothing to open.
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
assert_eq!(app.run("ui.missingFontsDialog", json!({})).err().as_deref(), Some("No fonts are missing in this document"));
|
||
}
|
||
|
||
#[test]
|
||
fn one_dialog_at_a_time_document_by_document() {
|
||
let root = folder("queue");
|
||
let mut app = app_for(&root);
|
||
let (a, b) = (root.join("a.vectorcraft"), root.join("b.vectorcraft"));
|
||
save_doc(&mut app, &a, "Alpha Missing Family", false);
|
||
save_doc(&mut app, &b, "Beta Missing Family", true);
|
||
open(&mut app, &a);
|
||
let family = |app: &VectorcraftApp| app.ui.dialog.as_ref().and_then(|d| d.fields["fonts"][0]["family"].as_str().map(str::to_string));
|
||
assert_eq!(family(&app).as_deref(), Some("Alpha Missing Family"));
|
||
// The second document's linked files are asked about first; then each document's fonts.
|
||
open(&mut app, &b);
|
||
assert_eq!(kind(&app), Some(super::super::missing_links::KIND));
|
||
super::super::cancel(&mut app);
|
||
assert_eq!(family(&app).as_deref(), Some("Alpha Missing Family"));
|
||
super::super::cancel(&mut app);
|
||
assert_eq!(family(&app).as_deref(), Some("Beta Missing Family"));
|
||
super::super::cancel(&mut app);
|
||
assert!(app.ui.dialog.is_none());
|
||
// A document closed before its turn is passed over.
|
||
open(&mut app, &a);
|
||
open(&mut app, &b);
|
||
let index = app.session.documents().iter().rposition(|d| d.path.as_deref() == Some(&*a.to_string_lossy())).unwrap();
|
||
app.session.execute("file.close", &json!({"index": index})).unwrap();
|
||
super::super::cancel(&mut app);
|
||
assert_eq!(family(&app).as_deref(), Some("Beta Missing Family"));
|
||
}
|
||
|
||
/// Another dialog that takes the Missing Fonts dialog's place (Import PDF for a PDF dropped
|
||
/// with the document) hands the question back once it closes.
|
||
#[test]
|
||
fn the_question_comes_back_after_a_dialog_that_took_its_place() {
|
||
let root = folder("replaced");
|
||
let mut app = app_for(&root);
|
||
let doc = root.join("a.vectorcraft");
|
||
save_doc(&mut app, &doc, "Alpha Missing Family", false);
|
||
let pdf = || {
|
||
let page = || vectorcraft_testkit::pdf::PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f");
|
||
vectorcraft_testkit::pdf::pdf(&[page(), page()], None)
|
||
};
|
||
let opened = |name: &str, path: Option<String>, bytes: Vec<u8>| (crate::place::DropTarget::Open, (name.to_string(), path, bytes));
|
||
let files =
|
||
vec![opened("a.vectorcraft", Some(doc.to_string_lossy().into_owned()), std::fs::read(&doc).unwrap()), opened("b.pdf", None, pdf())];
|
||
crate::place::drop_files(&mut app, files);
|
||
assert_eq!(kind(&app), Some(super::super::import_pdf::KIND));
|
||
super::super::cancel(&mut app);
|
||
let family = |app: &VectorcraftApp| app.ui.dialog.as_ref().filter(|d| d.kind == KIND).map(|d| d.fields["fonts"][0]["family"].clone());
|
||
assert_eq!(family(&app), Some(json!("Alpha Missing Family")));
|
||
// The same when a frame drew the other dialog before it closed.
|
||
crate::io::open_bytes(&mut app, "c.pdf", &pdf(), None).unwrap();
|
||
assert_eq!(kind(&app), Some(super::super::import_pdf::KIND));
|
||
frame(&mut app);
|
||
super::super::cancel(&mut app);
|
||
assert_eq!(family(&app), Some(json!("Alpha Missing Family")));
|
||
// Closed by Cancel, or by Escape (which empties the dialog slot), it stays closed.
|
||
super::super::cancel(&mut app);
|
||
frame(&mut app);
|
||
assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty());
|
||
app.run("ui.missingFontsDialog", json!({})).unwrap();
|
||
app.ui.dialog = None;
|
||
frame(&mut app);
|
||
assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty());
|
||
}
|
||
|
||
/// Type in a CSS generic family (`sans-serif`, the default font of SVG files from some editors)
|
||
/// opens without the question.
|
||
#[test]
|
||
fn generic_families_are_not_asked_about() {
|
||
let mut app = app_for(&folder("generic"));
|
||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><text x="10" y="40" font-family="sans-serif">A</text></svg>"#;
|
||
crate::io::open_bytes(&mut app, "generic.svg", svg.as_bytes(), None).unwrap();
|
||
let listed = app.session.execute("text.fonts", &json!({})).unwrap();
|
||
assert!(listed.as_array().unwrap().iter().any(|f| f["family"] == "sans-serif"), "{listed}");
|
||
frame(&mut app);
|
||
assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty());
|
||
}
|
||
|
||
/// A `searchFolder` whose search doesn't start is dropped from the dialog: OK doesn't try it
|
||
/// again.
|
||
#[test]
|
||
fn a_search_folder_that_fails_is_not_tried_again() {
|
||
let root = folder("retry");
|
||
let mut app = app_for(&root);
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap();
|
||
app.run("ui.missingFontsDialog", json!({})).unwrap();
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!("relative/folder"));
|
||
let e = super::super::confirm(&mut app).unwrap_err();
|
||
assert!(e.contains("absolute"), "{e}");
|
||
assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("searchFolder"));
|
||
assert_eq!(super::super::confirm(&mut app).err().as_deref(), Some("choose the font files to add"));
|
||
}
|
||
|
||
/// A placed document that Links › Edit Original opens asks about its fonts, as File › Open does.
|
||
#[test]
|
||
fn edit_original_asks_about_the_placed_documents_fonts() {
|
||
let root = folder("edit-original");
|
||
let mut app = app_for(&root);
|
||
let badge = root.join("badge.vectorcraft");
|
||
save_doc(&mut app, &badge, "Badge Missing Family", false);
|
||
app.session.execute("file.close", &json!({})).unwrap();
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("file.place", json!({"path": badge.to_string_lossy(), "link": true})).unwrap();
|
||
let tabs = app.session.documents().len();
|
||
app.run("links.editOriginal", json!({})).unwrap();
|
||
assert_eq!(app.session.documents().len(), tabs + 1);
|
||
let d = app.ui.dialog.as_ref().expect("Missing Fonts for the document opened");
|
||
assert_eq!((d.kind.as_str(), d.fields["fonts"][0]["family"].as_str()), (KIND, Some("Badge Missing Family")));
|
||
}
|
||
|
||
/// A search leaves out the fonts that became available: the files it found go with their fonts
|
||
/// by name, and a file the user unchecked stays unchecked as the search goes on.
|
||
#[test]
|
||
fn files_go_with_their_fonts_by_name() {
|
||
let (a, b) = (json!({"family": "A Sans", "style": "Regular"}), json!({"family": "B Sans", "style": "Bold"}));
|
||
let mut d = Dialog::new(KIND, json!({"fonts": [a, b], "chosen": []}));
|
||
update(&mut d, &json!({"state": "searching", "fonts": [{"family": "B Sans", "style": "Bold", "files": ["/f/b.otf"]}]}));
|
||
let found = d.fields["found"].as_array().unwrap().clone();
|
||
assert!(files_for(&found, &a).is_empty());
|
||
assert_eq!(files_for(&found, &b), [json!("/f/b.otf")]);
|
||
assert_eq!(d.fields["chosen"], json!(["/f/b.otf"]));
|
||
d.fields.insert("chosen".into(), json!([]));
|
||
update(&mut d, &json!({"state": "done", "fonts": [{"family": "B Sans", "style": "Bold", "files": ["/f/b.otf", "/g/b.otf"]}]}));
|
||
assert_eq!((d.str("state"), &d.fields["chosen"]), ("done".to_string(), &json!([])));
|
||
}
|
||
|
||
#[test]
|
||
fn the_web_says_how_many_fonts_are_missing() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap();
|
||
after_open_for(&mut app, &json!({}), true);
|
||
assert_eq!(app.ui.status, "1 font(s) aren't available here: their text shows in another font");
|
||
assert!(app.ui.pending_fonts.is_empty());
|
||
// The open's notes stay on show.
|
||
app.ui.status = "Opened with 1 note(s): fonts".into();
|
||
after_open_for(&mut app, &json!({"warnings": ["fonts"]}), true);
|
||
assert_eq!(app.ui.status, "Opened with 1 note(s): fonts");
|
||
}
|
||
}
|