From ad7cd71c390a56ae64b56fe4eeab3eafe6ca67ea Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 4 Nov 2026 21:47:41 +0300 Subject: [PATCH 1/4] Find Font: Replace With Font lists family and style rows Choosing a replacement meant picking the family from one drop-down and its style from another, and only then seeing what the style would be. Replace With Font now lists rows of a family and one of its styles - the family's closest style first, then each style it has - and one click sets both. A search above the list filters the rows by family and style, as the Character panel's font menu searches. Each list scrolls on its own: the document's fonts and the replacement rows keep separate scroll states and scrollbars, and the replacement list is ten rows tall at most, no taller than the rows it lists. Review: the rows are rebuilt after a replacement, the search clears when the dialog closes, and a double-click respects the same guard as the Change buttons; the type-parity checklist has its status line and revision row. Closes #4972. --- crates/ui-egui/src/find_font.rs | 350 +++++++++++++++++++++++++++++--- docs/roadmap.md | 1 + docs/target-app-parity.md | 1 + docs/type-parity.md | 5 +- 4 files changed, 323 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index 588869226..b841541cf 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -6,10 +6,10 @@ use egui::Ui; use serde_json::{Value, json}; +use crate::VectorcraftApp; use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets; -use crate::{VectorcraftApp, font_menu}; /// Open the dialog. pub fn open(app: &mut VectorcraftApp) { @@ -26,9 +26,12 @@ pub fn open_on_missing(app: &mut VectorcraftApp) { /// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen, and /// System's font ([`system_family`]) to change to. fn open_at(app: &mut VectorcraftApp, selected: usize) { + let used = fonts(app); + let source = "system"; + let rows = rows_json(&replacement_rows(app, source, &used)); app.ui.dialog = Some(Dialog::new( "findFont", - json!({ "selected": selected, "family": system_family(), "style": "", "selectionOnly": false, "replaceFrom": "system" }), + json!({ "selected": selected, "family": system_family(), "style": "", "selectionOnly": false, "replaceFrom": source, "rows": rows }), )); } @@ -71,6 +74,70 @@ fn replacement_families(app: &VectorcraftApp, source: &str, used: &[Value]) -> O Some(out) } +/// A row of Replace With Font: a family and one of its styles (`""` is the family's closest style), +/// so one click picks both. +#[derive(Clone, Debug, PartialEq)] +struct Row { + family: String, + style: String, +} + +/// What the row says: the family and its style, or the family alone for its closest style. +fn row_label(family: &str, style: &str) -> String { + if style.is_empty() { family.to_string() } else { format!("{family} {style}") } +} + +/// Does a row match the search `query` (lower case): its family and style, as typed. +fn row_matches(family: &str, style: &str, query: &str) -> bool { + query.is_empty() || format!("{family} {style}").to_lowercase().contains(query) +} + +/// Replace With Font's rows for `source` ([`SOURCES`]). Document lists the family and style pairs +/// `text.fonts` names, once each, in its order; Recent and System list their families with each +/// family's closest style and then its styles. +fn replacement_rows(app: &VectorcraftApp, source: &str, used: &[Value]) -> Vec { + let db = vectorcraft_text::FontDb::global(); + let mut rows: Vec = vec![]; + match source { + "document" => { + for f in used { + let (family, style) = (f["family"].as_str().unwrap_or_default(), f["style"].as_str().unwrap_or_default()); + if rows.iter().any(|r| r.family.eq_ignore_ascii_case(family) && r.style.eq_ignore_ascii_case(style)) { + continue; + } + rows.push(Row { family: family.to_string(), style: style.to_string() }); + } + } + "recent" => { + for family in replacement_families(app, "recent", used).unwrap_or_default() { + push_family(&mut rows, db, &family); + } + } + _ => { + for family in db.menu_family_list().iter() { + push_family(&mut rows, db, family); + } + } + } + rows +} + +/// Add `family` to `rows`: its closest style, then every style it has. +fn push_family(rows: &mut Vec, db: &vectorcraft_text::FontDb, family: &str) { + if family.is_empty() { + return; + } + rows.push(Row { family: family.to_string(), style: String::new() }); + for style in db.styles(family) { + rows.push(Row { family: family.to_string(), style }); + } +} + +/// `rows` as the dialog stores them (`{"family": …, "style": …}`). +fn rows_json(rows: &[Row]) -> Value { + Value::Array(rows.iter().map(|r| json!({ "family": r.family, "style": r.style })).collect()) +} + /// Is `family` installed: it resolves to a face of its own, as `text.replaceFont` requires. fn installed(family: &str) -> bool { !family.is_empty() @@ -97,6 +164,7 @@ fn can_replace_in(family: &str, is_installed: impl Fn(&str) -> bool, list: &[Str /// its closest style. System chooses [`system_family`]. fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value]) { d.fields.insert("replaceFrom".into(), json!(source)); + d.fields.insert("rows".into(), rows_json(&replacement_rows(app, source, used))); let families = replacement_families(app, source, used); if can_replace(d, families.as_deref()) { return; @@ -129,13 +197,16 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; let t = Tokens::get(ctx); let list = fonts(app); - let sample = font_menu::sample_text(app); // Find in Folder… shows when the document misses fonts, fonts used only in its symbols included. let offer_folder = crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some_and(|st| !vectorcraft_engine::cmd::fontfiles::missing_fonts(&st.doc).is_empty()); let mut close = false; let mut find_in_folder = false; let mut act: Option<&str> = None; + // The replacement-font search, kept in egui memory: cleared when the dialog closes, and when + // the source changes. + let source_key = egui::Id::unique("ff-search-source"); + let query_id = egui::Id::unique("ff-search"); crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::unique("dialog-find-font"), -40.0, 22, |ui: &mut Ui| { ui.set_width(380.0); crate::dialogs::modal::heading(ui, tl!("Find Font")); @@ -145,7 +216,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| { ui.set_min_height(120.0); ui.set_width(ui.available_width()); - egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("ff-document-list").max_height(160.0).auto_shrink([false, true]).show(ui, |ui| { for (i, f) in list.iter().enumerate() { let (note, color) = font_note(f); let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]); @@ -172,34 +243,59 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } }); let families = replacement_families(app, &d.str("replaceFrom"), &list); - let fam = d.str("family"); - ui.horizontal(|ui| { - if let Some(families) = &families { - // Font names are shown as they are. - let names: Vec<&str> = families.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown_names_named(ui, "ff-family-list", Some(tl!("Replace With Font")), &fam, &names, 220.0) { - d.fields.insert("family".into(), json!(families[i])); - d.fields.insert("style".into(), json!("")); + // The rows Replace With Font lists for the source: one click picks the family and its style. + let rows: Vec<(String, String)> = d + .fields + .get("rows") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .map(|r| (r["family"].as_str().unwrap_or_default().to_string(), r["style"].as_str().unwrap_or_default().to_string())) + .collect() + }) + .unwrap_or_default(); + let (fam, style) = (d.str("family"), d.str("style")); + // A search like the Character panel's font menu: the family and style are filtered as typed. + // A new source starts with an empty search. + let source = d.str("replaceFrom"); + if ui.data(|m| m.get_temp::(source_key).as_deref() != Some(source.as_str())) { + ui.data_mut(|m| { + m.insert_temp(source_key, source.clone()); + m.insert_temp(query_id, String::new()); + }); + } + let query = widgets::search_field(ui, query_id, tl!("Search Fonts")).to_lowercase(); + let shown: Vec<&(String, String)> = rows.iter().filter(|(f, s)| row_matches(f, s, &query)).collect(); + let mut picked: Option<(String, String, bool)> = None; + // Ten rows at a time, whichever height the rows get from the style. + let row = (ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Body)) + ui.spacing().item_spacing.y).max(18.0); + egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| { + ui.set_width(ui.available_width()); + let area = egui::ScrollArea::vertical() + .id_salt("ff-rows-list") + // Ten rows at most, and no taller than the rows there are: a search of three fonts + // leaves a list three rows tall. + .max_height((shown.len() as f32 * row).clamp(row, 10.0 * row)) + .auto_shrink([false, true]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible); + area.show_rows(ui, row, shown.len(), |ui, range| { + for i in range { + let Some((f, s)) = shown.get(i) else { continue }; + let is_selected = f.eq_ignore_ascii_case(&fam) && s.eq_ignore_ascii_case(&style); + let resp = ui.selectable_label(is_selected, row_label(f, s)); + if resp.clicked() { + picked = Some((f.clone(), s.clone(), resp.double_clicked())); + } } - } else { - // Picks the font only: nothing is previewed on the document. - let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app)); - if let Some((f, style)) = font_menu::picked(app, pick) { - d.fields.insert("family".into(), json!(f)); - d.fields.insert("style".into(), json!(style.unwrap_or_default())); - } - } - let styles = vectorcraft_text::FontDb::global().styles(&d.str("family")); - // "(closest)" is ours; the font's style names are shown as they are. - let closest = tl!("(closest)"); - let mut opts: Vec<&str> = vec![closest]; - opts.extend(styles.iter().map(String::as_str)); - let style = d.str("style"); - let cur = if style.is_empty() { closest } else { style.as_str() }; - if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) { - d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() })); - } + }); }); + if let Some((f, s, twice)) = picked { + d.fields.insert("family".into(), json!(f)); + d.fields.insert("style".into(), json!(s)); + if twice && can_replace(&d, families.as_deref()) { + act = Some("change"); + } + } ui.add_space(14.0); // An empty list (Recent before any font was applied) leaves nothing to change to. let replace = sel < list.len() && can_replace(&d, families.as_deref()); @@ -228,12 +324,23 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { "change" => change(app, &d, from, false), _ => change(app, &d, from, true), }; - if let Err(e) = r { - app.ui.status = e; + match r { + // The rows are a snapshot: the document's fonts and the recent fonts have moved on. + Ok(_) if a != "find" => { + let used = fonts(app); + d.fields.insert("rows".into(), rows_json(&replacement_rows(app, &d.str("replaceFrom"), &used))); + } + Ok(_) => {} + Err(e) => app.ui.status = e, } } if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.dialog = None; + // The next open starts with an empty search. + ctx.data_mut(|m| { + m.remove::(source_key); + m.remove::(query_id); + }); } else if app.ui.dialog.is_some() { app.ui.dialog = Some(d); } @@ -370,6 +477,185 @@ mod tests { } } + /// One headless frame of the dialog: every text it painted, with where. + fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(String, egui::Rect)> { + fn collect(s: &egui::epaint::Shape, out: &mut Vec<(String, egui::Rect)>) { + match s { + egui::epaint::Shape::Text(t) => out.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let raw = egui::RawInput { events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui.ctx())); + let mut texts = vec![]; + for c in &out.shapes { + collect(&c.shape, &mut texts); + } + out.textures_delta.clear(); + texts + } + + /// The texts of a [`frame`] that are in `labels`, in order. + fn painted(frame: &[(String, egui::Rect)], labels: &[String]) -> Vec { + frame.iter().map(|(t, _)| t.clone()).filter(|t| labels.contains(t)).collect() + } + + /// A click at `at`: the press on one frame, the release on the next. + fn click(ctx: &egui::Context, app: &mut VectorcraftApp, at: egui::Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true)]); + frame(ctx, app, vec![button(false)]); + frame(ctx, app, vec![]); + } + + /// A document set in `families`, one text each. + fn many_font_document(families: &[&str]) -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + for (i, family) in families.iter().enumerate() { + app.session.execute("text.create", &json!({"x": 10, "y": 10.0 + i as f64 * 20.0, "text": "Hi", "font": family})).unwrap(); + } + app + } + + /// The rows follow a replacement: Change All in the Document source leaves the list showing the + /// document's fonts as they are now. + #[test] + fn the_rows_refresh_after_a_change() { + let mut app = two_font_document(); + open(&mut app); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![]); + let used = fonts(&mut app); + let mut d = app.ui.dialog.clone().unwrap(); + set_source(&app, &mut d, "document", &used); + d.fields.insert("family".into(), json!("Source Serif 4")); + d.fields.insert("style".into(), json!("")); + app.ui.dialog = Some(d); + frame(&ctx, &mut app, vec![]); + let texts = frame(&ctx, &mut app, vec![]); + let change_all = texts.iter().find(|(t, _)| t == "Change All").map(|(_, r)| r.center()).expect("Change All is shown"); + click(&ctx, &mut app, change_all); + assert!(fonts(&mut app).iter().all(|f| f["family"] == "Source Serif 4"), "the document changed"); + let rows = app.ui.dialog.as_ref().expect("the dialog stays open").fields["rows"].as_array().cloned().unwrap_or_default(); + let families: Vec<&str> = rows.iter().filter_map(|r| r["family"].as_str()).collect(); + assert!( + !families.is_empty() && families.iter().all(|f| *f == "Source Serif 4"), + "the rows list the document's fonts after the change: {families:?}" + ); + } + + /// Closing the dialog clears the replacement search: the next open starts with an empty one. + #[test] + fn closing_the_dialog_clears_the_replacement_search() { + let mut app = two_font_document(); + open(&mut app); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![]); + let query_id = egui::Id::unique("ff-search"); + let source_id = egui::Id::unique("ff-search-source"); + ctx.data_mut(|m| { + m.insert_temp(query_id, "times".to_string()); + m.insert_temp(source_id, "system".to_string()); + }); + let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&ctx, &mut app, vec![escape]); + frame(&ctx, &mut app, vec![]); + assert!(app.ui.dialog.is_none(), "Escape closes the dialog"); + assert_eq!(ctx.data(|m| m.get_temp::(query_id)), None, "the search text is gone"); + assert_eq!(ctx.data(|m| m.get_temp::(source_id)), None, "the source it was typed under is gone"); + } + + /// The wheel over the rows scrolls them, and leaves the document's font list where it was: two + /// lists, two scrollbars. + #[test] + fn the_rows_list_scrolls_with_the_wheel_alone() { + let families = ["Arial", "Georgia", "Verdana", "Tahoma", "Courier New", "Times New Roman", "Consolas", "Impact"]; + let mut app = many_font_document(&families); + open(&mut app); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![]); + let texts = frame(&ctx, &mut app, vec![]); + let used = fonts(&mut app); + let doc_labels: Vec = used + .iter() + .map(|f| { + let (note, _) = font_note(f); + format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]) + }) + .collect(); + let rows = replacement_rows(&app, "system", &used); + let labels: Vec = rows.iter().map(|r| row_label(&r.family, &r.style)).collect(); + let at = |texts: &[(String, egui::Rect)], text: &str| texts.iter().find(|(t, _)| t == text).map(|(_, r)| *r); + let start = at(&texts, &labels[0]).expect("the rows start at their top"); + let (doc_before, rows_before) = (painted(&texts, &doc_labels), painted(&texts, &labels)); + assert!(!doc_before.is_empty(), "the document's fonts are listed: {doc_before:?}"); + assert!(!rows_before.is_empty(), "{rows_before:?}"); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -20.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(start.center()), wheel]); + for _ in 0..30 { + frame(&ctx, &mut app, vec![]); + } + let texts = frame(&ctx, &mut app, vec![]); + assert!(at(&texts, &labels[0]).is_none(), "the wheel scrolls the first rows out of view"); + assert_ne!(painted(&texts, &labels), rows_before, "the wheel scrolls the rows"); + assert_eq!(painted(&texts, &doc_labels), doc_before, "the document's font list stays where it was"); + } + /// The search filters the rows by family and style, whatever case was typed. + #[test] + fn the_search_filters_rows_by_family_and_style() { + assert!(row_matches("Arial", "Bold", ""), "no search lists every row"); + assert!(row_matches("Arial", "Bold", "arial")); + assert!(row_matches("Arial", "Bold", "rial b")); + assert!(row_matches("Source Sans 3", "Semibold", "sans 3 sem")); + assert!(!row_matches("Arial", "Bold", "times")); + assert!(!row_matches("Arial", "Bold", "bold italic")); + } + + /// Replace With Font's rows: Document lists the family and style pairs the document uses, once + /// each; Recent and System list each family with its closest style first and then its styles. + #[test] + fn replacement_rows_pick_a_family_and_its_style() { + let mut app = two_font_document(); + let used = fonts(&mut app); + let document = replacement_rows(&app, "document", &used); + assert_eq!( + document, + [Row { family: "Inter".into(), style: "Regular".into() }, Row { family: "Source Serif 4".into(), style: "Regular".into() }], + "one row per family and style the document uses" + ); + let recent = replacement_rows(&app, "recent", &used); + assert_eq!(recent.first(), Some(&Row { family: "Inter".into(), style: String::new() }), "the closest style comes first"); + assert!(recent.iter().all(|r| r.family == "Inter"), "{recent:?}"); + let system = replacement_rows(&app, "system", &used); + assert!(system.iter().any(|r| r.family == "Source Sans 3" && r.style == "Regular"), "{system:?}"); + assert!(!system.iter().any(|r| r.family.starts_with('.')), "the font menu's hidden families stay out"); + let menu = vectorcraft_text::FontDb::global().menu_family_list(); + assert_eq!(system.iter().filter(|r| r.style.is_empty()).count(), menu.len(), "one closest-style row per family"); + // The dialog stores the rows when it opens and when the source changes, and a picked row + // sets both fields Change then uses. + open(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["rows"].as_array().map(Vec::len), Some(system.len())); + let mut d = app.ui.dialog.clone().unwrap(); + set_source(&app, &mut d, "document", &used); + assert_eq!(d.fields["rows"].as_array().map(Vec::len), Some(document.len())); + d.fields.insert("family".into(), json!("Inter")); + d.fields.insert("style".into(), json!("Regular")); + let from = used.iter().find(|f| f["family"] == "Source Serif 4").unwrap().clone(); + change(&mut app, &d, &from, true).unwrap(); + assert!(fonts(&mut app).iter().all(|f| f["family"] == "Inter")); + } + #[test] fn change_all_replaces_with_a_font_from_the_document() { let mut app = two_font_document(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 527bb286e..6831863a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -28,6 +28,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | M7: Find Font's Replace With Font lists family and style rows with a search, and the list and the document's font list scroll on their own (#4972); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep the units their document or preset was made in, and the built-in tabs still follow the chosen units (#2078). Scores and estimates unchanged. | | 2026-11-03 | minor | M1: Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot; estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 05c14203e..1e3541028 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -35,6 +35,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-03 | minor | Correct the Type core missing list and remove duplicate target and Type core entries: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. | | 2026-11-03 | minor | Correct the Type core missing list: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. | | 2026-11-03 | minor | Ligature caret stops use GDEF contour-point positions in horizontal and vertical type at the font's default variation location; invalid point indices keep the evenly divided fallback. Scores and estimates unchanged. | +| 2026-11-04 | minor | Record Find Font's replacement rows: family and style together, a search over them, and a scroll state of their own (#4972); scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | diff --git a/docs/type-parity.md b/docs/type-parity.md index f4a265ece..2013f513b 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Type-range Copy keeps formatting when pasted as separate text art after editing; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Find Font's Replace With Font lists family and style rows, searchable, with its own scroll state and a refresh after each replacement; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | -| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) | +| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | @@ -78,6 +78,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha |---|---|---| | 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. | | 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. | +| 2026-11-04 | minor | Find Font's Replace With Font lists family and style rows, one click setting both, with a search over them; the list scrolls apart from the document's font list and refreshes after each replacement (#4972); scores and estimates unchanged. | | 2026-11-03 | minor | The OpenType panel lists the selected font's available Stylistic Sets with its custom names or numbered labels; sets can be combined and deselected. Scores and estimates unchanged. | | 2026-11-03 | minor | Character-menu Kashidas controls selected characters separately from the paragraph's Insert Kashidas length. Existing text allows Kashidas by default; scores and estimates unchanged. | | 2026-11-03 | minor | Type on a Path Options has a Preview checkbox: the type follows the fields while it is on, Cancel restores it, OK is one undo step (#5360, as public tutorials of the dialog show); scores and estimates unchanged. | From a53b58d7b7fc2f7b25f2df3890a962377d06cb5f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 4 Nov 2026 23:01:06 +0300 Subject: [PATCH 2/4] Find Font: a new source picks a listed row, and confirming forgets the search Switching Replace With Font From kept a family and style the new list might not show, so Change could replace with a closest style of its own choosing instead of a row on screen; the family and the style now both come from a row the list shows, and the chosen font stays only when that exact pair is one of them. Confirming the dialog with Enter closed it without clearing the search the Done and Escape paths clear; all three now share the same cleanup. Tests: a new source picks a row its list shows (family and style), and confirming the dialog clears the replacement search. --- crates/ui-egui/src/find_font.rs | 69 ++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index b841541cf..97efb92a8 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -164,17 +164,21 @@ fn can_replace_in(family: &str, is_installed: impl Fn(&str) -> bool, list: &[Str /// its closest style. System chooses [`system_family`]. fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value]) { d.fields.insert("replaceFrom".into(), json!(source)); - d.fields.insert("rows".into(), rows_json(&replacement_rows(app, source, used))); - let families = replacement_families(app, source, used); - if can_replace(d, families.as_deref()) { + let rows = replacement_rows(app, source, used); + d.fields.insert("rows".into(), rows_json(&rows)); + // The chosen font stays only when the new list has that family and style; otherwise the family + // and the style both come from a row the list shows, so Change replaces with what is listed. + let (family, style) = (d.str("family"), d.str("style")); + if rows.iter().any(|r| r.family.eq_ignore_ascii_case(&family) && r.style.eq_ignore_ascii_case(&style) && installed(&r.family)) { return; } - let family = match families { - Some(l) => l.into_iter().find(|f| installed(f)), - None => Some(system_family()), - }; - d.fields.insert("family".into(), json!(family.unwrap_or_default())); - d.fields.insert("style".into(), json!("")); + let system = if source == "system" { system_family() } else { String::new() }; + let pick = rows + .iter() + .find(|r| !system.is_empty() && r.family.eq_ignore_ascii_case(&system) && r.style.is_empty()) + .or_else(|| rows.iter().find(|r| installed(&r.family))); + d.fields.insert("family".into(), json!(pick.map(|r| r.family.clone()).unwrap_or_default())); + d.fields.insert("style".into(), json!(pick.map(|r| r.style.clone()).unwrap_or_default())); } fn fonts(app: &mut VectorcraftApp) -> Vec { @@ -182,10 +186,22 @@ fn fonts(app: &mut VectorcraftApp) -> Vec { } pub fn confirm(app: &mut VectorcraftApp) -> Result { + if let Some(ctx) = app.ui_ctx.clone() { + clear_search(&ctx); + } app.ui.dialog = None; Ok(Value::Null) } +/// Forget the replacement search: the next open starts with an empty one. +fn clear_search(ctx: &egui::Context) { + let (source_key, query_id) = (egui::Id::unique("ff-search-source"), egui::Id::unique("ff-search")); + ctx.data_mut(|m| { + m.remove::(source_key); + m.remove::(query_id); + }); +} + /// Replace the selected document font with the chosen one (`all`: everywhere, else the selection). fn change(app: &mut VectorcraftApp, d: &Dialog, from: &Value, all: bool) -> Result { let style = d.str("style"); @@ -337,10 +353,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.dialog = None; // The next open starts with an empty search. - ctx.data_mut(|m| { - m.remove::(source_key); - m.remove::(query_id); - }); + clear_search(ctx); } else if app.ui.dialog.is_some() { app.ui.dialog = Some(d); } @@ -519,6 +532,36 @@ mod tests { app } + /// A new source picks the family and the style from a row its list shows, so Change replaces with + /// what is listed rather than a closest style of its own choosing. + #[test] + fn a_new_source_picks_a_row_its_list_shows() { + let mut app = two_font_document(); + let used = fonts(&mut app); + let mut d = Dialog::new("findFont", json!({"family": "Inter", "style": "", "selected": 0, "replaceFrom": "system"})); + set_source(&app, &mut d, "document", &used); + assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Inter", "Regular"), "the document's own row"); + d.fields.insert("family".into(), json!("Inter")); + d.fields.insert("style".into(), json!("Black Wide")); + set_source(&app, &mut d, "document", &used); + assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Inter", "Regular"), "a pair the list lacks gives way"); + } + + /// Confirming the dialog with Enter forgets the replacement search, as Done and Escape do. + #[test] + fn confirming_the_dialog_clears_the_replacement_search() { + let mut app = two_font_document(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.ui_ctx = Some(ctx.clone()); + open(&mut app); + let query_id = egui::Id::unique("ff-search"); + ctx.data_mut(|m| m.insert_temp(query_id, "times".to_string())); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "the dialog closes"); + assert_eq!(ctx.data(|m| m.get_temp::(query_id)), None, "the search text is gone"); + } + /// The rows follow a replacement: Change All in the Document source leaves the list showing the /// document's fonts as they are now. #[test] From 33cd7ef21ac58243830293140148c63b28c8bd17 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 4 Nov 2026 23:13:21 +0300 Subject: [PATCH 3/4] Find Font: every row names its style, and the search uses the catalog's label The rows repeated each family: a nameless "closest style" row came first and then the same style again with its name. A family now lists each of its styles once, the closest style first under its own name, so every row reads "Family Style" as the reference app's list does. The search field used a label no translation catalog has; it now uses the font menu's "Search", so the two fields read the same in every language. --- crates/ui-egui/src/find_font.rs | 48 +++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index 97efb92a8..bfbb7d5df 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -28,10 +28,12 @@ pub fn open_on_missing(app: &mut VectorcraftApp) { fn open_at(app: &mut VectorcraftApp, selected: usize) { let used = fonts(app); let source = "system"; - let rows = rows_json(&replacement_rows(app, source, &used)); + let rows = replacement_rows(app, source, &used); + let family = system_family(); + let style = rows.iter().find(|r| r.family.eq_ignore_ascii_case(&family)).map(|r| r.style.clone()).unwrap_or_default(); app.ui.dialog = Some(Dialog::new( "findFont", - json!({ "selected": selected, "family": system_family(), "style": "", "selectionOnly": false, "replaceFrom": source, "rows": rows }), + json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows) }), )); } @@ -122,13 +124,24 @@ fn replacement_rows(app: &VectorcraftApp, source: &str, used: &[Value]) -> Vec, db: &vectorcraft_text::FontDb, family: &str) { if family.is_empty() { return; } - rows.push(Row { family: family.to_string(), style: String::new() }); - for style in db.styles(family) { + let mut styles = db.styles(family); + // The face a style-less request picks comes first, by its own name, not again without one. + if let Some(closest) = db.face(family, "").map(|f| f.style.clone()) + && let Some(i) = styles.iter().position(|s| s.eq_ignore_ascii_case(&closest)) + { + let style = styles.remove(i); + styles.insert(0, style); + } + if styles.is_empty() { + rows.push(Row { family: family.to_string(), style: String::new() }); + return; + } + for style in styles { rows.push(Row { family: family.to_string(), style }); } } @@ -173,10 +186,8 @@ fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value] return; } let system = if source == "system" { system_family() } else { String::new() }; - let pick = rows - .iter() - .find(|r| !system.is_empty() && r.family.eq_ignore_ascii_case(&system) && r.style.is_empty()) - .or_else(|| rows.iter().find(|r| installed(&r.family))); + let pick = + rows.iter().find(|r| !system.is_empty() && r.family.eq_ignore_ascii_case(&system)).or_else(|| rows.iter().find(|r| installed(&r.family))); d.fields.insert("family".into(), json!(pick.map(|r| r.family.clone()).unwrap_or_default())); d.fields.insert("style".into(), json!(pick.map(|r| r.style.clone()).unwrap_or_default())); } @@ -280,7 +291,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { m.insert_temp(query_id, String::new()); }); } - let query = widgets::search_field(ui, query_id, tl!("Search Fonts")).to_lowercase(); + let query = widgets::search_field(ui, query_id, tl!("Search")).to_lowercase(); let shown: Vec<&(String, String)> = rows.iter().filter(|(f, s)| row_matches(f, s, &query)).collect(); let mut picked: Option<(String, String, bool)> = None; // Ten rows at a time, whichever height the rows get from the style. @@ -677,14 +688,23 @@ mod tests { [Row { family: "Inter".into(), style: "Regular".into() }, Row { family: "Source Serif 4".into(), style: "Regular".into() }], "one row per family and style the document uses" ); + let db = vectorcraft_text::FontDb::global(); + let closest = db.face("Inter", "").expect("Inter is installed").style.clone(); let recent = replacement_rows(&app, "recent", &used); - assert_eq!(recent.first(), Some(&Row { family: "Inter".into(), style: String::new() }), "the closest style comes first"); + assert_eq!(recent.first(), Some(&Row { family: "Inter".into(), style: closest.clone() }), "the closest style comes first, by name"); assert!(recent.iter().all(|r| r.family == "Inter"), "{recent:?}"); + assert_eq!(recent.len(), db.styles("Inter").len(), "each style once, the closest not repeated"); + assert!(recent.iter().all(|r| !r.style.is_empty()), "every row names its style: {recent:?}"); let system = replacement_rows(&app, "system", &used); assert!(system.iter().any(|r| r.family == "Source Sans 3" && r.style == "Regular"), "{system:?}"); assert!(!system.iter().any(|r| r.family.starts_with('.')), "the font menu's hidden families stay out"); - let menu = vectorcraft_text::FontDb::global().menu_family_list(); - assert_eq!(system.iter().filter(|r| r.style.is_empty()).count(), menu.len(), "one closest-style row per family"); + assert!(system.iter().all(|r| !r.style.is_empty()), "no nameless rows: {system:?}"); + let menu = db.menu_family_list(); + let listed: std::collections::BTreeSet<&str> = system.iter().map(|r| r.family.as_str()).collect(); + assert_eq!(listed.len(), menu.len(), "every family the font menu shows is listed"); + let mut unique = system.clone(); + unique.dedup(); + assert_eq!(unique.len(), system.len(), "no family and style is listed twice"); // The dialog stores the rows when it opens and when the source changes, and a picked row // sets both fields Change then uses. open(&mut app); @@ -814,7 +834,7 @@ mod tests { set_source(&app, &mut d, "recent", &used); assert_eq!(d.str("family"), ""); set_source(&app, &mut d, "system", &used); - assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Source Sans 3", "")); + assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Source Sans 3", "Regular"), "the family's first listed style"); assert!(can_replace(&d, None)); } From 31f05d3545b3a7994a221bb8f27a1c406b0b04d9 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 4 Nov 2026 23:56:07 +0300 Subject: [PATCH 4/4] Find Font: a stand-in style is never the target, and the closest style comes from the catalog A document font drawn in a substitute style stayed listed and could be chosen as the replacement: the family is installed, so the old check passed, and Change sent the unavailable style to text.replaceFont, which stored it and warned that the text is drawn in a stand-in. The exact face now decides both the automatic choice and whether Change is available; the row stays listed. Listing System asked the font database for each family's style-less face, and for a family not loaded yet that reads all its cataloged files, so opening the dialog could load a whole font library. FontDb::closest_style answers from the catalog's weight and slant instead, and the rows put that style first as before. Tests: the dialog's 26 (the closest style is the face a style-less request resolves to, a stand-in style is listed but not chosen) and the text crate's 474. --- crates/text/src/fontdb/build.rs | 7 +++- crates/text/src/fontdb/db.rs | 24 +++++++++++++ crates/ui-egui/src/find_font.rs | 63 ++++++++++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index 128cd49b3..ba91aa999 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -136,5 +136,10 @@ pub(super) fn style_italic(style: &str) -> bool { /// slant. The penalty is less than the weight gap between Regular and Bold (and Bold and Black), /// so Bold Italic asked of a family with Bold and Italic faces only gets the Bold (#2491). pub(super) fn style_distance(face: &FontFace, weight: f32, italic: bool) -> f32 { - (face.weight - weight).abs() + if face.italic != italic { 250.0 } else { 0.0 } + weight_distance(face.weight, face.italic, weight, italic) +} + +/// [`style_distance`] for a face known only by its weight and slant (a cataloged face). +pub(super) fn weight_distance(face_weight: f32, face_italic: bool, weight: f32, italic: bool) -> f32 { + (face_weight - weight).abs() + if face_italic != italic { 250.0 } else { 0.0 } } diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 1b14dd6dc..226ad7832 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -218,6 +218,30 @@ impl FontDb { v.into_iter().map(|t| t.2).collect() } + /// The style a style-less request gets for `family`: its face nearest Regular upright, by the + /// catalog's or the loaded faces' metadata. Unlike [`FontDb::face`] it reads no font files, so + /// the closest styles of many families cost little. + pub fn closest_style(&self, family: &str) -> Option { + let (family, _) = self.canonical(family, ""); + let family = family.as_str(); + let mut best: Option<(f32, String)> = None; + let consider = |best: &mut Option<(f32, String)>, weight: f32, italic: bool, style: &str| { + let d = super::build::weight_distance(weight, italic, 400.0, false); + if best.as_ref().is_none_or(|(b, _)| d < *b) { + *best = Some((d, style.to_string())); + } + }; + for f in self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)) { + consider(&mut best, f.weight, f.italic, &f.style); + } + if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) { + for cf in &c.faces { + consider(&mut best, cf.weight, cf.italic, &cf.style); + } + } + best.map(|(_, style)| style) + } + /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or /// every face was already present). pub fn add_font(&self, bytes: Vec) -> usize { diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index bfbb7d5df..7324520e2 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -130,8 +130,10 @@ fn push_family(rows: &mut Vec, db: &vectorcraft_text::FontDb, family: &str) return; } let mut styles = db.styles(family); - // The face a style-less request picks comes first, by its own name, not again without one. - if let Some(closest) = db.face(family, "").map(|f| f.style.clone()) + // The style a style-less request picks comes first, by its own name, not again without one. Its + // name comes from the catalog's metadata: the rows of every family would otherwise have to read + // the family's font files. + if let Some(closest) = db.closest_style(family) && let Some(i) = styles.iter().position(|s| s.eq_ignore_ascii_case(&closest)) { let style = styles.remove(i); @@ -151,6 +153,18 @@ fn rows_json(rows: &[Row]) -> Value { Value::Array(rows.iter().map(|r| json!({ "family": r.family, "style": r.style })).collect()) } +/// Can the replacement name that very face: `family` and `style` both exist, no stand-in. A row of +/// an unavailable style stays listed, but only a font with that style is chosen as the replacement. +fn exact(family: &str, style: &str) -> bool { + !family.is_empty() && vectorcraft_text::FontDb::global().resolve(family, style).is_some_and(|(_, m)| m == vectorcraft_text::FontMatch::Exact) +} + +/// Can a row be chosen as the replacement: its family is installed and the style is the font's own +/// (or the family names no styles, and its closest style stands for them). +fn listed(row: &Row) -> bool { + installed(&row.family) && (row.style.is_empty() || exact(&row.family, &row.style)) +} + /// Is `family` installed: it resolves to a face of its own, as `text.replaceFont` requires. fn installed(family: &str) -> bool { !family.is_empty() @@ -161,6 +175,10 @@ fn installed(family: &str) -> bool { /// and it is one of the list Replace With Font From shows (System's list is the font menu). fn can_replace(d: &Dialog, families: Option<&[String]>) -> bool { let family = d.str("family"); + let style = d.str("style"); + if !style.is_empty() && !exact(&family, &style) { + return false; + } match families { Some(list) => can_replace_in(&family, installed, list), None => can_replace_in(&family, installed, &vectorcraft_text::FontDb::global().menu_family_list()), @@ -182,12 +200,12 @@ fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value] // The chosen font stays only when the new list has that family and style; otherwise the family // and the style both come from a row the list shows, so Change replaces with what is listed. let (family, style) = (d.str("family"), d.str("style")); - if rows.iter().any(|r| r.family.eq_ignore_ascii_case(&family) && r.style.eq_ignore_ascii_case(&style) && installed(&r.family)) { + if rows.iter().any(|r| r.family.eq_ignore_ascii_case(&family) && r.style.eq_ignore_ascii_case(&style) && listed(r)) { return; } let system = if source == "system" { system_family() } else { String::new() }; let pick = - rows.iter().find(|r| !system.is_empty() && r.family.eq_ignore_ascii_case(&system)).or_else(|| rows.iter().find(|r| installed(&r.family))); + rows.iter().find(|r| !system.is_empty() && r.family.eq_ignore_ascii_case(&system) && listed(r)).or_else(|| rows.iter().find(|r| listed(r))); d.fields.insert("family".into(), json!(pick.map(|r| r.family.clone()).unwrap_or_default())); d.fields.insert("style".into(), json!(pick.map(|r| r.style.clone()).unwrap_or_default())); } @@ -543,6 +561,41 @@ mod tests { app } + /// The catalog's closest style is the face a style-less request resolves to, and finding it reads + /// no font file. + #[test] + fn the_closest_style_is_the_face_a_style_less_request_gets() { + let db = vectorcraft_text::FontDb::global(); + for family in ["Source Sans 3", "Inter"] { + let closest = db.closest_style(family).unwrap_or_else(|| panic!("{family} is installed")); + let face = db.face(family, "").unwrap_or_else(|| panic!("{family} resolves")); + assert_eq!(closest, face.style, "{family}"); + } + } + + /// A document font drawn in a substitute style stays listed, but it is not chosen as the + /// replacement and Change stays off for it: only a style the font has can be the target. + #[test] + fn a_substituted_style_is_listed_but_not_chosen() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + // A style Source Sans 3 has none of: its closest style stands in. + app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Source Sans 3", "style": "Black Wide"})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 70, "text": "Hi", "font": "Inter"})).unwrap(); + open(&mut app); + let used = fonts(&mut app); + assert!(used.iter().any(|f| f["family"] == "Source Sans 3" && f["style"] == "Black Wide" && f["status"] == "substitute"), "{used:?}"); + let rows = replacement_rows(&app, "document", &used); + let stand_in = Row { family: "Source Sans 3".into(), style: "Black Wide".into() }; + assert!(rows.contains(&stand_in), "the row stays listed: {rows:?}"); + let mut d = app.ui.dialog.clone().unwrap(); + set_source(&app, &mut d, "document", &used); + assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Inter", "Regular"), "the first font whose style it has"); + d.fields.insert("family".into(), json!("Source Sans 3")); + d.fields.insert("style".into(), json!("Black Wide")); + assert!(!can_replace(&d, replacement_families(&app, "document", &used).as_deref()), "Change stays off for the stand-in style"); + } + /// A new source picks the family and the style from a row its list shows, so Change replaces with /// what is listed rather than a closest style of its own choosing. #[test] @@ -689,7 +742,7 @@ mod tests { "one row per family and style the document uses" ); let db = vectorcraft_text::FontDb::global(); - let closest = db.face("Inter", "").expect("Inter is installed").style.clone(); + let closest = db.closest_style("Inter").expect("Inter is installed"); let recent = replacement_rows(&app, "recent", &used); assert_eq!(recent.first(), Some(&Row { family: "Inter".into(), style: closest.clone() }), "the closest style comes first, by name"); assert!(recent.iter().all(|r| r.family == "Inter"), "{recent:?}");