diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 585cd8a79..efe978628 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -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 { } /// Text objects in scope: the selection's (and their descendants') or the whole document's. -fn scope(s: &Session, selection_only: bool) -> Result> { +fn scope(s: &Session, selection_only: bool, selected: Option<&[NodeId]>) -> Result> { 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, BTreeSet)> { +fn selected_symbols(s: &Session, selected: Option<&[NodeId]>) -> Result<(BTreeSet, BTreeSet)> { fn open_instances(n: &Node, out: &mut BTreeSet) { if !n.visible || n.locked { return; @@ -123,7 +124,7 @@ fn selected_symbols(s: &Session) -> Result<(BTreeSet, BTreeSet)> 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 { - 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 { + 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> { + 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 = { + 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::>(); + fn visit(n: &Node, open: bool, closed: bool, uses: &impl Fn(&TextObject) -> bool, ids: &mut Vec) { + 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 { + 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 = BTreeMap::new(); @@ -536,6 +591,21 @@ fn font_param(p: &Value, k: &str, c: &str) -> Result<(String, Option)> { 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, + 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 { 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 { // 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 = all.difference(&open).cloned().collect(); (Some(open), closed) } else { @@ -579,13 +649,9 @@ fn replace(s: &mut Session, p: &Value) -> Result { let (ids, skipped, in_symbols) = { let d = &s.doc()?.doc; let editable = d.editable(); - let (ids, other): (Vec, Vec) = 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, Vec) = 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 = 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 { 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. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e9a7c6d5c..fe7967416 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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 diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 775248a61..0652e6d9a 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -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 { + 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, Vec) { + 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::::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::::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 { @@ -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(¶ms, 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::>() + }) + .unwrap_or_default(); + let selection: Option> = (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::>()); + 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. diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index 7324520e2..e73d78736 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -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(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 044f3e66b..84e278b6e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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>, + 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, pub status: String, + /// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved. + #[serde(skip)] + pub replace_font_warning: Option, 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,