From 317ca00cccc76e6ddbc51cc42a4a95e47c01efb1 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Wed, 21 Oct 2026 18:16:45 -0600 Subject: [PATCH] UI: an embedder of the egui panels binds their font families without shipping their faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panels and dialogs draw in their own egui families (`ui`, `ui-semibold`, `mono`), and egui panics drawing text in a family no font is bound to. Only `theme::install_fonts` binds them, and only `VectorcraftApp::logic` calls it, on its first frame. An embedder that draws the panels or dialogs itself (`dialogs::show`, a panel's `ui`) on its own context, without `logic`, panics on the first semibold title it draws, an effect dialog's for one. Calling `install_fonts` avoids that, but it embeds the interface's five faces (about 2 MB) whatever the embedder wants: today such an embedder keeps them out of its binary only by never calling it, so that the linker drops them. vectorcraft-ui-egui gets an `interface-fonts` feature, on by default, that embeds those faces. Without it `install_fonts` binds the same three families to egui's built-in fonts, so an embedder can always call it. Those lack some symbols the panels show (→ ▾ ● ✓), which the docs say. With the feature, `install_fonts` installs exactly what it did before: the same font data and the same family stacks. `tests/install_fonts.rs` draws an effect dialog on a context only `install_fonts` prepared, and checks the face at the head of each family in both configurations. `cargo xtask ci`'s `no-bundled-fonts` step runs it without default features and lints the crate that way. Without the `install_fonts` call, or without the semibold binding, both tests fail with egui's "FontFamily::Name(\"ui-semibold\") is not bound to any fonts". The two lib tests about the interface faces are adjusted: the graphics-loss test checks a family install_fonts binds, and the catalogs' glyph coverage test needs `interface-fonts`. docs/development.md says when an embedder calls `install_fonts`, and that it replaces the context's font definitions. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/Cargo.toml | 8 ++- crates/ui-egui/src/graphics.rs | 5 +- crates/ui-egui/src/i18n/tests.rs | 5 +- crates/ui-egui/src/theme.rs | 55 ++++++++++++------- crates/ui-egui/tests/install_fonts.rs | 77 +++++++++++++++++++++++++++ docs/development.md | 20 +++++-- xtask/src/main.rs | 10 +++- 7 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 crates/ui-egui/tests/install_fonts.rs diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index dc7311745..ba650c3b6 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,11 +9,15 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap workspace = true [features] -default = ["bundled-fonts"] +default = ["bundled-fonts", "interface-fonts"] # The fonts vectorcraft-text bundles for documents, through the engine's `bundled-fonts`. Off, for # an embedder that supplies every document font at run time (`vectorcraft_text::FontDb::add_font`); -# the interface's own faces are separate copies in theme.rs and stay either way. +# the interface's own faces are `interface-fonts`. bundled-fonts = ["vectorcraft-engine/bundled-fonts"] +# The interface's own faces (theme.rs: Inter, Source Sans 3, JetBrains Mono, about 2 MB). Off, the +# panels and dialogs draw in egui's built-in fonts, which lack some symbols they show (→ ▾ ● ✓); +# `theme::install_fonts` binds their families either way. +interface-fonts = [] [dependencies] craft-ui = { workspace = true } diff --git a/crates/ui-egui/src/graphics.rs b/crates/ui-egui/src/graphics.rs index e246f987e..88c37fef9 100644 --- a/crates/ui-egui/src/graphics.rs +++ b/crates/ui-egui/src/graphics.rs @@ -182,6 +182,8 @@ mod tests { assert_eq!(CACHE.with(|c| c.borrow().clone()), [8], "and filled again"); } + /// After the graphics are lost, the app uploads its canvas and previews to the new context and + /// applies its theme and fonts there again. #[test] fn the_app_uploads_everything_again_in_a_new_context() { let mut app = app(); @@ -212,7 +214,8 @@ mod tests { assert_eq!(texture_name(&new, canvas).as_deref(), Some("canvas"), "uploaded to the new context"); assert_eq!(texture_name(&new, preview(&new)).as_deref(), Some("preview-8x8@1:loss"), "cached previews are made again"); assert_eq!(new.global_style().visuals.panel_fill, old.global_style().visuals.panel_fill, "the theme is applied again"); - assert!(new.fonts(|f| f.definitions().font_data.contains_key("SourceSans3")), "and the fonts installed"); + let families = new.fonts(|f| f.definitions().families.clone()); + assert!(families.contains_key(&egui::FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into())), "and the fonts installed"); } #[test] diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ca7985025..a2c154e19 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -281,6 +281,7 @@ fn is_cjk(c: char) -> bool { } /// Georgian letters (Mkhedruli, Mtavruli, Asomtavruli): only craft-fonts' Noto Sans Georgian has them. +#[cfg(feature = "interface-fonts")] fn is_georgian(c: char) -> bool { matches!(c as u32, 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F) } @@ -290,7 +291,9 @@ fn is_georgian(c: char) -> bool { /// the craft-fonts build input, which is optional: without it they are left to the installed /// fonts, and with it the Japanese catalog is checked against BIZ UDPGothic (the Traditional /// Chinese one waits for a Traditional Chinese UI font in craft-fonts). Georgian letters are the -/// same: checked against Noto Sans Georgian when craft-fonts has it. +/// same: checked against Noto Sans Georgian when craft-fonts has it. It needs the interface's own +/// faces (`interface-fonts`): egui's built-in fonts lack some of the catalogs' symbols. +#[cfg(feature = "interface-fonts")] #[test] fn bundled_fonts_cover_the_catalogs() { let japanese_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_japanese()); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2babb91da..bff6ff8ca 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -495,35 +495,54 @@ pub fn mono(size: f32) -> FontId { FontId::new(size, FontFamily::Name(FONT_MONO.into())) } +/// Installs the interface's fonts and binds the families the panels and dialogs draw in +/// ([`FONT_UI`], [`FONT_UI_SEMIBOLD`], [`FONT_MONO`]). egui panics drawing text in a family no font +/// is bound to, so every context that shows them needs this once: [`crate::VectorcraftApp::logic`] +/// calls it on its first frame, and an embedder that draws the panels or dialogs without it calls +/// it when it makes the context. It replaces the context's font definitions: add other fonts after +/// it, or keep these families in definitions of one's own. Without the `interface-fonts` feature +/// the families are egui's built-in fonts (and craft-fonts' faces in a build with them), which lack +/// some symbols the panels show (→ ▾ ● ✓). pub fn install_fonts(ctx: &egui::Context) { let mut fonts = FontDefinitions::default(); + let (ui, semi, mono) = interface_faces(&mut fonts); + let stack = |fonts: &FontDefinitions, family: FontFamily| fonts.families.get(&family).cloned().unwrap_or_default(); + let (fallback, monospace) = (stack(&fonts, FontFamily::Proportional), stack(&fonts, FontFamily::Monospace)); + // Without its own monospace face, `mono` is egui's; with it, the face falls back to the text's. + let mono_fallback = if mono.is_empty() { monospace } else { fallback.clone() }; + let family = + |faces: Vec<&str>, fallback: &[String]| -> Vec { faces.into_iter().map(str::to_owned).chain(fallback.iter().cloned()).collect() }; + fonts.families.insert(FontFamily::Proportional, family(ui.clone(), &fallback)); + fonts.families.insert(FontFamily::Name(FONT_UI_SEMIBOLD.into()), family(semi, &fallback)); + fonts.families.insert(FontFamily::Name(FONT_UI.into()), family(ui, &fallback)); + fonts.families.insert(FontFamily::Name(FONT_MONO.into()), family(mono, &mono_fallback)); + add_craft_fonts(&mut fonts); + ctx.set_fonts(fonts); +} + +/// The interface's own faces, added to `fonts`: the names of the regular, semibold and monospace +/// faces that head their families, in order. +#[cfg(feature = "interface-fonts")] +fn interface_faces(fonts: &mut FontDefinitions) -> (Vec<&'static str>, Vec<&'static str>, Vec<&'static str>) { let add = |f: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { f.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); }; - add(&mut fonts, "SourceSans3", include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf")); + add(fonts, "SourceSans3", include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf")); // Inter is drawn a little smaller than its size: its letters are wider than Source Sans's, and // the panels are laid out for those. let inter = |bytes: &'static [u8]| Arc::new(FontData::from_static(bytes).tweak(egui::epaint::text::FontTweak { scale: 0.92, ..Default::default() })); fonts.font_data.insert("Inter".to_owned(), inter(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))); fonts.font_data.insert("Inter-SemiBold".to_owned(), inter(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"))); - add(&mut fonts, "SourceSans3-Semibold", include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf")); - add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")); - let fallback: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); - let mut prop = vec!["Inter".to_string(), "SourceSans3".to_string()]; - prop.extend(fallback.clone()); - fonts.families.insert(FontFamily::Proportional, prop); - let mut semi = vec!["Inter-SemiBold".to_string(), "SourceSans3-Semibold".to_string()]; - semi.extend(fallback.clone()); - fonts.families.insert(FontFamily::Name(FONT_UI_SEMIBOLD.into()), semi); - let mut ui = vec!["Inter".to_string(), "SourceSans3".to_string()]; - ui.extend(fallback.clone()); - fonts.families.insert(FontFamily::Name(FONT_UI.into()), ui); - let mut mono = vec!["JetBrainsMono".to_string()]; - mono.extend(fallback); - fonts.families.insert(FontFamily::Name(FONT_MONO.into()), mono); - add_craft_fonts(&mut fonts); - ctx.set_fonts(fonts); + add(fonts, "SourceSans3-Semibold", include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf")); + add(fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")); + (vec!["Inter", "SourceSans3"], vec!["Inter-SemiBold", "SourceSans3-Semibold"], vec!["JetBrainsMono"]) +} + +/// Without the `interface-fonts` feature the interface has no faces of its own: every family is egui's. +#[cfg(not(feature = "interface-fonts"))] +fn interface_faces(_fonts: &mut FontDefinitions) -> (Vec<&'static str>, Vec<&'static str>, Vec<&'static str>) { + (Vec::new(), Vec::new(), Vec::new()) } /// The egui name of a craft-fonts face. diff --git a/crates/ui-egui/tests/install_fonts.rs b/crates/ui-egui/tests/install_fonts.rs new file mode 100644 index 000000000..a209b80ca --- /dev/null +++ b/crates/ui-egui/tests/install_fonts.rs @@ -0,0 +1,77 @@ +//! `theme::install_fonts` on a context the app never ran on, as an embedder that draws the panels +//! or dialogs itself makes it: every family they draw in is bound, so an effect dialog draws. With +//! the `interface-fonts` feature the interface's own faces head them; without it +//! (`cargo test -p vectorcraft-ui-egui --no-default-features --test install_fonts`) they are +//! egui's. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use egui::{FontFamily, FontId}; +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_ui_egui::{VectorcraftApp, dialogs, theme}; + +/// The faces `install_fonts` adds with the `interface-fonts` feature. +const INTERFACE_FACES: [&str; 5] = ["Inter", "Inter-SemiBold", "SourceSans3", "SourceSans3-Semibold", "JetBrainsMono"]; + +/// A context with the interface's fonts installed and nothing else done to it. +fn context() -> egui::Context { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + ctx +} + +/// One headless frame running `draw`. +fn frame(ctx: &egui::Context, draw: impl FnMut(&mut egui::Ui)) { + let mut out = ctx.run_ui(egui::RawInput::default(), draw); + out.textures_delta.clear(); +} + +/// Text in each family the panels draw in lays out, and the faces at the head of those families are +/// the interface's with `interface-fonts` and egui's without it. +#[test] +fn every_family_the_panels_draw_in_is_bound() { + let ctx = context(); + frame(&ctx, |ui| { + for font in [theme::semibold(12.0), theme::mono(12.0), FontId::new(12.0, FontFamily::Name(theme::FONT_UI.into()))] { + ui.label(egui::RichText::new("Gaussian Blur").font(font)); + } + }); + let definitions = ctx.fonts(|fonts| fonts.definitions().clone()); + for name in [theme::FONT_UI, theme::FONT_UI_SEMIBOLD, theme::FONT_MONO] { + let family = &definitions.families[&FontFamily::Name(name.into())]; + assert!(!family.is_empty(), "{name} has no font"); + } + let installed: Vec<&str> = INTERFACE_FACES.into_iter().filter(|face| definitions.font_data.contains_key(*face)).collect(); + let family = |family: FontFamily| definitions.families[&family].clone(); + let named = |name: &str| family(FontFamily::Name(name.into())); + if cfg!(feature = "interface-fonts") { + assert_eq!(installed, INTERFACE_FACES); + assert_eq!(named(theme::FONT_UI)[0], "Inter"); + assert_eq!(named(theme::FONT_UI_SEMIBOLD)[0], "Inter-SemiBold"); + assert_eq!(named(theme::FONT_MONO)[0], "JetBrainsMono"); + assert_eq!(family(FontFamily::Proportional)[0], "Inter"); + } else { + assert!(installed.is_empty(), "{installed:?}"); + let egui = egui::FontDefinitions::default(); + let proportional = &egui.families[&FontFamily::Proportional]; + assert!(named(theme::FONT_UI).starts_with(proportional)); + assert!(named(theme::FONT_UI_SEMIBOLD).starts_with(proportional)); + assert!(named(theme::FONT_MONO).starts_with(&egui.families[&FontFamily::Monospace])); + assert!(family(FontFamily::Proportional).starts_with(proportional)); + } +} + +/// An effect dialog, whose titles are semibold, draws without panicking on a context only +/// `install_fonts` prepared. +#[test] +fn an_effect_dialog_draws_on_a_context_the_app_never_ran_on() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "blur.gaussian"})).unwrap(); + assert!(app.ui.dialog.is_some()); + let ctx = context(); + frame(&ctx, |ui| dialogs::show(&mut app, ui.ctx())); +} diff --git a/docs/development.md b/docs/development.md index 4fc518178..9a6576f33 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,6 @@ # Development +> **Last updated:** 2026-10-21 · **Change:** the interface faces as vectorcraft-ui-egui's `interface-fonts` feature, and `theme::install_fonts` for an embedder that draws the panels itself. > **Last updated:** 2026-10-21 · **Change:** leaving out the bundled fonts with the egui panels (vectorcraft-ui-egui's and vectorcraft-mcp's `bundled-fonts` features). > **Last updated:** 2026-10-14 · **Change:** leaving out the bundled fonts (`bundled-fonts` feature). > **Last updated:** 2026-10-14 · **Change:** document-font registration for all explicitly supplied faces. @@ -171,8 +172,17 @@ and text has no glyphs until a font is added. An embedder that also hosts the egui panels depends on `vectorcraft-ui-egui` with `default-features = false` as well: its own `bundled-fonts` feature, on by default, turns the engine's on; `vectorcraft-mcp` has the same feature. The panels' interface faces (Source Sans 3 -Regular and Semibold, Inter Regular and SemiBold, JetBrains Mono) are separate copies in -`theme.rs` and stay either way. +Regular and Semibold, Inter Regular and SemiBold, JetBrains Mono, about 2 MB: `theme.rs`) are +`vectorcraft-ui-egui`'s `interface-fonts` feature, also on by default; without it the panels draw in +egui's built-in fonts, which lack some symbols they show (→ ▾ ● ✓), so such an embedder adds a +font that has them to each of the families below (`ctx.add_font` with an `InsertFontFamily` per +family). + +The panels and dialogs draw in their own egui families (`theme::FONT_UI`, `FONT_UI_SEMIBOLD`, +`FONT_MONO`), and egui panics drawing text in a family no font is bound to. `VectorcraftApp::logic` +binds them on its first frame; an embedder that draws the panels or dialogs without it calls +`theme::install_fonts` once on its context, with or without `interface-fonts`. It replaces the +context's font definitions, so the embedder's own fonts are added after it. - The workspace declares `vectorcraft-text` and `vectorcraft-engine` without default features, so each crate that wants the fonts asks for them (the apps, and `vectorcraft-testkit` for tests); @@ -184,7 +194,10 @@ Regular and Semibold, Inter Regular and SemiBold, JetBrains Mono) are separate c vectorcraft-text`, and the same for `vectorcraft-mcp`), and with them do; a test of either can't see that configuration, since their dev-dependencies turn the fonts on. - `cargo test -p vectorcraft-text --no-default-features --test without_bundled_fonts` checks the - configuration without them. + configuration without them, and `cargo test -p vectorcraft-ui-egui --no-default-features --test + install_fonts` that the panels without their interface faces still bind every family; the same + step lints `vectorcraft-ui-egui` without its default features. Of the crate's other tests, those + about the interface faces need them. ### Arabic fonts and the web build @@ -442,6 +455,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Fonts › Leaving out the bundled fonts: vectorcraft-ui-egui's `interface-fonts` feature, and `theme::install_fonts` for an embedder that draws the panels without `VectorcraftApp::logic`. | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | Fonts › Leaving out the bundled fonts: vectorcraft-ui-egui's and vectorcraft-mcp's `bundled-fonts` features, and the engine declared without default features. | | 2026-10-14 | minor | Document the `bundled-fonts` feature of vectorcraft-text and vectorcraft-engine (Fonts › Leaving out the bundled fonts). | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fdd0f251a..04486ff2f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -256,7 +256,15 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo test -p vectorcraft-text --no-default-features --test without_bundled_fonts")?; // The egui panels and the MCP server as such an embedder builds them: nothing turns // the fonts back on. - libraries_without_bundled_fonts() + libraries_without_bundled_fonts()?; + // The panels without their interface faces lint clean and still bind every family + // they draw in. + let mut c = cargo(); + c.args(["clippy", "-p", "vectorcraft-ui-egui", "--no-default-features", "--all-targets", "--", "-D", "warnings"]); + run(c, "cargo clippy -p vectorcraft-ui-egui --no-default-features --all-targets -- -D warnings")?; + let mut c = cargo(); + c.args(["test", "-p", "vectorcraft-ui-egui", "--no-default-features", "--test", "install_fonts"]); + run(c, "cargo test -p vectorcraft-ui-egui --no-default-features --test install_fonts") }), ), ("pdf-deps", Box::new(|| pdf_deps::run(&root()))),