mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 13:23:22 +00:00
Refresh Find Font warnings after document changes
Squashed from #5773 onto main.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶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::<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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user