G2: font menus list the installed fonts

A Windows user reported that the Properties panel's font menu offered only the bundled Inter,
JetBrains Mono and Noto Serif. The system font folders were scanned only when a project asked for
a family the bundled fonts lack, so until then every font menu (Properties, Character, Essential
Graphics) and `file.findMissing {what: fonts}` saw the bundled families alone, on every
platform. Listing the families now scans the system fonts first, and the desktop app starts that
scan on a background thread at launch so the menus are complete when first opened.

The style menus in the Properties and Character panels list the chosen family's own styles
instead of a fixed Regular / Medium / SemiBold / Bold / Italic. Both menus build their lists only
while open.

New query for agents: `text.fonts {query?, rescan?}` (MCP tool `list_fonts`) lists every family
with its styles, origin (bundled / system / user) and own-language name; `rescan` picks up fonts
installed since launch.

Tests: a fresh-process test that the family list includes every family in the platform's font
folders without a prior scan (failed before), and `text.fonts` filtering, rescan and per-family
styles, and the `list_fonts` MCP tool.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EjTYdt76hTG4Kh2CJFnCr
This commit is contained in:
Claude 2026-10-06 13:31:49 +00:00
parent e6c817bb3f
commit dda9961889
No known key found for this signature in database
11 changed files with 189 additions and 16 deletions

View File

@ -54,6 +54,8 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
#[cfg(target_os = "linux")]
appimage::integrate_from_env(ICON_PNG);

View File

@ -394,6 +394,18 @@ fn high_level_tools_add_effect_and_report_state() {
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown effect"), "{c:?}");
}
#[test]
fn list_fonts_lists_families_with_styles() {
let mut s = server();
let all = call_json(&mut s, "list_fonts", json!({}));
let fams = all["families"].as_array().unwrap();
assert!(fams.iter().any(|f| f["family"] == "Inter" && f["origin"] == "bundled"), "{all}");
let one = call_json(&mut s, "list_fonts", json!({"query": "jetbrains"}));
assert_eq!(one["count"], 1, "{one}");
assert_eq!(one["families"][0]["family"], "JetBrains Mono");
assert!(one["families"][0]["styles"].as_array().unwrap().contains(&json!("Regular")), "{one}");
}
/// Trained models over MCP (Roto Brush and face tracking): listed with their authors and
/// licence, chosen, refused when unknown.
#[test]

View File

