mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
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>
179 lines
9.3 KiB
Rust
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");
|
|
}
|