Files
effectcraft/crates/ui-egui/tests/ui_font_search.rs
vangel76andClaude Opus 5.5 7afe723260 #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 <noreply@anthropic.com>
2026-10-11 12:14:50 +02:00

179 lines
9.3 KiB
Rust

//! Real keyboard/pointer coverage for the font menus in both text panels.
use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId};
use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind};
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::Harness;
use serde_json::json;
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone();
let pos = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::PointerMoved(pos));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
}
h.run_steps(3);
}
fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) {
h.input_mut().events.push(Event::Text(text.into()));
h.run_steps(3);
}
#[test]
fn font_search_filters_selects_and_undoes_in_both_panels() {
for (panel, prefix) in [(PanelKind::Character, "character.font"), (PanelKind::Properties, "properties.text.font")] {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0));
let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() {
builder.wgpu().build_eframe(move |_| EffectcraftApp::new(s))
} else {
builder.build_eframe(move |_| EffectcraftApp::new(s))
};
h.state_mut().show_panel(panel);
h.run_steps(3);
click(&mut h, prefix);
assert!(h.state().auto.find(&format!("{prefix}.search")).is_some());
assert!(effectcraft_ui_egui::widgets::any_menu_open(&h.ctx), "font search must hold app shortcuts like other popup lists");
let field = h.state().auto.find(prefix).unwrap();
let search = h.state().auto.find(&format!("{prefix}.search")).unwrap();
assert!(
(field.rect[1] + field.rect[3] / 2.0 - search.rect[1] - search.rect[3] / 2.0).abs() < 1.0,
"font selection and search must occupy the same row"
);
// Search gets focus on open. Mixed case and surrounding whitespace are accepted.
type_text(&mut h, " jEtBrAiNs ");
let rows = h.state().auto.query(&format!("{prefix}.option."));
assert!(!rows.is_empty());
assert!(rows.iter().all(|r| r.label.to_lowercase().contains("jetbrains")));
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
h.render().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap();
}
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "typing only filters");
key(&mut h, Key::Enter);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Keep this text");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter");
click(&mut h, prefix);
type_text(&mut h, "no_such_font_123456789");
assert!(h.state().auto.query(&format!("{prefix}.option.")).is_empty());
assert!(h.state().auto.find(&format!("{prefix}.empty")).is_some());
key(&mut h, Key::Enter);
assert!(h.state().auto.find(&format!("{prefix}.search")).is_some(), "no result keeps menu open");
key(&mut h, Key::Escape);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter");
click(&mut h, prefix);
// Reopening clears the previous query, and mouse selection still works.
h.run_steps(2);
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
h.render().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap();
}
assert!(h.state().auto.query(&format!("{prefix}.option.")).len() > 1);
type_text(&mut h, "JetBrains");
click(&mut h, &format!("{prefix}.option.JetBrains Mono"));
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono");
}
}
#[test]
fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "First second", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.edit", json!({"layer": lid})).unwrap();
s.execute("text.setSelection", json!({"start": 0, "end": 5})).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);
click(&mut h, "character.font");
type_text(&mut h, "noto serif");
key(&mut h, Key::ArrowDown);
key(&mut h, Key::ArrowUp);
key(&mut h, Key::Enter);
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
assert_eq!(doc.text, "First second");
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");
}