UI: an embedder of the egui panels binds their font families without shipping their faces

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 <noreply@anthropic.com>
This commit is contained in:
Drew Dibble
2026-10-22 10:46:06 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 438c6194a6
commit 317ca00ccc
7 changed files with 154 additions and 26 deletions
+6 -2
View File
@@ -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 }
+4 -1
View File
@@ -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]
+4 -1
View File
@@ -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());
+37 -18
View File
@@ -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<String> { 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<String> = 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.
+77
View File
@@ -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()));
}
+17 -3
View File
@@ -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). |
+9 -1
View File
@@ -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()))),