Merge pull request #5677 from sadkotheguest/feat/find-font-replacement-rows

Find Font: Replace With Font lists family and style rows
This commit is contained in:
BFlat
2026-11-05 05:10:07 +07:00
committed by GitHub
6 changed files with 478 additions and 44 deletions
+6 -1
View File
@@ -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 }
}
+24
View File
@@ -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<String> {
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<u8>) -> usize {
+443 -41
View File
@@ -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<Row> {
let db = vectorcraft_text::FontDb::global();
let mut rows: Vec<Row> = 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<Row>, 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<Value> {
@@ -114,10 +215,22 @@ fn fonts(app: &mut VectorcraftApp) -> Vec<Value> {
}
pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
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::<String>(source_key);
m.remove::<String>(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<Value, String> {
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::<String>(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<egui::Event>) -> 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<String> {
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::<String>(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::<String>(query_id)), None, "the search text is gone");
assert_eq!(ctx.data(|m| m.get_temp::<String>(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<String> = 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<String> = 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));
}
+1
View File
@@ -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. |
+1
View File
@@ -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. |
+3 -2
View File
@@ -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. |