mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
#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:
1 parent
9a7d3e7699
commit
7afe723260
12 files changed
+259
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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."}
|
||||
}),
|
||||
&[],
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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", "Немає клавіатурного скорочення"),
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user