@ -150,6 +150,10 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
}
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"))]))?)
}
/// `effect.apply` on one layer, then set parameters on the new instance by param id.
fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
let (layer, effect, comp) = (need(a, "layer")?, need(a, "effect")?, get(a, "comp"));
@ -607,6 +611,21 @@ static TOOLS: &[ToolDef] = &[
schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]),
run: list_effects,
},
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\"}`.",
bridge_only: false,
schema: || {
schema(
json!({
"query": {"type": "string", "description": "Only families whose name (English or own-language) contains this text."},
"rescan": {"type": "boolean", "description": "Look for fonts installed since launch first."}
}),
&[],
)
},
run: list_fonts,
},
ToolDef {
name: "get_state",
description: "Where things stand: the editor state (active comp, current time, selected layers/keys/items, open comps, tool, work area) plus what this build can do (`app`: version, command and effect counts, GPU effects, import/export formats, After Effects parity summary). Cheap; call it to re-orient after a series of edits.",

View File

@ -498,6 +498,33 @@ pub fn font_features(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
/// The installed font families (bundled and system), with their styles, where each comes from
/// and its name in the font's own language. `query` keeps the families whose English or native
/// name contains it (case-insensitive); `rescan` first looks for fonts installed since the app
/// started.
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 native = fonts::native_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);
}
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)))
.map(|(f, styles)| {
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system")});
if let Some(n) = native.get(&f) {
v["nativeName"] = json!(n);
}
v
})
.collect();
Ok(json!({"count": list.len(), "families": list, "added": added}))
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
@ -527,6 +554,15 @@ pub fn specs() -> Vec<CommandSpec> {
always_ok,
font_features
),
cmd!(
"text.fonts",
"List Fonts",
[],
None,
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
always_ok,
fonts
),
cmd!(
"text.setSelection",
"Set Text Selection",

View File

@ -1018,6 +1018,17 @@ pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
/// The styles a family offers (its own faces, in weight order), for the style menus. A family
/// that isn't installed offers the usual styles, which resolve to Inter's.
pub fn font_styles(family: &str) -> Vec<String> {
effectcraft_text::families()
.into_iter()
.find(|(f, _)| f.eq_ignore_ascii_case(family))
.map(|(_, st)| st)
.filter(|st| !st.is_empty())
.unwrap_or_else(|| ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect())
}
/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to.
pub fn font_features(family: &str, style: &str) -> Vec<String> {
effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features()

View File

@ -227,3 +227,27 @@ fn opentype_features_per_range_render_and_query() {
assert!(f["options"]["stylisticSets"].as_array().unwrap().contains(&json!(1)));
assert_eq!(f["options"]["smallCaps"], false);
}
#[test]
fn fonts_lists_bundled_and_installed_families() {
let mut s = Session::default();
let r = s.execute("text.fonts", json!({})).unwrap();
let fams = r["families"].as_array().unwrap();
assert_eq!(r["count"].as_u64().unwrap() as usize, fams.len());
let inter = fams.iter().find(|f| f["family"] == "Inter").unwrap();
assert_eq!(inter["origin"], "bundled");
assert!(inter["styles"].as_array().unwrap().contains(&json!("Bold")));
// Installed fonts are listed without any layer having asked for one.
use effectcraft_text::fonts::FontSource as _;
for face in effectcraft_text::fonts::DirectorySource::system().faces() {
assert!(fams.iter().any(|f| f["family"].as_str().unwrap().eq_ignore_ascii_case(&face.family)), "{} not listed", face.family);
}
// Filtered by name; rescanning keeps what was there.
let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap();
assert_eq!(r["families"][0]["family"], "Noto Serif");
assert_eq!(r["families"][0]["styles"], json!(["Regular"]));
assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser")));
// The style menus offer a family's own styles.
assert_eq!(crate::font_styles("noto serif"), vec!["Regular".to_string()]);
assert_eq!(crate::font_styles("No Such Family").len(), 5);
}

View File

@ -306,12 +306,16 @@ pub fn add_font_data(data: Vec<u8>) -> Vec<FaceId> {
names.into_iter().map(|n| push(&mut d, info_from(n, FaceData::Shared(data.clone()), "user"))).collect()
}
/// Held for a whole system scan: a concurrent caller waits for the complete list instead of
/// returning early and resolving against a half-filled database.
static SCAN: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Scan the system font folders once (native only; a no-op on wasm). Returns the number of faces
/// added by this call.
pub fn scan_system() -> usize {
// Held for the whole scan: a concurrent caller waits for the complete list instead of
// returning early and resolving against a half-filled database.
static SCAN: std::sync::Mutex<()> = std::sync::Mutex::new(());
if system_scanned() {
return 0;
}
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
if system_scanned() {
return 0;
@ -321,6 +325,31 @@ pub fn scan_system() -> usize {
n
}
/// Scan the system font folders again, adding fonts installed since the last scan (faces already
/// known are kept, so font ids stay valid). Returns the number of faces added.
pub fn rescan_system() -> usize {
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
db().write().unwrap_or_else(|e| e.into_inner()).scanned = true;
n
}
/// Start the system font scan on a background thread, so the font menus are complete by the
/// time someone opens them without the first open waiting for the scan. Native only; on wasm
/// (no threads, no font folders) this does nothing.
pub fn scan_system_in_background() {
#[cfg(not(target_arch = "wasm32"))]
{
if system_scanned() {
return;
}
// A failed spawn only means the scan happens on first use instead.
let _ = std::thread::Builder::new().name("font-scan".into()).spawn(|| {
scan_system();
});
}
}
pub fn system_scanned() -> bool {
db().read().unwrap_or_else(|e| e.into_inner()).scanned
}
@ -340,13 +369,18 @@ pub fn all_faces() -> Vec<Arc<Face>> {
}
/// Family names in the fonts' own languages: English family → native family (only families
/// that have one).
/// that have one). Scans the system fonts first if that hasn't happened yet.
pub fn native_families() -> std::collections::BTreeMap<String, String> {
scan_system();
all_faces().into_iter().filter_map(|f| Some((f.info.family.clone(), f.info.native_family.clone()?))).collect()
}
/// Families with their style names, sorted by family; styles in weight order.
/// 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
/// three bundled families alone).
pub fn families() -> Vec<(String, Vec<String>)> {
scan_system();
let mut m: std::collections::BTreeMap<String, Vec<(bool, u16, String)>> = Default::default();
for f in all_faces() {
let v = m.entry(f.info.family.clone()).or_default();
@ -649,9 +683,15 @@ mod tests {
let s = resolve("Noto Serif", "Bold Italic");
assert_eq!(face(s.face).info.family, "Noto Serif");
assert!(s.synth_bold && s.synth_italic);
// Inter Bold Italic: nearest is Italic (400) → synth bold
// Inter Bold Italic: the bundled cuts' nearest is Italic (400) → synth bold. Listing the
// families scanned the system fonts, so an installed Inter's real Bold Italic wins.
let bi = resolve("Inter", "Bold Italic");
assert!(face(bi.face).info.italic && bi.synth_bold && !bi.synth_italic);
let f = face(bi.face);
if f.info.origin == "bundled" {
assert!(f.info.italic && bi.synth_bold && !bi.synth_italic);
} else {
assert!(f.info.style.eq_ignore_ascii_case("Bold Italic") && !bi.synth_bold && !bi.synth_italic);
}
}
#[test]

View File

@ -0,0 +1,19 @@
//! The font menus list the installed fonts (issue: on Windows the Properties panel's font menu
//! offered only Inter, JetBrains Mono and Noto Serif). Its own test binary, so the system font
//! scan has not run yet when it starts.
use effectcraft_text::fonts::{DirectorySource, FontSource, families, system_scanned};
#[test]
fn families_include_installed_fonts_without_a_prior_scan() {
assert!(!system_scanned(), "nothing has scanned yet in this process");
let listed: Vec<String> = families().into_iter().map(|(f, _)| f).collect();
assert!(system_scanned() || cfg!(target_arch = "wasm32"), "listing the families scans the system fonts");
for b in ["Inter", "JetBrains Mono", "Noto Serif"] {
assert!(listed.iter().any(|f| f == b), "bundled {b} listed");
}
// Every family found in the platform's font folders is in the menu.
for face in DirectorySource::system().faces() {
assert!(listed.iter().any(|f| f.eq_ignore_ascii_case(&face.family)), "installed family {} missing from {listed:?}", face.family);
}
}

View File

@ -345,9 +345,10 @@ fn text_section(
widgets::open_popup(ui, pop);
}
app.auto.add("properties.text.font", fr, "Font family");
let fams: Vec<String> = effectcraft_engine::text_families();
if let Some(i) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)) {
set(actions, json!({"font": fams[i]}));
// Every installed family (built only while the menu is open: it can be long).
let fams: Vec<String> = if widgets::popup_is_open(ui, pop) { effectcraft_engine::text_families() } else { vec![] };
if let Some(f) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)).and_then(|i| fams.get(i)) {
set(actions, json!({"font": f}));
}
y += 28.0;
let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
@ -356,9 +357,10 @@ fn text_section(
widgets::open_popup(ui, spop);
}
app.auto.add("properties.text.style", sr, "Font style");
let styles: Vec<String> = ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect();
if let Some(i) = widgets::popup_menu(ui, spop, sr.left_bottom(), &styles, styles.iter().position(|s| *s == doc.style)) {
set(actions, json!({"style": styles[i]}));
// The family's own styles.
let styles: Vec<String> = if widgets::popup_is_open(ui, spop) { effectcraft_engine::font_styles(&doc.font) } else { vec![] };
if let Some(st) = widgets::popup_menu(ui, spop, sr.left_bottom(), &styles, styles.iter().position(|s| *s == doc.style)).and_then(|i| styles.get(i)) {
set(actions, json!({"style": st}));
}
y += 32.0;
// Size / leading, tracking / stroke width.

View File

@ -107,9 +107,10 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
widgets::open_popup(ui, spop);
}
app.auto.add("character.style", sr, "Font style");
let styles: Vec<String> = ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect();
if let Some(i) = widgets::popup_menu(ui, spop, sr.left_bottom(), &styles, styles.iter().position(|s| *s == doc.style)) {
actions.push(json!({"style": styles[i]}));
// The family's own styles.
let styles: Vec<String> = if widgets::popup_is_open(ui, spop) { effectcraft_engine::font_styles(&doc.font) } else { vec![] };
if let Some(st) = widgets::popup_menu(ui, spop, sr.left_bottom(), &styles, styles.iter().position(|s| *s == doc.style)).and_then(|i| styles.get(i)) {
actions.push(json!({"style": st}));
}
y += 34.0;
// Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 right).

View File

@ -58,6 +58,7 @@ The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MC
| `set_property {layer, path, value?, time?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe; with `expression` it sets an expression. |
| `add_keyframe {layer, path, time+value \| keys:[...], interpolation?}` | Adds keys, 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. |
| `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call; 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. |
@ -350,6 +351,12 @@ whole layer, or only characters `range: [start, end]` (character indices): chara
4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly`
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
one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` says which OpenType
options a font draws with its own glyphs.
In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the setters
(`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a
styled document.