Merge pull request #761 from vangel76/gap-variable-font-filter

#741: font menu filters to variable fonts
This commit is contained in:
Brandon Thomas
2026-10-12 03:03:28 -04:00
12 changed files with 259 additions and 16 deletions
+5 -2
View File
@@ -506,7 +506,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_text::fonts;
let added = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { fonts::rescan_system() } else { 0 };
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
let only_variable = p.get("variable").and_then(Value::as_bool).unwrap_or(false);
let native = fonts::native_families();
let variable = fonts::variable_families();
let mut origin: std::collections::BTreeMap<String, &'static str> = Default::default();
for f in fonts::all_faces() {
origin.entry(f.info.family.clone()).or_insert(f.info.origin);
@@ -514,8 +516,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
let list: Vec<Value> = effectcraft_text::families()
.into_iter()
.filter(|(f, _)| query.is_empty() || f.to_lowercase().contains(&query) || native.get(f).is_some_and(|n| n.to_lowercase().contains(&query)))
.filter(|(f, _)| !only_variable || variable.contains(f))
.map(|(f, styles)| {
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system")});
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system"), "variable": variable.contains(&f)});
if let Some(n) = native.get(&f) {
v["nativeName"] = json!(n);
}
@@ -559,7 +562,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Fonts",
[],
None,
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
"{query?, variable?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, variable, nativeName?}], added} (variable: only variable fonts; rescan picks up fonts installed since launch)",
always_ok,
fonts
),
+12 -3
View File
@@ -1162,6 +1162,8 @@ pub struct FontRow {
/// Settings ▸ Type ▸ Show Font Names in English is off.
pub display: String,
pub recent: bool,
/// A variable font (the menu's Variable Fonts filter keeps only these).
pub variable: bool,
}
/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for
@@ -1202,14 +1204,21 @@ pub fn font_preview(family: &str, size: f64) -> Vec<Vec<[f32; 2]>> {
}
/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts
/// to Display), a separator, then every family.
/// to Display), a separator, then every family. Each row says whether the family is variable,
/// for the menu's Variable Fonts filter.
pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
let all = text_families();
let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() };
let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent };
let variable = effectcraft_text::fonts::variable_families();
let row = |f: &String, recent: bool| FontRow {
family: f.clone(),
display: native.get(f).cloned().unwrap_or_else(|| f.clone()),
recent,
variable: variable.contains(f),
};
let mut out: Vec<FontRow> = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect();
if !out.is_empty() {
out.push(FontRow { family: String::new(), display: "-".into(), recent: false });
out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false });
}
out.extend(all.iter().map(|f| row(f, false)));
out
+57
View File
@@ -260,6 +260,63 @@ fn fonts_lists_bundled_and_installed_families() {
assert_eq!(crate::font_styles("No Such Family").len(), 5);
}
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
fn pinned_variable_font(family: &str) -> Vec<u8> {
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
let mut name = u16s(&[0, 2, 30]);
let mut off = 0;
for (id, st) in strings.iter().enumerate() {
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
off += st.len() as u16;
}
strings.iter().for_each(|st| name.extend(st));
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
let mut body: Vec<u8> = vec![];
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
out.extend(tag);
out.extend([0u8; 4]);
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
}
out.extend(body);
out
}
/// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}`
/// lists only families with variation axes, every listing says which families are variable, and
/// the font menu's rows carry the flag the filter uses.
#[test]
fn fonts_filter_variable_families() {
const FAMILY: &str = "EffectCraft Variable Filter Test";
assert_eq!(effectcraft_text::fonts::add_font_data(pinned_variable_font(FAMILY)).len(), 1);
let mut s = Session::default();
let all = s.execute("text.fonts", json!({})).unwrap();
let fams = all["families"].as_array().unwrap();
let flag = |name: &str| fams.iter().find(|f| f["family"] == name).map(|f| f["variable"].clone());
assert_eq!(flag("Inter"), Some(json!(false)), "the bundled fonts are static");
assert_eq!(flag(FAMILY), Some(json!(true)));
let var = s.execute("text.fonts", json!({"variable": true})).unwrap();
let listed = var["families"].as_array().unwrap();
assert_eq!(var["count"].as_u64(), Some(listed.len() as u64));
assert!(listed.iter().all(|f| f["variable"] == true), "{var}");
assert!(listed.iter().any(|f| f["family"] == FAMILY), "{var}");
assert!(listed.len() < fams.len(), "the filter drops the static families");
// Combined with the name query.
let both = s.execute("text.fonts", json!({"variable": true, "query": "variable filter"})).unwrap();
assert_eq!(both["families"].as_array().map(Vec::len), Some(1), "{both}");
let none = s.execute("text.fonts", json!({"variable": true, "query": "jetbrains mono"})).unwrap();
assert!(!none["families"].as_array().unwrap().iter().any(|f| f["family"] == "JetBrains Mono"), "{none}");
// The font menu's rows.
let menu = crate::font_menu(&s.prefs);
assert!(menu.iter().any(|r| r.family == FAMILY && r.variable));
assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable));
}
/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control
/// channel, MCP) accepts them and requires none (#520).
#[test]