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 588869226..7324520e2 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,14 @@ 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 = 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": "system" }), + json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows) }), )); } @@ -71,6 +76,95 @@ 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`: each style once, the closest style first, every row naming its style. +fn push_family(rows: &mut Vec, db: &vectorcraft_text::FontDb, family: &str) { + if family.is_empty() { + return; + } + let mut styles = db.styles(family); + // 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); + 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 }); + } +} + +/// `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()) +} + +/// 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() @@ -81,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()), @@ -97,16 +195,19 @@ 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)); - 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) && listed(r)) { 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) && 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())); } fn fonts(app: &mut VectorcraftApp) -> Vec { @@ -114,10 +215,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"); @@ -129,13 +242,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 +261,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 +288,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")).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 +369,20 @@ 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. + clear_search(ctx); } else if app.ui.dialog.is_some() { app.ui.dialog = Some(d); } @@ -370,6 +519,259 @@ 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 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] + 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] + 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 db = vectorcraft_text::FontDb::global(); + 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:?}"); + 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"); + 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); + 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(); @@ -485,7 +887,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)); } 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. |