Refresh Find Font warnings after document changes

Squashed from #5773 onto main.
This commit is contained in:
dotCooCoo
2026-11-05 18:23:06 +07:00
committed by bflatastic
parent e6ac6cd3ba
commit fdde506ec7
5 changed files with 509 additions and 24 deletions
+83 -17
View File
@@ -2,7 +2,7 @@
//! another everywhere or in the selection, and selecting the text that uses a font. Also the fonts
//! available (the bundled and installed ones) and rescanning the installed fonts.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
use serde_json::{Value, json};
@@ -64,18 +64,19 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// Text objects in scope: the selection's (and their descendants') or the whole document's.
fn scope(s: &Session, selection_only: bool) -> Result<Vec<NodeId>> {
fn scope(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let mut v = vec![];
let mut seen = HashSet::new();
let mut add = |n: &vectorcraft_doc::Node| {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Text(_)) && !v.contains(&c.id) {
if matches!(c.kind, NodeKind::Text(_)) && seen.insert(c.id) {
v.push(c.id);
}
})
};
if selection_only {
for id in &st.selection.objects {
for id in selected.unwrap_or(&st.selection.objects) {
if let Some(n) = st.doc.node(*id) {
add(n);
}
@@ -97,7 +98,7 @@ fn being_edited(d: &Document, name: &str) -> bool {
/// descendants, and of the instances in those symbols' art that are not hidden or locked, nor in a
/// hidden or locked group of the art. Returned as (all of them, those shown by instances the
/// Selection tool can reach: not hidden or locked, as `Document::editable` says).
fn selected_symbols(s: &Session) -> Result<(BTreeSet<String>, BTreeSet<String>)> {
fn selected_symbols(s: &Session, selected: Option<&[NodeId]>) -> Result<(BTreeSet<String>, BTreeSet<String>)> {
fn open_instances(n: &Node, out: &mut BTreeSet<String>) {
if !n.visible || n.locked {
return;
@@ -123,7 +124,7 @@ fn selected_symbols(s: &Session) -> Result<(BTreeSet<String>, BTreeSet<String>)>
let st = s.doc()?;
let (mut all, mut open) = (BTreeSet::new(), BTreeSet::new());
let editable = st.doc.editable();
for id in &st.selection.objects {
for id in selected.unwrap_or(&st.selection.objects) {
if let Some(n) = st.doc.node(*id) {
n.walk(&mut |c| {
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
@@ -482,12 +483,66 @@ fn text(d: &Document, id: NodeId) -> Option<&vectorcraft_doc::TextObject> {
}
fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
let selection_only = bool_or(p, "selectionOnly", false);
let ids = scope(s, selection_only)?;
if bool_or(p, "selectionOnly", false) {
let selected = s.doc()?.selection.objects.clone();
font_inventory(s, Some(&selected))
} else {
font_inventory(s, None)
}
}
impl Session {
/// List the fonts in the document, or in the given objects and their symbols.
pub fn font_inventory(&self, selected: Option<&[NodeId]>) -> Result<Value> {
font_inventory(self, selected)
}
/// List text currently unavailable to a Replace Font request in its original selection.
pub fn skipped_font_ids(&self, params: &Value, selected: Option<&[NodeId]>) -> Result<Vec<NodeId>> {
let (family, style) = font_param(params, "from", "text.replaceFont")?;
let selection_only = bool_or(params, "selectionOnly", false);
let selected_symbols = selection_only.then(|| selected_symbols(self, selected)).transpose()?;
let d = &self.doc()?.doc;
let other: Vec<NodeId> = {
let editable = d.editable();
scope(self, selection_only, selected)?.into_iter().filter(|id| !editable(*id)).collect()
};
let uses = |t: &TextObject| {
t.runs.iter().any(|r| {
r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|name| r.style.font_style.eq_ignore_ascii_case(name))
})
};
let mut ids = other.into_iter().filter(|id| text(d, *id).is_some_and(uses)).collect::<Vec<_>>();
fn visit(n: &Node, open: bool, closed: bool, uses: &impl Fn(&TextObject) -> bool, ids: &mut Vec<NodeId>) {
let open = open && n.visible && !n.locked;
if let NodeKind::Text(t) = &n.kind
&& (!open || closed)
&& uses(t)
{
ids.push(n.id);
}
for child in n.children().map(Vec::as_slice).unwrap_or_default() {
visit(child, open, closed, uses, ids);
}
}
for symbol in d
.symbols
.iter()
.filter(|symbol| !being_edited(d, &symbol.name) && selected_symbols.as_ref().is_none_or(|(all, _)| all.contains(&symbol.name)))
{
let closed = selected_symbols.as_ref().is_some_and(|(_, open)| !open.contains(&symbol.name));
visit(&symbol.art, true, closed, &uses, &mut ids);
}
Ok(ids)
}
}
fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result<Value> {
let ids = scope(s, selected.is_some(), selected)?;
let d = &s.doc()?.doc;
let db = vectorcraft_text::FontDb::global();
// The type in the art of the symbols in scope too: all of them, or those the selection shows.
let symbols = if selection_only { Some(selected_symbols(s)?.0) } else { None };
let symbols = if selected.is_some() { Some(selected_symbols(s, selected)?.0) } else { None };
let mut texts: Vec<&TextObject> = ids.iter().filter_map(|id| text(d, *id)).collect();
texts.extend(symbol_texts(d, symbols.as_ref()).into_iter().map(|(t, _)| t));
let mut found: BTreeMap<UsedFont, (usize, usize, String)> = BTreeMap::new();
@@ -536,6 +591,21 @@ fn font_param(p: &Value, k: &str, c: &str) -> Result<(String, Option<String>)> {
Ok((family.to_string(), v.get("style").and_then(Value::as_str).map(str::to_string)))
}
fn skipped_font_texts(
d: &Document,
other: &[NodeId],
symbols: &[(&TextObject, bool)],
closed: &BTreeSet<String>,
uses: &impl Fn(&TextObject) -> bool,
) -> usize {
let mut skipped = other.iter().filter(|id| text(d, **id).is_some_and(uses)).count();
skipped += symbols.iter().filter(|(t, open)| !open && uses(t)).count();
if !closed.is_empty() {
skipped += symbol_texts(d, Some(closed)).into_iter().filter(|(t, _)| uses(t)).count();
}
skipped
}
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.replaceFont";
let (ff, fs) = font_param(p, "from", C)?;
@@ -567,7 +637,7 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
// With selectionOnly: the symbols the selection shows; those shown only by hidden or locked
// instances keep their type, counted in `skipped`.
let (symbols, closed) = if selection_only {
let (all, open) = selected_symbols(s)?;
let (all, open) = selected_symbols(s, None)?;
let closed: BTreeSet<String> = all.difference(&open).cloned().collect();
(Some(open), closed)
} else {
@@ -579,13 +649,9 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
let (ids, skipped, in_symbols) = {
let d = &s.doc()?.doc;
let editable = d.editable();
let (ids, other): (Vec<NodeId>, Vec<NodeId>) = scope(s, selection_only)?.into_iter().partition(|id| editable(*id));
let mut skipped = other.into_iter().filter(|id| text(d, *id).is_some_and(uses)).count();
let (ids, other): (Vec<NodeId>, Vec<NodeId>) = scope(s, selection_only, None)?.into_iter().partition(|id| editable(*id));
let texts = symbol_texts(d, symbols.as_ref());
skipped += texts.iter().filter(|(t, open)| !open && uses(t)).count();
if !closed.is_empty() {
skipped += symbol_texts(d, Some(&closed)).into_iter().filter(|(t, _)| uses(t)).count();
}
let skipped = skipped_font_texts(d, &other, &texts, &closed, &uses);
// The text that changes: when none does, the document and its undo history stay as they are.
let ids: Vec<NodeId> = ids.into_iter().filter(|id| text(d, *id).is_some_and(changes)).collect();
let in_symbols = texts.iter().any(|(t, open)| *open && changes(t));
@@ -646,7 +712,7 @@ const REPLACE_SKIPPED: &str = "some text is hidden or locked, so its font wasn't
fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
let family = str_param(p, "family").ok_or_else(|| bad("select.font", "missing `family`"))?.to_string();
let style = str_param(p, "style").map(str::to_string);
let ids = scope(s, false)?;
let ids = scope(s, false, None)?;
let d = &s.doc()?.doc;
// Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group
// or layer, or on a locked artboard.
+1
View File
@@ -280,6 +280,7 @@ impl VectorcraftApp {
let now = now_secs();
self.perf.fps = self.frame_rate.frame(now, self.perf.frame_ms / 1000.0);
self.sync_views();
self.refresh_replace_font_warning(false);
updates_ui::tick(self, ctx);
// Read the system clipboard only when that alone decides whether Paste is enabled. A
// background thread reads it where the host installs one (an unresponsive owner must never
+156 -6
View File
@@ -1,6 +1,7 @@
use super::fonts::MAX_RECENT_FONTS;
use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar};
use crate::placing;
use crate::state::ReplaceFontWarning;
/// The last repeatable Pathfinder operation completed by a command or atomic batch.
fn completed_pathfinder_operation<'a>(id: &'a str, params: &'a Value) -> Option<&'a str> {
@@ -12,6 +13,63 @@ fn completed_pathfinder_operation<'a>(id: &'a str, params: &'a Value) -> Option<
}
}
fn substitute_style_description(font: &Value) -> Option<String> {
if font["status"] != "substitute" {
return None;
}
let family = font["family"].as_str()?;
let style = font["style"].as_str()?;
let resolved_family = font["resolved"]["family"].as_str()?;
let resolved_style = font["resolved"]["style"].as_str()?;
Some(format!("{family} {style} is drawn in {resolved_family} {resolved_style}"))
}
fn substitute_warnings(fonts: &[Value], styles: &[(String, String)]) -> (Vec<String>, Vec<String>) {
let mut missing = Vec::new();
let mut live_styles = Vec::new();
for font in fonts.iter().filter(|font| {
styles.iter().any(|(family, style)| {
font["family"].as_str().is_some_and(|name| name.eq_ignore_ascii_case(family))
&& font["style"].as_str().is_some_and(|name| name.eq_ignore_ascii_case(style))
})
}) {
match font["status"].as_str() {
Some("missing") => {
if let Some(family) = font["family"].as_str() {
missing.push(family.to_string());
}
}
Some("substitute") => {
if let Some(description) = substitute_style_description(font) {
live_styles.push(description);
}
}
_ => {}
}
}
missing.sort();
missing.dedup();
live_styles.sort();
live_styles.dedup();
(missing, live_styles)
}
#[cfg(test)]
#[test]
fn substitute_warnings_follow_current_font_inventory() {
let styles = vec![("Source Sans 3".to_string(), "Black Wide".to_string())];
let replaced_face = serde_json::json!([{
"family": "Source Sans 3", "style": "Black Wide", "status": "substitute",
"resolved": {"family": "Inter", "style": "Bold"}
}]);
assert_eq!(
substitute_warnings(replaced_face.as_array().unwrap(), &styles),
(Vec::<String>::new(), vec!["Source Sans 3 Black Wide is drawn in Inter Bold".to_string()])
);
let removed_family = serde_json::json!([{"family": "Source Sans 3", "style": "Black Wide", "status": "missing"}]);
assert_eq!(substitute_warnings(removed_family.as_array().unwrap(), &styles), (vec!["Source Sans 3".to_string()], Vec::<String>::new()));
}
impl VectorcraftApp {
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
@@ -145,6 +203,7 @@ impl VectorcraftApp {
self.publish_clipboard();
}
self.sync_views();
self.refresh_replace_font_warning(false);
// File › Revert replaces the document in its tab, keeps the tab's view and starts the tool afresh.
self.follow_artboard_tool(artboards);
match &r {
@@ -188,22 +247,113 @@ impl VectorcraftApp {
r.insert(0, font.to_string());
r.truncate(MAX_RECENT_FONTS);
}
if id == "text.replaceFont"
&& v.get("warning").is_none()
&& self.ui.status.starts_with("some text is hidden or locked, so its font wasn't replaced")
{
self.ui.status.clear();
if id == "text.replaceFont" {
self.update_replace_font_status(&params, v);
}
// A command's warning (Rasterize's when an image couldn't be decoded) shows in the
// status bar, as a tool preview's does.
if let Some(w) = v.get("warning").and_then(Value::as_str) {
self.ui.status = w.to_string();
self.set_command_warning(id, w);
}
}
}
r
}
fn set_command_warning(&mut self, id: &str, warning: &str) {
self.ui.status = warning.to_string();
if id != "text.replaceFont" {
self.ui.replace_font_warning = None;
}
}
fn update_replace_font_status(&mut self, params: &Value, result: &Value) {
if let Some(warning) = result.get("warning").and_then(Value::as_str) {
let Some((document_uid, revision)) = self.session.active().map(|doc| (doc.uid, doc.revision)) else {
return;
};
let styles = self
.session
.execute("text.fonts", &serde_json::json!({}))
.ok()
.and_then(|value| value.as_array().cloned())
.map(|fonts| {
fonts
.iter()
.filter_map(|font| {
let description = substitute_style_description(font)?;
if !warning.contains(&description) {
return None;
}
Some((font["family"].as_str()?.to_string(), font["style"].as_str()?.to_string()))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let selection: Option<Vec<u64>> = (params["selectionOnly"] == true)
.then(|| self.session.active().map(|doc| doc.selection.objects.iter().map(|id| id.0).collect()).unwrap_or_default());
self.ui.replace_font_warning =
Some(ReplaceFontWarning { document_uid, revision, request: params.clone(), selection, text: warning.to_string(), styles });
return;
}
self.refresh_replace_font_warning(true);
}
pub(super) fn refresh_replace_font_warning(&mut self, force: bool) {
const SKIPPED: &str = "some text is hidden or locked, so its font wasn't replaced";
const STYLE_PREFIX: &str = "font styles that aren't available are drawn in the closest style: ";
const STYLE_SUFFIX: &str = " (pick a style the family has)";
let Some(previous) = self.ui.replace_font_warning.as_ref() else {
return;
};
let Some(revision) = self.session.active().filter(|doc| doc.uid == previous.document_uid).map(|doc| doc.revision) else {
return;
};
if (!force && revision == previous.revision) || previous.text != self.ui.status {
return;
}
let had_skipped = self.ui.status.starts_with(SKIPPED);
let had_style = !previous.styles.is_empty();
if !had_skipped && !had_style {
return;
}
let scoped_selection = previous.selection.as_ref().map(|selected| selected.iter().copied().map(vectorcraft_doc::NodeId).collect::<Vec<_>>());
let skipped_remains = if had_skipped {
self.session.skipped_font_ids(&previous.request, scoped_selection.as_deref()).is_ok_and(|ids| !ids.is_empty())
} else {
false
};
let (missing, live_styles) = if had_style {
let Some(fonts) = self.session.font_inventory(scoped_selection.as_deref()).ok().and_then(|value| value.as_array().cloned()) else {
return;
};
substitute_warnings(&fonts, &previous.styles)
} else {
(Vec::new(), Vec::new())
};
let mut parts = Vec::new();
if skipped_remains {
parts.push(SKIPPED.to_string());
}
if !missing.is_empty() {
parts.push(format!(
"fonts that aren't available are drawn in the fallback font, {}: {} (install them, or replace them with Find Font)",
vectorcraft_text::FALLBACK_FAMILY,
missing.join(", ")
));
}
if !live_styles.is_empty() {
parts.push(format!("{STYLE_PREFIX}{}{STYLE_SUFFIX}", live_styles.join(", ")));
}
self.ui.status = parts.join("; ");
if self.ui.status.is_empty() {
self.ui.replace_font_warning = None;
} else if let Some(warning) = &mut self.ui.replace_font_warning {
warning.revision = revision;
warning.text.clone_from(&self.ui.status);
}
}
/// Select a tool (also used by the toolbar and shortcuts).
pub fn select_tool(&mut self, id: &str) {
// The tip of a pen whose eraser end selected the Eraser doesn't undo this choice.
+253 -1
View File
@@ -448,7 +448,7 @@ mod tests {
assert_eq!(fonts(&mut app)[0]["family"], "Missing Family");
assert_eq!(app.ui.status, "some text is hidden or locked, so its font wasn't replaced");
app.run("object.unlockAll", json!({})).unwrap();
assert_eq!(app.ui.status, "some text is hidden or locked, so its font wasn't replaced");
assert_eq!(app.ui.status, "", "unlocking the skipped text clears the warning");
let r = change(&mut app, &d, &from, true).unwrap();
assert_eq!(r["runs"], 1, "{r}");
assert!(r.get("warning").is_none(), "{r}");
@@ -459,6 +459,258 @@ mod tests {
assert_eq!(app.ui.status, "Saved another document");
}
#[test]
fn replacement_keeps_only_font_warnings_that_still_apply() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 70, "text": "Bye", "font": "Missing Family"})).unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 90, "text": "Other", "font": "Inter"})).unwrap();
let replace = |app: &mut VectorcraftApp, from: &str, from_style: Option<&str>, style: &str| {
app.run("text.replaceFont", json!({"from": {"family": from, "style": from_style}, "to": {"family": "Source Sans 3", "style": style}}))
.unwrap()
};
let first = replace(&mut app, "Missing Family", Some("Regular"), "Black Wide");
assert_eq!(first["runs"], 1, "{first}");
assert_eq!(first["skipped"], 1, "{first}");
let style_warning = first["warning"].as_str().unwrap().split_once("; ").unwrap().1.to_string();
assert!(style_warning.contains("Source Sans 3 Black Wide is drawn in Source Sans 3"), "{style_warning}");
let unrelated = replace(&mut app, "Inter", None, "Regular");
assert_eq!(unrelated["runs"], 1, "{unrelated}");
assert!(unrelated.get("warning").is_none(), "{unrelated}");
assert_eq!(app.ui.status, first["warning"].as_str().unwrap());
app.run("object.unlockAll", json!({})).unwrap();
let second = replace(&mut app, "Missing Family", None, "Regular");
assert_eq!(second["runs"], 1, "{second}");
assert!(second.get("warning").is_none(), "{second}");
assert_eq!(app.ui.status, style_warning);
let third = replace(&mut app, "Source Sans 3", Some("Black Wide"), "Regular");
assert_eq!(third["runs"], 1, "{third}");
assert!(third.get("warning").is_none(), "{third}");
assert_eq!(app.ui.status, "");
}
#[test]
fn a_style_specific_retry_clears_a_family_wide_skipped_warning() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 70, "text": "Bye", "font": "Missing Family"})).unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
let first = app
.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3", "style": "Regular"}}))
.unwrap();
assert_eq!((first["runs"].as_u64(), first["skipped"].as_u64()), (Some(1), Some(1)));
app.run("object.unlockAll", json!({})).unwrap();
let second = app
.run(
"text.replaceFont",
json!({"from": {"family": "Missing Family", "style": "Regular"}, "to": {"family": "Source Sans 3", "style": "Regular"}}),
)
.unwrap();
assert_eq!(second["runs"], 1, "{second}");
assert!(second.get("warning").is_none(), "{second}");
assert_eq!(app.ui.status, "");
}
#[test]
fn new_text_in_the_source_font_does_not_keep_an_old_skip_warning() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let replace = |app: &mut VectorcraftApp, from: &str, style: &str| {
app.run("text.replaceFont", json!({"from": {"family": from}, "to": {"family": "Source Sans 3", "style": style}})).unwrap()
};
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 30, "text": "Editable", "font": "Missing Family"})).unwrap();
let skipped = app.session.execute("text.create", &json!({"x": 10, "y": 60, "text": "Locked", "font": "Missing Family"})).unwrap()["id"]
.as_u64()
.unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
let first = replace(&mut app, "Missing Family", "Regular");
assert_eq!((first["runs"].as_u64(), first["skipped"].as_u64()), (Some(1), Some(1)));
app.session.execute("text.create", &json!({"x": 10, "y": 90, "text": "New", "font": "Missing Family"})).unwrap();
app.run("object.unlockAll", json!({})).unwrap();
app.session.active_mut().unwrap().selection.objects = vec![vectorcraft_doc::NodeId(skipped)];
let second = app
.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true}))
.unwrap();
assert_eq!(second["runs"], 1, "{second}");
assert!(second.get("warning").is_none(), "{second}");
assert_eq!(app.ui.status, "");
assert!(fonts(&mut app).iter().any(|font| font["family"] == "Missing Family"));
}
#[test]
fn unlocking_skipped_text_clears_its_warning() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 30, "text": "Other", "font": "Inter"})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 60, "text": "Locked", "font": "Missing Family"})).unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
let first = app.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3"}})).unwrap();
assert_eq!(first["skipped"], 1);
app.run("object.unlockAll", json!({})).unwrap();
assert_eq!(app.ui.status, "");
let unrelated = app.run("text.replaceFont", json!({"from": {"family": "Inter"}, "to": {"family": "Source Sans 3"}})).unwrap();
assert_eq!(unrelated["runs"], 1);
assert_eq!(app.ui.status, "");
}
#[test]
fn a_document_revision_refreshes_the_skip_warning_on_tick() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 60, "text": "Locked", "font": "Missing Family"})).unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
let first = app.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3"}})).unwrap();
assert_eq!(first["skipped"], 1);
assert!(!app.ui.status.is_empty());
app.session.execute("object.unlockAll", &json!({})).unwrap();
let ctx = egui::Context::default();
app.begin_host_iteration();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.tick(ui.ctx()));
out.textures_delta.clear();
assert_eq!(app.ui.status, "");
}
#[test]
fn a_selection_only_retry_ignores_unselected_text_in_the_same_font() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Outside", "font": "Missing Family"})).unwrap();
let selected = app.session.execute("text.create", &json!({"x": 10, "y": 70, "text": "Selected", "font": "Missing Family"})).unwrap()["id"]
.as_u64()
.unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
app.session.active_mut().unwrap().selection.objects = vec![vectorcraft_doc::NodeId(selected)];
let params = json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true});
let first = app.run("text.replaceFont", params.clone()).unwrap();
assert_eq!((first["runs"].as_u64(), first["skipped"].as_u64()), (Some(0), Some(1)));
app.run("object.unlockAll", json!({})).unwrap();
app.session.active_mut().unwrap().selection.objects = vec![vectorcraft_doc::NodeId(selected)];
let second = app.run("text.replaceFont", params).unwrap();
assert_eq!(second["runs"], 1, "{second}");
assert!(second.get("warning").is_none(), "{second}");
assert_eq!(app.ui.status, "");
assert!(app.session.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().any(|font| font["family"] == "Missing Family"));
}
#[test]
fn a_changed_selection_keeps_the_original_warning_scope() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 30, "text": "Outside", "font": "Missing Family"})).unwrap();
let added = app.session.execute("text.create", &json!({"x": 10, "y": 60, "text": "Added", "font": "Missing Family"})).unwrap()["id"]
.as_u64()
.unwrap();
let skipped = app.session.execute("text.create", &json!({"x": 10, "y": 90, "text": "Locked", "font": "Missing Family"})).unwrap()["id"]
.as_u64()
.unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
app.session.active_mut().unwrap().selection.objects = vec![vectorcraft_doc::NodeId(skipped)];
let params = json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true});
let first = app.run("text.replaceFont", params.clone()).unwrap();
assert_eq!(first["skipped"], 1, "{first}");
app.run("object.unlockAll", json!({})).unwrap();
app.session.active_mut().unwrap().selection.objects = vec![vectorcraft_doc::NodeId(skipped), vectorcraft_doc::NodeId(added)];
let second = app.run("text.replaceFont", params).unwrap();
assert_eq!(second["runs"], 2, "{second}");
assert!(second.get("warning").is_none(), "{second}");
assert_eq!(app.ui.status, "");
assert!(app.session.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().any(|font| font["family"] == "Missing Family"));
}
#[test]
fn changing_selection_does_not_clear_a_skipped_font_warning() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let selected = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Locked", "font": "Missing Family"})).unwrap()["id"]
.as_u64()
.unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
app.session.active_mut().unwrap().selection.objects = vec![vectorcraft_doc::NodeId(selected)];
let first = app
.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true}))
.unwrap();
assert_eq!(first["skipped"], 1, "{first}");
let warning = app.ui.status.clone();
app.session.execute("text.create", &json!({"x": 10, "y": 70, "text": "Other", "font": "Inter"})).unwrap();
let unrelated =
app.run("text.replaceFont", json!({"from": {"family": "Inter"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true})).unwrap();
assert_eq!(unrelated["runs"], 1, "{unrelated}");
assert!(unrelated.get("warning").is_none(), "{unrelated}");
assert_eq!(app.ui.status, warning);
app.run("object.unlockAll", json!({})).unwrap();
let retry = app.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3"}})).unwrap();
assert_eq!(retry["runs"], 1, "{retry}");
assert!(retry.get("warning").is_none(), "{retry}");
assert_eq!(app.ui.status, "");
}
#[test]
fn replacing_a_font_in_another_document_keeps_the_original_warning() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Locked", "font": "Missing Family"})).unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
let first = app.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3"}})).unwrap();
assert_eq!(first["skipped"], 1, "{first}");
let warning = app.ui.status.clone();
let original_uid = app.session.active().unwrap().uid;
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Other", "font": "Inter"})).unwrap();
let second = app.run("text.replaceFont", json!({"from": {"family": "Inter"}, "to": {"family": "Source Sans 3"}})).unwrap();
assert_eq!(second["runs"], 1, "{second}");
assert_eq!(app.ui.status, warning);
app.session.set_active(0);
assert_eq!(app.session.active().unwrap().uid, original_uid);
assert_eq!(app.ui.status, warning);
}
#[test]
fn a_comma_in_a_substituted_style_keeps_its_warning() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 70, "text": "Other", "font": "Inter"})).unwrap();
let first = app
.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3", "style": "Black, Wide"}}))
.unwrap();
let warning = first["warning"].as_str().unwrap().to_string();
assert!(warning.contains("Black, Wide"), "{warning}");
let unrelated = app.run("text.replaceFont", json!({"from": {"family": "Inter"}, "to": {"family": "Source Serif 4"}})).unwrap();
assert_eq!(unrelated["runs"], 1, "{unrelated}");
assert!(unrelated.get("warning").is_none(), "{unrelated}");
assert_eq!(app.ui.status, warning);
}
#[test]
fn an_unrelated_substituted_style_does_not_keep_a_resolved_warning() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 70, "text": "Other", "font": "Inter"})).unwrap();
app.run("text.replaceFont", json!({"from": {"family": "Inter"}, "to": {"family": "Source Sans 3", "style": "Narrow"}})).unwrap();
let first = app
.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3", "style": "Black, Wide"}}))
.unwrap();
assert!(first["warning"].as_str().is_some_and(|warning| warning.contains("Black, Wide")), "{first}");
let resolved = app
.run(
"text.replaceFont",
json!({"from": {"family": "Source Sans 3", "style": "Black, Wide"}, "to": {"family": "Inter", "style": "Regular"}}),
)
.unwrap();
assert!(resolved.get("warning").is_none(), "{resolved}");
assert_eq!(app.ui.status, "");
}
#[test]
fn find_in_folder_puts_the_missing_fonts_dialog_in_find_fonts_place() {
let dir = std::fs::canonicalize(vectorcraft_testkit::temp_dir("find-font-folder")).unwrap();
+16
View File
@@ -325,6 +325,18 @@ impl Dialog {
}
}
/// The last Replace Font request whose warning the status bar shows, to refresh it as the
/// document changes.
#[derive(Clone, Debug)]
pub struct ReplaceFontWarning {
pub document_uid: u64,
pub revision: u64,
pub request: serde_json::Value,
pub selection: Option<Vec<u64>>,
pub text: String,
pub styles: Vec<(String, String)>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct UiState {
@@ -461,6 +473,9 @@ pub struct UiState {
/// Last tool shown for each toolbar group (flyout selection sticks).
pub group_tool: Vec<String>,
pub status: String,
/// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved.
#[serde(skip)]
pub replace_font_warning: Option<ReplaceFontWarning>,
pub palette_open: bool,
pub palette_query: String,
/// Screen mode: 0 normal, 1 full screen with menu, 2 full screen, 3 presentation. Not saved:
@@ -974,6 +989,7 @@ impl Default for UiState {
flyout: None,
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
status: String::new(),
replace_font_warning: None,
palette_open: false,
palette_query: String::new(),
screen_mode: 0,