UI fonts: symbol fallback faces so interface symbols aren't boxes (#197)

UI fonts such as Noto Sans, Cantarell and egui's built-in Ubuntu lack some
of the symbols the interface draws as text (▸ ⌃ ✓ ✕ ▲ ▼ arrows), so they
rendered as empty boxes. Two symbol faces now sit at the end of every font
chain: DejaVu Sans (Debian, Fedora and Arch paths; Segoe UI Symbol on
Windows, Apple Symbols on macOS) and Noto Sans Symbols 2. The UI font
candidates also gain Fedora's package folders (#54).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 12:14:16 +02:00
co-authored by Claude Opus 5.5
parent 9f896c1249
commit 022aba41a8
3 changed files with 108 additions and 3 deletions
+1
View File
@@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-11 | Interface symbols (▸ ⌃ ✓ ✕ arrows) no longer show as boxes on Linux: symbol fallback faces and Fedora font paths (#197, #54) |
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
+78 -3
View File
@@ -207,6 +207,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);
@@ -216,6 +221,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 {
@@ -388,7 +396,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`,
@@ -513,6 +529,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] {
@@ -584,8 +653,14 @@ mod tests {
#[test]
fn language_reorders_only_the_han_faces() {
let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
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] {
assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)");
+29
View File
@@ -0,0 +1,29 @@
//! 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::<Vec<_>>()
};
let tofu: Vec<_> = glyphs("\u{25FB}").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);
}
}