diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6812c51..d2756ca 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -240,6 +240,11 @@ fn candidates(role: &str) -> Vec<(String, u32)> { add(format!("{d}/dejavu/DejaVuSans.ttf"), 0); add(format!("{d}/liberation/LiberationSans-Regular.ttf"), 0); } + // Fedora keeps each family in its own package folder (#54). + add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); + add("/usr/share/fonts/google-noto/NotoSans-Regular.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf".into(), 0); } "ui-bold" => { add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); @@ -249,6 +254,9 @@ fn candidates(role: &str) -> Vec<(String, u32)> { add(format!("{d}/dejavu/DejaVuSans-Bold.ttf"), 0); add(format!("{d}/liberation/LiberationSans-Bold.ttf"), 0); } + add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf".into(), 0); } "cell" | "cell-bold" | "cell-italic" | "cell-bold-italic" => { let (carlito, calibri_win, calibri_mac, arial_mac, arial_win, lib, dejavu) = match role { @@ -422,7 +430,15 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { HanOrder::Japanese => (ja, zh), HanOrder::Chinese => (zh, ja), }; - vec![first, second, ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-hebrew", hebrew())] + vec![ + first, + second, + ("sys-cjk-hangul", cjk_hangul()), + ("sys-thai", thai()), + ("sys-hebrew", hebrew()), + ("sys-symbols", symbols()), + ("sys-symbols-2", symbols_more()), + ] } /// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, @@ -547,6 +563,59 @@ fn hebrew() -> Vec<(String, u32)> { v } +/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's +/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), +/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover +/// Debian/Ubuntu, Fedora and Arch layouts. [`load_fallback`] skips the file if Hebrew already +/// loaded it. +#[cfg(not(target_arch = "wasm32"))] +fn symbols() -> Vec<(String, u32)> { + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let mut v: Vec<(String, u32)> = vec![(format!("{win}\\seguisym.ttf"), 0), ("/System/Library/Fonts/Apple Symbols.ttf".into(), 0)]; + for d in [ + "/usr/share/fonts/truetype/dejavu", + "/usr/share/fonts/dejavu-sans-fonts", + "/usr/share/fonts/TTF", + "/usr/share/fonts/dejavu", + "/usr/share/fonts/truetype/ttf-dejavu", + "/usr/local/share/fonts/dejavu", + "/usr/local/share/fonts", + ] { + v.push((format!("{d}/DejaVuSans.ttf"), 0)); + } + v +} + +/// A second symbol face for what the first lacks (⌘ and other technical symbols): Noto Sans +/// Symbols 2 where it is installed, or Segoe UI Symbol behind Segoe UI. +#[cfg(not(target_arch = "wasm32"))] +fn symbols_more() -> Vec<(String, u32)> { + let mut v: Vec<(String, u32)> = Vec::new(); + for d in [ + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-sans-symbols-2-fonts", + "/usr/share/fonts/google-noto", + "/usr/local/share/fonts/noto", + ] { + v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); + } + v +} + +/// Whether any symbol fallback face is installed (tests skip their glyph checks without one). +pub fn symbol_face_installed() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + symbols().iter().chain(symbols_more().iter()).any(|(p, _)| std::path::Path::new(p).exists()) + } + #[cfg(target_arch = "wasm32")] + { + false + } +} + pub fn apply(ctx: &egui::Context, dark: bool) { // Install both palettes before System can switch between the style slots. for theme in [egui::Theme::Light, egui::Theme::Dark] { @@ -618,8 +687,14 @@ mod tests { #[test] fn language_reorders_only_the_han_faces() { let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); - assert_eq!(keys(HanOrder::Japanese), ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); - assert_eq!(keys(HanOrder::Chinese), ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); + assert_eq!( + keys(HanOrder::Japanese), + ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!( + keys(HanOrder::Chinese), + ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs] { assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); diff --git a/crates/ui-egui/tests/symbol_glyphs.rs b/crates/ui-egui/tests/symbol_glyphs.rs new file mode 100644 index 0000000..7f2a55f --- /dev/null +++ b/crates/ui-egui/tests/symbol_glyphs.rs @@ -0,0 +1,30 @@ +//! The interface's symbol characters must not render as empty boxes (#197, #54): the UI font often +//! lacks arrows, check marks and geometric shapes, so a symbol face backs it up. + +use egui::{Color32, FontFamily, FontId}; +use gridcraft_ui_egui::theme; + +/// Symbols the interface draws as text (buttons, menus, dialogs, tooltips, Insert › Symbol). +const UI_SYMBOLS: &str = "▲▼▸▾✓✕←↑→↓↳↘↗⇧⌃⌘★•›≠≤≥∞"; + +#[test] +fn ui_symbols_have_glyphs_when_a_symbol_face_is_installed() { + if !theme::symbol_face_installed() { + return; // a minimal machine without any symbol face; nothing to assert + } + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test + for family in [FontFamily::Proportional, FontFamily::Name(theme::CELL.into())] { + let font = FontId::new(14.0, family); + let glyphs = |text: &str| { + let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK)); + galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.chr, g.uv_rect))).collect::>() + }; + // A private-use code point no font covers renders as the replacement box. + let tofu: Vec<_> = glyphs("\u{10FFFD}").into_iter().map(|(_, uv)| uv).collect(); + let missing: String = glyphs(UI_SYMBOLS).into_iter().filter(|(_, uv)| tofu.contains(uv)).map(|(c, _)| c).collect(); + assert!(missing.is_empty(), "these interface symbols render as boxes in {:?}: {missing}", font.family); + } +}