mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
# Conflicts: # ROADMAP.md # crates/ui-egui/tests/keyboard_shortcuts.rs # docs/file-format-parity.md # docs/localization-parity.md # docs/ui-parity.md
993 lines
47 KiB
Rust
993 lines
47 KiB
Rust
//! Design tokens and fonts.
|
|
//!
|
|
//! Colours are our own, tuned to read like a modern desktop spreadsheet. Fonts come from the
|
|
//! operating system at runtime (nothing bundled): the UI uses the platform UI font, cells use the
|
|
//! best available match for the workbook font (Calibri/Carlito/Aptos/Arial…) with egui's built-in
|
|
//! fonts as the last resort.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Tokens {
|
|
pub dark: bool,
|
|
pub window: Color32,
|
|
pub ribbon: Color32,
|
|
pub ribbon_border: Color32,
|
|
pub text: Color32,
|
|
pub text_dim: Color32,
|
|
pub text_disabled: Color32,
|
|
pub accent: Color32,
|
|
pub accent_dark: Color32,
|
|
pub accent_soft: Color32,
|
|
pub hover: Color32,
|
|
pub pressed: Color32,
|
|
pub separator: Color32,
|
|
pub grid_bg: Color32,
|
|
/// Behind the pages in Page Layout view.
|
|
pub desk: Color32,
|
|
pub gridline: Color32,
|
|
pub header_bg: Color32,
|
|
pub header_text: Color32,
|
|
pub header_line: Color32,
|
|
pub header_sel_bg: Color32,
|
|
pub header_sel_text: Color32,
|
|
pub header_all_bg: Color32,
|
|
pub sel_fill: Color32,
|
|
pub sel_border: Color32,
|
|
pub input_bg: Color32,
|
|
pub input_border: Color32,
|
|
pub tab_bar: Color32,
|
|
pub tab_active: Color32,
|
|
pub status_bar: Color32,
|
|
pub cell_text: Color32,
|
|
pub menu_bg: Color32,
|
|
pub shadow: Color32,
|
|
pub danger: Color32,
|
|
/// Info-bar (notice strip) fills, by severity.
|
|
pub notice_warn_bg: Color32,
|
|
pub notice_warn_border: Color32,
|
|
pub notice_info_bg: Color32,
|
|
pub notice_info_border: Color32,
|
|
}
|
|
|
|
impl Tokens {
|
|
pub fn light() -> Tokens {
|
|
Tokens {
|
|
dark: false,
|
|
window: Color32::from_rgb(0xF3, 0xF3, 0xF3),
|
|
ribbon: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
|
ribbon_border: Color32::from_rgb(0xE3, 0xE3, 0xE3),
|
|
text: Color32::from_rgb(0x24, 0x24, 0x24),
|
|
text_dim: Color32::from_rgb(0x61, 0x61, 0x61),
|
|
text_disabled: Color32::from_rgb(0xB0, 0xB0, 0xB0),
|
|
accent: Color32::from_rgb(0x10, 0x7C, 0x41),
|
|
accent_dark: Color32::from_rgb(0x0B, 0x5E, 0x31),
|
|
accent_soft: Color32::from_rgb(0xDF, 0xF1, 0xE6),
|
|
hover: Color32::from_rgb(0xEB, 0xEB, 0xEB),
|
|
pressed: Color32::from_rgb(0xDD, 0xDD, 0xDD),
|
|
separator: Color32::from_rgb(0xE0, 0xE0, 0xE0),
|
|
grid_bg: Color32::WHITE,
|
|
desk: Color32::from_rgb(0xDE, 0xDE, 0xDE),
|
|
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
|
header_bg: Color32::from_rgb(0xF8, 0xF8, 0xF8),
|
|
header_text: Color32::from_rgb(0x42, 0x42, 0x42),
|
|
header_line: Color32::from_rgb(0xD6, 0xD6, 0xD6),
|
|
header_sel_bg: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
|
header_sel_text: Color32::from_rgb(0x10, 0x7C, 0x41),
|
|
header_all_bg: Color32::from_rgb(0xC8, 0xE6, 0xD3),
|
|
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C),
|
|
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
|
input_bg: Color32::WHITE,
|
|
input_border: Color32::from_rgb(0xD1, 0xD1, 0xD1),
|
|
tab_bar: Color32::from_rgb(0xF3, 0xF3, 0xF3),
|
|
tab_active: Color32::WHITE,
|
|
status_bar: Color32::from_rgb(0xF3, 0xF3, 0xF3),
|
|
cell_text: Color32::BLACK,
|
|
menu_bg: Color32::WHITE,
|
|
shadow: Color32::from_black_alpha(40),
|
|
danger: Color32::from_rgb(0xC4, 0x2B, 0x1C),
|
|
notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE),
|
|
notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E),
|
|
notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB),
|
|
notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC),
|
|
}
|
|
}
|
|
|
|
pub fn dark() -> Tokens {
|
|
Tokens {
|
|
dark: true,
|
|
window: Color32::from_rgb(0x29, 0x29, 0x29),
|
|
ribbon: Color32::from_rgb(0x33, 0x33, 0x33),
|
|
ribbon_border: Color32::from_rgb(0x40, 0x40, 0x40),
|
|
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
|
|
text_dim: Color32::from_rgb(0xB0, 0xB0, 0xB0),
|
|
text_disabled: Color32::from_rgb(0x70, 0x70, 0x70),
|
|
accent: Color32::from_rgb(0x5C, 0xC5, 0x87),
|
|
accent_dark: Color32::from_rgb(0x3F, 0xA8, 0x6C),
|
|
accent_soft: Color32::from_rgb(0x23, 0x45, 0x31),
|
|
hover: Color32::from_rgb(0x42, 0x42, 0x42),
|
|
pressed: Color32::from_rgb(0x4D, 0x4D, 0x4D),
|
|
separator: Color32::from_rgb(0x48, 0x48, 0x48),
|
|
grid_bg: Color32::WHITE,
|
|
desk: Color32::from_rgb(0x3A, 0x3A, 0x3A),
|
|
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
|
header_bg: Color32::from_rgb(0x30, 0x30, 0x30),
|
|
header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC),
|
|
header_line: Color32::from_rgb(0x45, 0x45, 0x45),
|
|
header_sel_bg: Color32::from_rgb(0x45, 0x45, 0x45),
|
|
header_sel_text: Color32::from_rgb(0x5C, 0xC5, 0x87),
|
|
header_all_bg: Color32::from_rgb(0x2E, 0x55, 0x3E),
|
|
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24),
|
|
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
|
input_bg: Color32::from_rgb(0x1F, 0x1F, 0x1F),
|
|
input_border: Color32::from_rgb(0x50, 0x50, 0x50),
|
|
tab_bar: Color32::from_rgb(0x29, 0x29, 0x29),
|
|
tab_active: Color32::from_rgb(0x3A, 0x3A, 0x3A),
|
|
status_bar: Color32::from_rgb(0x29, 0x29, 0x29),
|
|
cell_text: Color32::BLACK,
|
|
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
|
|
shadow: Color32::from_black_alpha(90),
|
|
danger: Color32::from_rgb(0xF1, 0x70, 0x5F),
|
|
notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E),
|
|
notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30),
|
|
notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),
|
|
notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72),
|
|
}
|
|
}
|
|
|
|
pub fn get(ctx: &egui::Context) -> Tokens {
|
|
if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::light() }
|
|
}
|
|
}
|
|
|
|
pub const UI: &str = "ui";
|
|
pub const UI_BOLD: &str = "ui-bold";
|
|
pub const CELL: &str = "cell";
|
|
pub const CELL_BOLD: &str = "cell-bold";
|
|
pub const CELL_ITALIC: &str = "cell-italic";
|
|
pub const CELL_BOLD_ITALIC: &str = "cell-bold-italic";
|
|
pub const SERIF: &str = "serif";
|
|
pub const MONO: &str = "mono";
|
|
|
|
pub fn ui_font(size: f32) -> FontId {
|
|
FontId::new(size, FontFamily::Name(UI.into()))
|
|
}
|
|
pub fn ui_bold(size: f32) -> FontId {
|
|
FontId::new(size, FontFamily::Name(UI_BOLD.into()))
|
|
}
|
|
|
|
/// The font family to use for a cell font name and weight.
|
|
///
|
|
/// When the name is an installed font whose faces were bound by [`font_definitions`], that family
|
|
/// is used; otherwise the name is bucketed to one of the built-in roles (mono/serif/cell). egui
|
|
/// panics on a family that was never bound, so the installed branch is gated on
|
|
/// [`crate::fonts::is_bound`] and never names a family egui hasn't been given.
|
|
pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily {
|
|
let role = crate::fonts::role_key(name, bold, italic);
|
|
if crate::fonts::is_bound(&role) {
|
|
return FontFamily::Name(role.into());
|
|
}
|
|
let lower = name.to_ascii_lowercase();
|
|
if lower.contains("courier") || lower.contains("mono") || lower.contains("consolas") {
|
|
return FontFamily::Name(MONO.into());
|
|
}
|
|
if lower.contains("times")
|
|
|| lower.contains("georgia")
|
|
|| lower.contains("cambria")
|
|
|| lower.contains("serif") && !lower.contains("sans")
|
|
|| lower.contains("garamond")
|
|
{
|
|
return FontFamily::Name(SERIF.into());
|
|
}
|
|
FontFamily::Name(
|
|
match (bold, italic) {
|
|
(true, true) => CELL_BOLD_ITALIC,
|
|
(true, false) => CELL_BOLD,
|
|
(false, true) => CELL_ITALIC,
|
|
(false, false) => CELL,
|
|
}
|
|
.into(),
|
|
)
|
|
}
|
|
|
|
/// Width in points, at 100% zoom, of one line of cell text drawn as the grid draws it: in the
|
|
/// face [`cell_family`] picks, which on a machine without the named font is a wider or narrower
|
|
/// substitute (#1316). A family the active font set doesn't have yet (bound fonts apply from the
|
|
/// next frame) falls back to the engine's estimate rather than letting egui panic on it.
|
|
///
|
|
/// `exact` lays the text out (a galley kept until the end of the frame); otherwise the glyph
|
|
/// advances are summed without kerning, which is cheap and never narrower. This takes the
|
|
/// Context lock (`fonts_mut`): never call it, or run a command, while an egui lock is held.
|
|
pub fn cell_text_width(ctx: &egui::Context, text: &str, font: &gridcraft_engine::model::Font, exact: bool) -> f32 {
|
|
let family = cell_family(&font.name, font.bold, font.italic);
|
|
let id = FontId::new(font.size * 96.0 / 72.0, family);
|
|
ctx.fonts_mut(|f| {
|
|
f.families().contains(&id.family).then(|| {
|
|
if exact {
|
|
f.layout_no_wrap(text.to_owned(), id, Color32::PLACEHOLDER).size().x
|
|
} else {
|
|
// Half a point covers the galley's rounding of its width up to the pixel grid.
|
|
text.chars().map(|c| f.glyph_width(&id, c)).sum::<f32>() + 0.5
|
|
}
|
|
})
|
|
})
|
|
.unwrap_or_else(|| gridcraft_engine::cmd::format::approx_text_width(text, font.size, font.bold))
|
|
}
|
|
|
|
/// Whether epaint can open face `index` of this font file and it maps characters to glyphs.
|
|
/// epaint panics (in `Context::set_fonts`, i.e. on startup) on a face it can't open, so every font
|
|
/// file we did not ship is checked first with the same parser and the same call epaint makes
|
|
/// (`skrifa::FontRef::from_index`): a corrupt file, something that is not an sfnt at all, or a
|
|
/// collection index past the end is rejected instead of crashing the app (#226). A face whose
|
|
/// character map is unreadable (a truncated file) would open but draw nothing, so it is refused
|
|
/// too and the next candidate gets its turn.
|
|
pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool {
|
|
use skrifa::MetadataProvider;
|
|
skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| font.charmap().has_map())
|
|
}
|
|
|
|
/// Loads the first candidate that exists and parses. A file that exists but can't be parsed is
|
|
/// skipped with a warning and the next candidate is tried.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
|
|
for (p, index) in paths {
|
|
if let Ok(bytes) = std::fs::read(p) {
|
|
if !font_parses(&bytes, *index) {
|
|
log::warn!("skipping font {p} (face {index}): not a font file egui can read");
|
|
continue;
|
|
}
|
|
let mut fd = FontData::from_owned(bytes);
|
|
fd.index = *index;
|
|
return Some(fd);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not
|
|
/// parse (and its key from every family), so a bad font costs a fallback, never a crash.
|
|
pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) {
|
|
let bad: Vec<String> = fonts.font_data.iter().filter(|(_, fd)| !font_parses(&fd.font, fd.index)).map(|(k, _)| k.clone()).collect();
|
|
for key in &bad {
|
|
log::warn!("dropping font {key:?}: egui can't parse it");
|
|
fonts.font_data.remove(key);
|
|
}
|
|
if !bad.is_empty() {
|
|
for chain in fonts.families.values_mut() {
|
|
chain.retain(|k| !bad.contains(k));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn try_load(_paths: &[(&str, u32)]) -> Option<FontData> {
|
|
None
|
|
}
|
|
|
|
fn home_fonts(file: &str) -> String {
|
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
format!("{home}/Library/Fonts/{file}")
|
|
}
|
|
|
|
/// Candidate font files per role, in preference order.
|
|
fn candidates(role: &str) -> Vec<(String, u32)> {
|
|
let mac_sys = "/System/Library/Fonts";
|
|
let mac_sup = "/System/Library/Fonts/Supplemental";
|
|
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
|
// ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762).
|
|
let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"];
|
|
let mut v: Vec<(String, u32)> = Vec::new();
|
|
let mut add = |s: String, i: u32| v.push((s, i));
|
|
match role {
|
|
"ui" => {
|
|
add(format!("{mac_sys}/SFNS.ttf"), 0);
|
|
add(format!("{mac_sys}/HelveticaNeue.ttc"), 0);
|
|
add(format!("{win}\\segoeui.ttf"), 0);
|
|
for d in lin {
|
|
add(format!("{d}/cantarell/Cantarell-Regular.otf"), 0);
|
|
add(format!("{d}/noto/NotoSans-Regular.ttf"), 0);
|
|
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);
|
|
add(format!("{win}\\segoeuib.ttf"), 0);
|
|
for d in lin {
|
|
add(format!("{d}/noto/NotoSans-Bold.ttf"), 0);
|
|
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 {
|
|
"cell" => {
|
|
("Carlito-Regular.ttf", "calibri.ttf", "Calibri.ttf", "Arial.ttf", "arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf")
|
|
}
|
|
"cell-bold" => (
|
|
"Carlito-Bold.ttf",
|
|
"calibrib.ttf",
|
|
"Calibri Bold.ttf",
|
|
"Arial Bold.ttf",
|
|
"arialbd.ttf",
|
|
"LiberationSans-Bold.ttf",
|
|
"DejaVuSans-Bold.ttf",
|
|
),
|
|
"cell-italic" => (
|
|
"Carlito-Italic.ttf",
|
|
"calibrii.ttf",
|
|
"Calibri Italic.ttf",
|
|
"Arial Italic.ttf",
|
|
"ariali.ttf",
|
|
"LiberationSans-Italic.ttf",
|
|
"DejaVuSans-Oblique.ttf",
|
|
),
|
|
_ => (
|
|
"Carlito-BoldItalic.ttf",
|
|
"calibriz.ttf",
|
|
"Calibri Bold Italic.ttf",
|
|
"Arial Bold Italic.ttf",
|
|
"arialbi.ttf",
|
|
"LiberationSans-BoldItalic.ttf",
|
|
"DejaVuSans-BoldOblique.ttf",
|
|
),
|
|
};
|
|
// Calibri if the user has it installed system-wide (never from an Office bundle).
|
|
add(format!("/Library/Fonts/{calibri_mac}"), 0);
|
|
add(home_fonts(calibri_mac), 0);
|
|
add(format!("{win}\\{calibri_win}"), 0);
|
|
for d in lin {
|
|
add(format!("{d}/crosextra/{carlito}"), 0);
|
|
add(format!("{d}/carlito/{carlito}"), 0);
|
|
}
|
|
add(home_fonts(carlito), 0);
|
|
add(format!("{mac_sup}/{arial_mac}"), 0);
|
|
add(format!("{win}\\{arial_win}"), 0);
|
|
for d in lin {
|
|
add(format!("{d}/liberation/{lib}"), 0);
|
|
add(format!("{d}/dejavu/{dejavu}"), 0);
|
|
}
|
|
}
|
|
"serif" => {
|
|
add(format!("{mac_sup}/Times New Roman.ttf"), 0);
|
|
add(format!("{win}\\times.ttf"), 0);
|
|
for d in lin {
|
|
add(format!("{d}/liberation/LiberationSerif-Regular.ttf"), 0);
|
|
add(format!("{d}/dejavu/DejaVuSerif.ttf"), 0);
|
|
}
|
|
}
|
|
"mono" => {
|
|
add(format!("{mac_sys}/SFNSMono.ttf"), 0);
|
|
add(format!("{mac_sys}/Menlo.ttc"), 0);
|
|
add(format!("{win}\\consola.ttf"), 0);
|
|
for d in lin {
|
|
add(format!("{d}/dejavu/DejaVuSansMono.ttf"), 0);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Builds the font set from system fonts (with egui's defaults as fallback), with the default
|
|
/// (non-Japanese) Han order. Never reads the host locale, so tests and snapshots are stable.
|
|
pub fn font_definitions() -> FontDefinitions {
|
|
font_definitions_for_language(crate::i18n::Language::En)
|
|
}
|
|
|
|
/// Which Han face leads the fallback chain. Both Han faces cover kana and most kanji, so this only
|
|
/// decides glyph shapes (and which face catches a glyph the other lacks); switching between two
|
|
/// languages with the same order needs no font rebuild.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum HanOrder {
|
|
/// Japanese glyph shapes first (the interface is Japanese).
|
|
Japanese,
|
|
/// Simplified Chinese first: the widest Han coverage, for every other interface language.
|
|
Chinese,
|
|
}
|
|
|
|
impl HanOrder {
|
|
pub fn of(language: crate::i18n::Language) -> Self {
|
|
if language == crate::i18n::Language::Ja { Self::Japanese } else { Self::Chinese }
|
|
}
|
|
}
|
|
|
|
/// Builds the font set with the Han fallback faces ordered for the interface language.
|
|
pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions {
|
|
font_definitions_with(language, &[])
|
|
}
|
|
|
|
/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are
|
|
/// installed, so cells set in them render with them; the rest fall back to the roles. The bound
|
|
/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has.
|
|
pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions {
|
|
let mut fonts = FontDefinitions::default();
|
|
let base_prop: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
|
let base_mono: Vec<String> = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default();
|
|
let mut bound: Vec<String> = Vec::new();
|
|
// The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin,
|
|
// and epaint picks the first face that has the glyph, so they only catch their own scripts.
|
|
let fallback = load_fallback(&mut fonts, HanOrder::of(language));
|
|
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
|
|
let paths = candidates(role);
|
|
let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect();
|
|
let mut chain: Vec<String> = Vec::new();
|
|
if let Some(fd) = try_load(&refs) {
|
|
let key = format!("sys-{role}");
|
|
fonts.font_data.insert(key.clone(), Arc::new(fd));
|
|
chain.push(key);
|
|
} else if role == UI_BOLD {
|
|
chain.push(format!("sys-{UI}"));
|
|
} else if role.starts_with("cell-") {
|
|
chain.push(format!("sys-{CELL}"));
|
|
}
|
|
chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() });
|
|
chain.extend(fallback.iter().cloned());
|
|
chain.retain(|k| fonts.font_data.contains_key(k));
|
|
fonts.families.insert(FontFamily::Name(role.into()), chain);
|
|
bound.push(role.to_string());
|
|
}
|
|
// Installed workbook fonts, so a cell's chosen font actually renders in it.
|
|
for family in crate::fonts::installed_among(used) {
|
|
bound.extend(crate::fonts::bind_family(&mut fonts, &family));
|
|
}
|
|
// Interface text (formula bar, name box, dialogs) falls back to the cell font before the
|
|
// script faces: whatever a cell can draw, the formula bar can draw too, even where no script
|
|
// fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it).
|
|
let cell_key = format!("sys-{CELL}");
|
|
if fonts.font_data.contains_key(&cell_key) {
|
|
for role in [UI, UI_BOLD, SERIF, MONO] {
|
|
if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into()))
|
|
&& !chain.contains(&cell_key)
|
|
{
|
|
let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len());
|
|
chain.insert(at, cell_key.clone());
|
|
}
|
|
}
|
|
}
|
|
// Default proportional text in widgets uses the UI font.
|
|
if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() {
|
|
fonts.families.insert(FontFamily::Proportional, ui);
|
|
}
|
|
drop_unparseable_fonts(&mut fonts);
|
|
// Only families egui still has data for count as bound (a dropped face can empty a chain).
|
|
bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty()));
|
|
crate::fonts::set_bound(bound);
|
|
fonts
|
|
}
|
|
|
|
/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so
|
|
/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so
|
|
/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text
|
|
/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing
|
|
/// is bundled, and a machine without the face keeps the old behaviour.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec<String> {
|
|
let mut keys = Vec::new();
|
|
// Files already loaded as a fallback face. A file serves at most one key: the region-merged
|
|
// Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and
|
|
// Hebrew), so loading it again for a later script would only duplicate tens of MB in memory.
|
|
let mut loaded: Vec<String> = Vec::new();
|
|
for (key, cands) in fallback_faces(han) {
|
|
for (path, index) in &cands {
|
|
if loaded.contains(path) {
|
|
break; // a face loaded for an earlier script already covers this one
|
|
}
|
|
if let Some(fd) = try_load(&[(path.as_str(), *index)]) {
|
|
fonts.font_data.insert(key.to_string(), Arc::new(fd));
|
|
keys.push(key.to_string());
|
|
loaded.push(path.clone());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
keys
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec<String> {
|
|
Vec::new()
|
|
}
|
|
|
|
/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script
|
|
/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one
|
|
/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so
|
|
/// the interface language reorders the two Han entries and nothing else.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> {
|
|
let ja = ("sys-cjk-han-ja", cjk_han_japanese());
|
|
let zh = ("sys-cjk-han-zh", cjk_han_simplified());
|
|
let (first, second) = match han {
|
|
HanOrder::Japanese => (ja, zh),
|
|
HanOrder::Chinese => (zh, ja),
|
|
};
|
|
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`,
|
|
/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux
|
|
/// container (`chromeos/notocjk`, #1762), plus local and flat layouts.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const LINUX_CJK_DIRS: [&str; 11] = [
|
|
"/usr/share/fonts/opentype/noto",
|
|
"/usr/share/fonts/google-noto-cjk",
|
|
"/usr/share/fonts/noto-cjk",
|
|
"/usr/share/fonts/truetype/noto",
|
|
"/usr/share/fonts/noto",
|
|
"/usr/share/fonts/truetype",
|
|
"/usr/share/fonts/TTF",
|
|
"/usr/share/fonts",
|
|
"/usr/local/share/fonts",
|
|
"/usr/local/share/fonts/noto-cjk",
|
|
"/usr/share/fonts/chromeos/notocjk",
|
|
];
|
|
|
|
/// Japanese Han + Kana faces (Japanese kanji glyph shapes).
|
|
///
|
|
/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3,
|
|
/// HK 4) with the same glyph coverage, so the index only picks glyph shapes. Whichever Han list
|
|
/// comes first loads it; [`load_fallback`] does not load the same file again for the other.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn cjk_han_japanese() -> Vec<(String, u32)> {
|
|
let mac = "/System/Library/Fonts";
|
|
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::new();
|
|
v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic
|
|
v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic
|
|
v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans
|
|
for d in LINUX_CJK_DIRS {
|
|
v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0));
|
|
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); // JP face
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Simplified Chinese Han faces. Loaded apart from the Japanese ones so a glyph one family lacks
|
|
/// (simplified-only hanzi in a Japanese font) still falls through to a real glyph.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn cjk_han_simplified() -> Vec<(String, u32)> {
|
|
let mac = "/System/Library/Fonts";
|
|
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::new();
|
|
v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei
|
|
v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun
|
|
v.push((format!("{win}\\simhei.ttf"), 0)); // SimHei
|
|
v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS
|
|
v.push((format!("{mac}/PingFang.ttc"), 0)); // older macOS only; now a downloadable asset
|
|
for d in LINUX_CJK_DIRS {
|
|
v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0));
|
|
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 2)); // SC face
|
|
}
|
|
for d in LINUX_CJK_DIRS {
|
|
v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0));
|
|
v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0));
|
|
v.push((format!("{d}/wqy-microhei.ttc"), 0));
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file
|
|
/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn cjk_hangul() -> Vec<(String, u32)> {
|
|
let mac = "/System/Library/Fonts";
|
|
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::new();
|
|
v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic
|
|
v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0));
|
|
for d in LINUX_CJK_DIRS {
|
|
v.push((format!("{d}/NotoSansCJKkr-Regular.otf"), 0));
|
|
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 1)); // KR face
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai);
|
|
/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn thai() -> Vec<(String, u32)> {
|
|
let mac_sys = "/System/Library/Fonts";
|
|
let mac_sup = "/System/Library/Fonts/Supplemental";
|
|
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
|
let lin = [
|
|
"/usr/share/fonts/truetype/noto",
|
|
"/usr/share/fonts/opentype/noto",
|
|
"/usr/share/fonts/truetype",
|
|
"/usr/share/fonts",
|
|
"/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
|
|
];
|
|
let mut v: Vec<(String, u32)> = Vec::new();
|
|
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
|
|
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
|
|
v.push((format!("{win}\\micross.ttf"), 0)); // Microsoft Sans Serif
|
|
v.push((format!("{mac_sup}/Thonburi.ttc"), 0));
|
|
v.push((format!("{mac_sys}/SukhumvitSet.ttc"), 0));
|
|
for d in lin {
|
|
v.push((format!("{d}/NotoSansThai-Regular.ttf"), 0));
|
|
v.push((format!("{d}/tlwg/Garuda.ttf"), 0));
|
|
v.push((format!("{d}/garuda/Garuda.ttf"), 0));
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has
|
|
/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn hebrew() -> Vec<(String, u32)> {
|
|
let mac_sys = "/System/Library/Fonts";
|
|
let mac_sup = "/System/Library/Fonts/Supplemental";
|
|
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
|
let lin = [
|
|
"/usr/share/fonts/truetype/noto",
|
|
"/usr/share/fonts/opentype/noto",
|
|
"/usr/share/fonts/truetype",
|
|
"/usr/share/fonts",
|
|
"/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
|
|
];
|
|
let mut v: Vec<(String, u32)> = Vec::new();
|
|
v.push((format!("{win}\\david.ttf"), 0)); // David
|
|
v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl
|
|
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
|
|
v.push((format!("{mac_sys}/ArialHB.ttc"), 0)); // Arial Hebrew
|
|
v.push((format!("{mac_sys}/SFHebrew.ttf"), 0));
|
|
v.push((format!("{mac_sup}/Arial Hebrew.ttf"), 0));
|
|
v.push((format!("{mac_sup}/David.ttf"), 0));
|
|
v.push((format!("{mac_sys}/LucidaGrande.ttc"), 0));
|
|
v.push((format!("{mac_sys}/Lucida Grande.ttc"), 0));
|
|
for d in lin {
|
|
v.push((format!("{d}/NotoSansHebrew-Regular.ttf"), 0));
|
|
v.push((format!("{d}/dejavu/DejaVuSans.ttf"), 0));
|
|
v.push((format!("{d}/freefont/FreeSans.ttf"), 0));
|
|
}
|
|
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",
|
|
"/usr/share/fonts/chromeos/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] {
|
|
let dark = theme == egui::Theme::Dark;
|
|
let t = if dark { Tokens::dark() } else { Tokens::light() };
|
|
let mut v = if dark { Visuals::dark() } else { Visuals::light() };
|
|
v.panel_fill = t.window;
|
|
v.window_fill = t.menu_bg;
|
|
v.extreme_bg_color = t.input_bg;
|
|
v.selection.bg_fill = t.accent_soft;
|
|
v.selection.stroke = Stroke::new(1.0, t.accent);
|
|
v.hyperlink_color = t.accent;
|
|
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text);
|
|
v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text);
|
|
v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
|
|
v.widgets.hovered.weak_bg_fill = t.hover;
|
|
v.widgets.active.weak_bg_fill = t.pressed;
|
|
v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border);
|
|
v.window_corner_radius = egui::CornerRadius::same(10);
|
|
v.menu_corner_radius = egui::CornerRadius::same(8);
|
|
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow };
|
|
ctx.set_visuals_of(theme, v);
|
|
}
|
|
ctx.options_mut(|o| o.fallback_theme = egui::Theme::Light);
|
|
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
|
|
ctx.all_styles_mut(|s| {
|
|
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
|
s.spacing.button_padding = egui::vec2(6.0, 3.0);
|
|
s.spacing.interact_size.y = 22.0;
|
|
s.text_styles.insert(egui::TextStyle::Body, ui_font(13.0));
|
|
s.text_styles.insert(egui::TextStyle::Button, ui_font(13.0));
|
|
s.text_styles.insert(egui::TextStyle::Small, ui_font(11.0));
|
|
s.text_styles.insert(egui::TextStyle::Heading, ui_bold(18.0));
|
|
s.text_styles.insert(egui::TextStyle::Monospace, FontId::new(12.0, FontFamily::Name(MONO.into())));
|
|
});
|
|
}
|
|
|
|
/// Model colour → egui colour.
|
|
pub fn color32(rgb: [u8; 3]) -> Color32 {
|
|
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod font_binding_tests {
|
|
use super::*;
|
|
|
|
/// The families [`font_definitions`] always binds, whatever is installed.
|
|
fn always_bound() -> Vec<String> {
|
|
[UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() {
|
|
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
// Nothing installed: every name buckets to a role that font_definitions bound.
|
|
crate::fonts::set_bound(always_bound());
|
|
for name in ["", "Calibri", "Courier New", "Times New Roman", "Grossly Missing Font", "\u{1F600}"] {
|
|
for (b, i) in [(false, false), (true, false), (false, true), (true, true)] {
|
|
let FontFamily::Name(fam) = cell_family(name, b, i) else { panic!("{name:?} should name a family") };
|
|
assert!(crate::fonts::is_bound(&fam), "{name:?} {b} {i} → unbound {fam:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_bound_installed_font_is_used_by_name() {
|
|
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
let role = crate::fonts::role_key("Ubuntu", true, false);
|
|
crate::fonts::set_bound(always_bound().into_iter().chain([role.clone()]));
|
|
assert!(matches!(cell_family("Ubuntu", true, false), FontFamily::Name(f) if *f == *role));
|
|
// The same family in a style that wasn't bound falls back to a built-in role that is.
|
|
let FontFamily::Name(fallback) = cell_family("Ubuntu", false, true) else { panic!("should name a family") };
|
|
assert!(crate::fonts::is_bound(&fallback), "the fallback is a bound role");
|
|
crate::fonts::set_bound(always_bound());
|
|
}
|
|
|
|
#[test]
|
|
fn arbitrary_names_never_yield_an_unbound_family() {
|
|
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
// A workbook can hold any font name; none may name a family egui has no data for.
|
|
crate::fonts::set_bound(always_bound());
|
|
for seed in 0..500u32 {
|
|
let name: String =
|
|
(0..(seed % 40)).map(|k| char::from_u32(0x20 + (seed.wrapping_mul(31).wrapping_add(k) % 0x5F)).unwrap_or('x')).collect();
|
|
let FontFamily::Name(fam) = cell_family(&name, seed % 2 == 0, seed % 3 == 0) else { continue };
|
|
assert!(crate::fonts::is_bound(&fam), "{name:?} → unbound {fam:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, not(target_arch = "wasm32")))]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::i18n::Language;
|
|
|
|
#[test]
|
|
fn fallback_faces_are_appended_to_every_family() {
|
|
// Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain
|
|
// (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order.
|
|
for han in [HanOrder::Chinese, HanOrder::Japanese] {
|
|
let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh };
|
|
let fonts = font_definitions_for_language(language);
|
|
let fb_keys: Vec<String> =
|
|
fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect();
|
|
if fb_keys.is_empty() {
|
|
return; // no fallback face on this machine (minimal Linux)
|
|
}
|
|
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
|
|
let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default();
|
|
assert!(chain.len() >= fb_keys.len(), "{role} chain too short for its fallback");
|
|
let tail = &chain[chain.len() - fb_keys.len()..];
|
|
assert_eq!(tail, fb_keys.as_slice(), "{role} must end its chain with the fallback faces");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn interface_families_fall_back_to_the_cell_font() {
|
|
// Structural (#349): the formula bar, name box and dialogs draw every script the cells can,
|
|
// so each interface chain carries the cell face, ahead of the script fallback faces.
|
|
let fonts = font_definitions_for_language(Language::En);
|
|
let cell = format!("sys-{CELL}");
|
|
if !fonts.font_data.contains_key(&cell) {
|
|
return; // no system cell font on this machine
|
|
}
|
|
let fb: Vec<String> = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect();
|
|
for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] {
|
|
let chain = fonts.families.get(&family).cloned().unwrap_or_default();
|
|
let at = chain.iter().position(|k| *k == cell);
|
|
assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}");
|
|
let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len());
|
|
assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces");
|
|
}
|
|
}
|
|
|
|
#[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", "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, Language::Fr, Language::Tr] {
|
|
assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_faces_load_each_file_once() {
|
|
// The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded
|
|
// once, not once per script (tens of MB each).
|
|
for language in [Language::En, Language::Ja] {
|
|
let fonts = font_definitions_for_language(language);
|
|
let present: Vec<&Arc<FontData>> =
|
|
fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect();
|
|
for (i, a) in present.iter().enumerate() {
|
|
for b in present.iter().skip(i + 1) {
|
|
assert!(a.font != b.font, "the same font file is loaded for two fallback scripts");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_covers_its_script() {
|
|
// Real render check, per script: the proportional family (what cells and the UI use) must
|
|
// resolve a sample string to a real glyph, not the tofu replacement char. Skips a script
|
|
// whose face is absent (a minimal machine) and fails if a face is present but not wired in.
|
|
let cases: [(&str, Vec<(String, u32)>, &str); 7] = [
|
|
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
|
|
("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"),
|
|
("sys-cjk-hangul", cjk_hangul(), "한글"),
|
|
("sys-thai", thai(), "ทดสอบ"),
|
|
("sys-hebrew", hebrew(), "עברית"),
|
|
("ui", candidates(UI), "Русский"),
|
|
// Czech interface labels: every Czech accented letter, in both cases.
|
|
("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"),
|
|
];
|
|
for language in [Language::En, Language::Ja] {
|
|
let ctx = egui::Context::default();
|
|
ctx.set_fonts(font_definitions_for_language(language));
|
|
let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {});
|
|
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
|
|
// `has_glyphs` is a false negative when the face that owns the script is also the face
|
|
// that draws the replacement glyph (DejaVu Sans as the UI font owns Hebrew), so compare
|
|
// the laid out glyphs with the tofu glyph itself instead.
|
|
for family in [FontFamily::Proportional, FontFamily::Name(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().filter(|g| g.chr != ' ').map(|g| g.uv_rect)).collect::<Vec<_>>()
|
|
};
|
|
let tofu = glyphs("\u{25FB}");
|
|
for (key, cands, sample) in &cases {
|
|
if !cands.iter().any(|(p, _)| std::path::Path::new(p).exists()) {
|
|
continue; // no face for this script on this machine; nothing to assert
|
|
}
|
|
let drawn = glyphs(sample);
|
|
assert!(
|
|
!drawn.is_empty() && drawn.iter().all(|g| !tofu.contains(g)),
|
|
"{key} is present but {sample:?} still renders as tofu ({language:?}, {:?})",
|
|
font.family
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn chromeos_font_layout_is_probed() {
|
|
// #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per
|
|
// family set; a script whose list misses it renders as tofu with the font installed.
|
|
let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path);
|
|
let cros = "/usr/share/fonts/chromeos";
|
|
assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf")));
|
|
assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf")));
|
|
assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
|
|
assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
|
|
assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
|
|
assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf")));
|
|
assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf")));
|
|
assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf")));
|
|
}
|
|
|
|
#[test]
|
|
fn unparseable_font_files_are_rejected_not_fatal() {
|
|
// #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of
|
|
// bounds"). Garbage, a truncated real font and a bad collection index must all be refused.
|
|
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
|
|
assert!(font_parses(&real, 0), "egui's own font must parse");
|
|
assert!(!font_parses(b"", 0));
|
|
assert!(!font_parses(&[0xAB; 64], 0));
|
|
assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused");
|
|
assert!(!font_parses(&real, 3), "a single-face file has no face 3");
|
|
|
|
// A bad face that slipped into the definitions is dropped (key and family entries), and the
|
|
// context builds its fonts without panicking.
|
|
let mut fonts = font_definitions();
|
|
fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0])));
|
|
for chain in fonts.families.values_mut() {
|
|
chain.push("sys-hebrew".into());
|
|
}
|
|
drop_unparseable_fonts(&mut fonts);
|
|
assert!(!fonts.font_data.contains_key("sys-hebrew"));
|
|
assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew")));
|
|
let ctx = egui::Context::default();
|
|
ctx.set_fonts(fonts);
|
|
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
|
ui.label("Hebrew עברית");
|
|
});
|
|
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[test]
|
|
fn try_load_skips_a_broken_candidate() {
|
|
// A candidate that exists but is not a readable font must not win over a good one behind it.
|
|
let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id()));
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let bad = dir.join("broken.ttf");
|
|
let good = dir.join("good.ttf");
|
|
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
|
|
let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default());
|
|
let _ = std::fs::write(&good, &real);
|
|
let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned());
|
|
assert!(try_load(&[(bad_s.as_str(), 0)]).is_none());
|
|
let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]);
|
|
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|