From dda99618899e5fbdb87c5ca02b68526e2b84e017 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 13:31:49 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_019EjTYdt76hTG4Kh2CJFnCr --- apps/effectcraft/src/main.rs | 2 + crates/automation/src/tests.rs | 12 ++++++ crates/automation/src/tools.rs | 19 +++++++++ crates/engine/src/commands/text_edit.rs | 36 ++++++++++++++++ crates/engine/src/lib.rs | 11 +++++ crates/engine/src/tests_text_edit.rs | 24 +++++++++++ crates/text/src/fonts.rs | 54 +++++++++++++++++++++--- crates/text/tests/system_font_menu.rs | 19 +++++++++ crates/ui-egui/src/panels/properties.rs | 14 +++--- crates/ui-egui/src/panels/text_panels.rs | 7 +-- docs/agents.md | 7 +++ 11 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 crates/text/tests/system_font_menu.rs diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 84cdbebb..5afb3c79 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -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); diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6a0a8023..11ba83d3 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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] diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 42612c0e..8a1ae2be 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -150,6 +150,10 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?) } +fn list_fonts(b: &mut Backend, a: &Value) -> Result { + 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 { 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.", diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 58a93f23..0ef2d6cc 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -498,6 +498,33 @@ pub fn font_features(s: &mut Session, p: &Value) -> Result { })) } +/// 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 { + 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 = Default::default(); + for f in fonts::all_faces() { + origin.entry(f.info.family.clone()).or_insert(f.info.origin); + } + let list: Vec = 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 { 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", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a3392414..e690021b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1018,6 +1018,17 @@ pub fn text_families() -> Vec { 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 { + 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 { effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features() diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 46610786..e3b6e63f 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -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); +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 4dccd53f..6e7bc1cb 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -306,12 +306,16 @@ pub fn add_font_data(data: Vec) -> Vec { 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> { } /// 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 { + 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)> { + scan_system(); let mut m: std::collections::BTreeMap> = 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] diff --git a/crates/text/tests/system_font_menu.rs b/crates/text/tests/system_font_menu.rs new file mode 100644 index 00000000..fb813e9b --- /dev/null +++ b/crates/text/tests/system_font_menu.rs @@ -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 = 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); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 09f9eda3..b93dc86f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -345,9 +345,10 @@ fn text_section( widgets::open_popup(ui, pop); } app.auto.add("properties.text.font", fr, "Font family"); - let fams: Vec = 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 = 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 = ["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 = 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. diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 782cb599..ac97d106 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -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 = ["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 = 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). diff --git a/docs/agents.md b/docs/agents.md index e97a3945..78a47121 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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.