#741: font menu filters to variable fonts

Problem: After Effects 26.3's font menu can show only variable fonts.
EffectCraft's font menus (Character panel, Properties ▸ Text) had no such
filter, and text.fonts / MCP list_fonts didn't say which families are
variable.

Fix: the font scan marks a face variable when its table directory has an
fvar table declaring axes (header read only, no extra file loading). The
font menu gets a "Variable fonts only" checkbox above the results; it
combines with the typed search, keeps the keyboard in the search field and
stays set while the app runs. text.fonts reports `variable` per family and
takes `{variable: true}`; list_fonts passes it through. Catalog rows for
ja, zh-Hans, zh-Hant and uk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-10-11 12:14:50 +02:00
1 parent 9a7d3e7699
commit 7afe723260
12 files changed
+259 -16

No files matched your search

+6
View File
@@ -681,6 +681,12 @@ fn list_fonts_lists_families_with_styles() {
assert!(matched.len() < fams.len(), "the query filters: {one}");
let mono = matched.iter().find(|f| f["family"] == "JetBrains Mono").unwrap_or_else(|| panic!("{one}"));
assert!(mono["styles"].as_array().unwrap().contains(&json!("Regular")), "{one}");
assert_eq!(mono["variable"], false, "{one}");
// `variable` keeps only the variable fonts (the font menu's filter); the static bundled
// families drop out.
let var = call_json(&mut s, "list_fonts", json!({"variable": true}));
let listed = var["families"].as_array().unwrap();
assert!(listed.iter().all(|f| f["variable"] == true && f["family"] != "Inter"), "{var}");
}
/// Trained models over MCP (Roto Brush and face tracking): listed with their authors and
+3 -2
View File
@@ -210,7 +210,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
}
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("variable", get(a, "variable")), ("rescan", get(a, "rescan"))]))?)
}
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
@@ -703,12 +703,13 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "list_fonts",
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user) and the name in the font's own language. Narrow with `query`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user), whether it is a variable font and the name in the font's own language. Narrow with `query` and `variable`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
bridge_only: false,
schema: || {
schema(
json!({
"query": {"type": "string", "description": "Only families whose name (English or own-language) contains this text."},
"variable": {"type": "boolean", "description": "Only variable fonts (families with variation axes)."},
"rescan": {"type": "boolean", "description": "Look for fonts installed since launch first."}
}),
&[],
+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
@@ -1153,6 +1153,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
@@ -1193,14 +1195,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]
+10
View File
@@ -58,6 +58,8 @@ pub struct FaceInfo {
/// be requested the way After Effects scripts name fonts.
pub full_name: String,
pub postscript: Option<String>,
/// The face has variation axes (an `fvar` table), read from the table directory at scan time.
pub variable: bool,
}
/// Something that can list fonts (system folders, a web font picker, a project's font folder…).
@@ -150,6 +152,7 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
native_family: n.native_family,
full_name: n.full_name,
postscript: n.postscript,
variable: n.variable,
}
}
@@ -403,6 +406,13 @@ pub fn native_families() -> std::collections::BTreeMap<String, String> {
all_faces().into_iter().filter_map(|f| Some((f.info.family.clone(), f.info.native_family.clone()?))).collect()
}
/// Families with at least one variable face (variation axes), for the font menu's Variable
/// Fonts filter. Scans the system fonts first if that hasn't happened yet.
pub fn variable_families() -> std::collections::BTreeSet<String> {
scan_system();
all_faces().into_iter().filter(|f| f.info.variable).map(|f| f.info.family.clone()).collect()
}
/// Families with their style names, sorted by family; styles in weight order. This is what font
/// menus list, so it scans the system fonts first if that hasn't happened yet (before, only a
/// project asking for a font the bundled ones lack started the scan, and the menus offered the
+60 -1
View File
@@ -19,6 +19,9 @@ pub struct FaceNames {
/// The PostScript name (name id 6, e.g. `YuGothic-Bold`): what After Effects scripts and
/// `.aep`-derived data call a font.
pub postscript: Option<String>,
/// A variable font: it has an `fvar` table with at least one axis (the font menu's
/// Variable Fonts filter).
pub variable: bool,
}
fn be16(b: &[u8], o: usize) -> Option<u16> {
@@ -66,12 +69,14 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
let dir = read_at(r, off + 12, n * 16)?;
let mut name = None;
let mut os2 = None;
let mut fvar = None;
for i in 0..n {
let rec = &dir[i * 16..i * 16 + 16];
let (o, l) = (be32(rec, 8)? as u64, be32(rec, 12)? as usize);
match &rec[0..4] {
b"name" => name = Some((o, l)),
b"OS/2" => os2 = Some((o, l)),
b"fvar" => fvar = Some((o, l)),
_ => {}
}
}
@@ -92,7 +97,9 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
}
let sl = style.to_ascii_lowercase();
italic |= sl.contains("italic") || sl.contains("oblique");
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript })
// fvar header: version (4), axesArrayOffset (2), reserved (2), axisCount (2).
let variable = fvar.filter(|(_, l)| *l >= 10).and_then(|(fo, _)| read_at(r, fo, 10)).and_then(|fb| be16(&fb, 8)).is_some_and(|axes| axes > 0);
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript, variable })
}
/// A Windows Unicode name-table string by id in a language other than US English.
@@ -165,5 +172,57 @@ mod tests {
let n = read_faces_bytes(crate::fonts::NOTO_SERIF_REGULAR);
assert_eq!(n[0].family, "Noto Serif");
assert!(read_faces_bytes(b"not a font").is_empty());
assert!(!f[0].variable && !n[0].variable, "the bundled fonts are static");
}
/// A font file of just `name` (family, Regular) and, when `fvar` is given, an `fvar` table
/// with that many axes (or a truncated one with `Some(None)`).
fn names_font(family: &str, fvar: Option<Option<u16>>) -> 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 tables: Vec<(&[u8; 4], Vec<u8>)> = vec![];
match fvar {
Some(Some(n)) => {
// Header, then `n` axis records: `wght` pinned at 400 (min = default = max).
let mut t = u16s(&[1, 0, 16, 2, n, 20, 0, 4 + 4 * n]);
(0..n).for_each(|_| t.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256]))));
tables.push((b"fvar", t));
}
Some(None) => tables.push((b"fvar", u16s(&[1, 0, 16]))),
None => {}
}
tables.push((b"name", name));
let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]);
let mut offset = 12 + 16 * tables.len();
let mut body: Vec<u8> = vec![];
for (tag, data) in &tables {
out.extend(*tag);
out.extend([0u8; 4]);
out.extend((offset as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
offset += data.len();
}
out.extend(body);
out
}
/// The scan marks a face with an `fvar` table that declares axes as variable, reading only
/// the table directory and the fvar header; a static font, an fvar without axes and a
/// truncated fvar are not.
#[test]
fn detects_variable_faces_from_fvar() {
let v = read_faces_bytes(&names_font("Var Test", Some(Some(1))));
assert_eq!((v.len(), v[0].family.as_str(), v[0].variable), (1, "Var Test", true), "{v:?}");
assert!(!read_faces_bytes(&names_font("Static Test", None))[0].variable);
assert!(!read_faces_bytes(&names_font("No Axes", Some(Some(0))))[0].variable);
assert!(!read_faces_bytes(&names_font("Cut", Some(None)))[0].variable);
}
}
+4
View File
@@ -322,6 +322,7 @@ const JAPANESE: &[(&str, &str)] = &[
("No layer selected", "レイヤーが選択されていません"),
("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"),
("No matching fonts", "一致するフォントはありません"),
("Variable fonts only", "可変フォントのみ"),
("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"),
("No nested compositions", "ネストされたコンポジションはありません"),
("No shortcut", "ショートカットなし"),
@@ -756,6 +757,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("No layer selected", "未选择图层"),
("No matching effects or presets", "没有匹配的效果或预设"),
("No matching fonts", "没有匹配的字体"),
("Variable fonts only", "仅可变字体"),
("No model credits were built into this copy.", "此副本未内置模型致谢。"),
("No nested compositions", "没有嵌套合成"),
("No shortcut", "无快捷键"),
@@ -1281,6 +1283,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("No layer selected", "未選取圖層"),
("No matching effects or presets", "沒有匹配的效果或預設集"),
("No matching fonts", "沒有符合的字體"),
("Variable fonts only", "僅可變字型"),
("No model credits were built into this copy.", "此副本未內置模型致謝。"),
("No nested compositions", "沒有嵌套合成"),
("No shortcut", "無快速鍵"),
@@ -1802,6 +1805,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("No layer selected", "Шар не виділено"),
("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"),
("No matching fonts", "Не знайдено відповідних шрифтів"),
("Variable fonts only", "Лише змінні шрифти"),
("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."),
("No nested compositions", "Немає вкладених композицій"),
("No shortcut", "Немає клавіатурного скорочення"),
+26 -4
View File
@@ -579,6 +579,10 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
});
let preview = app.session.prefs.type_.font_preview;
let t = app.tokens;
// The Variable Fonts filter (After Effects 26.3) stays as set while the app runs, like the
// menu's other settings; the search query resets on every open.
let variable_id = id.with("variable-only");
let mut only_variable = ui.data(|d| d.get_temp::<bool>(variable_id)).unwrap_or(false);
// The font field itself becomes editable. The popup contains only results.
let arrow = Rect::from_min_max(pos2(rect.max.x - 22.0, rect.min.y), rect.max);
let edit_rect = Rect::from_min_max(rect.min, pos2(arrow.min.x, rect.max.y));
@@ -608,12 +612,28 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(rect.width().max(180.0));
// Filter row: a checkbox that keeps only the variable fonts.
let filter_h = 22.0;
let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, ""));
let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h));
let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max);
let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check"));
ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text);
app.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only");
let toggled = row.clicked() || boxed.clicked();
if toggled {
only_variable = !only_variable;
ui.data_mut(|d| d.insert_temp(variable_id, only_variable));
// Typing goes on into the search field.
ui.memory_mut(|m| m.request_focus(search_id));
}
let query = state.query.trim().to_lowercase();
let mut seen = std::collections::HashSet::new();
let filtered: Vec<_> = rows
.iter()
.filter(|r| {
!r.family.is_empty()
&& (!only_variable || r.variable)
&& (query.is_empty()
|| ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family)))
})
@@ -621,7 +641,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
if fresh {
state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0);
}
if search.changed() {
if search.changed() || toggled {
state.selected = 0;
}
let last = filtered.len().saturating_sub(1);
@@ -637,10 +657,12 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
}
// Give the result list its actual height even when the previous query had
// only one row. A cached tiny Area must not collapse a reopened long list.
let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, max_height);
ui.set_height(height);
let filter_room = filter_h + ui.spacing().item_spacing.y;
let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, (max_height - filter_room).max(28.0));
// A minimum, from below the filter row (a maximum would move the cursor back up).
ui.set_min_height(height);
let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height);
if search.changed() {
if search.changed() || toggled {
scroll = scroll.vertical_scroll_offset(0.0);
}
scroll.show(ui, |ui| {
+71
View File
@@ -105,3 +105,74 @@ fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() {
assert_eq!(doc.style_at(0).font, "Noto Serif");
assert_eq!(doc.style_at(8).font, "Inter");
}
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
/// 400: a variable font as far as the font menu's filter is concerned.
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): the checkbox keeps only variable
/// families, works with the typed search (which keeps the keyboard), and stays on when the menu
/// is reopened until it is turned off.
#[test]
fn font_menu_filters_variable_fonts() {
const FAMILY: &str = "Inter Variable Menu Test";
assert_eq!(effectcraft_engine::text::fonts::add_font_data(pinned_variable_font(FAMILY)).len(), 1);
let variable = effectcraft_engine::text::fonts::variable_families();
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "Var", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Character);
h.run_steps(3);
let families = |h: &Harness<'_, EffectcraftApp>| -> Vec<String> {
h.state().auto.query("character.font.option.").iter().map(|e| e.id.trim_start_matches("character.font.option.").to_string()).collect()
};
click(&mut h, "character.font");
type_text(&mut h, "inter");
let shown = families(&h);
assert!(shown.iter().any(|f| f == "Inter") && shown.iter().any(|f| f == FAMILY), "unfiltered at first: {shown:?}");
click(&mut h, "character.font.variable");
let shown = families(&h);
assert!(shown.iter().any(|f| f == FAMILY), "{shown:?}");
assert!(shown.iter().all(|f| variable.contains(f)), "only variable families: {shown:?}");
// The search field keeps the keyboard and narrows the filtered list further.
type_text(&mut h, " variable menu");
assert_eq!(families(&h), vec![FAMILY.to_string()]);
type_text(&mut h, "x");
assert!(families(&h).is_empty());
assert!(h.state().auto.find("character.font.empty").is_some());
key(&mut h, Key::Escape);
// Reopened: still filtered; turning it off lists the static families again.
click(&mut h, "character.font");
h.run_steps(2);
let shown = families(&h);
assert!(!shown.is_empty() && shown.iter().all(|f| variable.contains(f)), "the filter stays on: {shown:?}");
click(&mut h, "character.font.variable");
type_text(&mut h, "inter");
assert!(families(&h).iter().any(|f| f == "Inter"), "{:?}", families(&h));
key(&mut h, Key::Escape);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "filtering changes nothing");
}
+4 -3
View File
@@ -107,7 +107,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r
| `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. |
| `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. |
| `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. |
| `list_fonts {query?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin and own-language name; `rescan` picks up fonts installed since launch. |
| `list_fonts {query?, variable?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin, whether each is a variable font and own-language name; `variable` lists only variable fonts; `rescan` picks up fonts installed since launch. |
| `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. |
| `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. |
| `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. |
@@ -491,8 +491,9 @@ whole layer, or only characters `range: [start, end]` (character indices): chara
work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed).
Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use, bundled and installed, with its
styles, origin (`bundled`, `system`, `user`) and own-language name (`{"query":"gothic"}` filters
by name; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and
styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name
(`{"query":"gothic"}` filters by name, `{"variable":true}` keeps only variable fonts, like the font
menu's Variable Fonts filter; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and
one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` says which OpenType
options a font draws with its own glyphs.
A tool with its own font library can hand it over without installing it: folders listed in
+1 -1
View File
@@ -26,7 +26,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`; #227, #290, #407 fixed |
| Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; adding shortcuts fails on Linux (#467); requests for more (#428, #336); no shortcuts for scripts |
| Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); multi-file drops (#552, #351); crash dragging over the import dialog (#511) |
| Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); variable-font filter (26.3) |
| Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`, `ui_font_search`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); the font menus' variable-font filter (26.3) landed (#741) |
| Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304), scrubbing lag (#329), Shift+Space default (#551) |
| Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) |
| Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply |