//! Design tokens. Every widget reads colours/sizes from [`Tokens`] so themes apply everywhere. //! //! The default theme follows the look of After Effects' dark UI (values estimated from public //! documentation and product knowledge, tuned by eye; see `plan/aftereffects/README.md` §2). Fonts //! are Inter + JetBrains Mono (OFL). use std::sync::Arc; use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ThemeKind { #[default] Dark, Darker, Light, } impl ThemeKind { pub fn from_name(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { "dark" | "default" => Some(ThemeKind::Dark), "darker" | "darkest" => Some(ThemeKind::Darker), "light" => Some(ThemeKind::Light), _ => None, } } /// The settings value (`appearance.theme`, `appearance.darkTheme`, `appearance.lightTheme`). pub fn name(self) -> &'static str { match self { ThemeKind::Dark => "dark", ThemeKind::Darker => "darker", ThemeKind::Light => "light", } } pub fn is_light(self) -> bool { self == ThemeKind::Light } } #[derive(Clone, Copy, Debug, PartialEq)] pub struct Tokens { pub kind: ThemeKind, pub app_bg: Color32, pub header_bg: Color32, pub panel_bg: Color32, pub tab_text: Color32, pub tab_text_active: Color32, pub focus: Color32, pub accent: Color32, pub accent_hover: Color32, pub text: Color32, pub text_dim: Color32, pub text_faint: Color32, pub icon: Color32, pub icon_active: Color32, pub hover: Color32, pub pressed: Color32, pub field_bg: Color32, pub field_border: Color32, pub separator: Color32, pub row: Color32, pub row_alt: Color32, pub row_selected: Color32, pub hot_text: Color32, pub tl_bg: Color32, pub tl_ruler_bg: Color32, pub tl_ruler_tick: Color32, pub tl_ruler_text: Color32, pub cti: Color32, pub work_area: Color32, pub cache_green: Color32, /// Timeline cache bar for frames held in the disk cache (not in RAM). pub cache_blue: Color32, pub keyframe: Color32, pub keyframe_selected: Color32, pub pasteboard: Color32, pub timecode: Color32, pub danger: Color32, pub warning: Color32, /// Effect Controls effect headers, and the header of the effect being worked on (it or one /// of its properties selected). pub fx_header: Color32, pub fx_header_active: Color32, /// Timeline: a selected layer's row in the time graph, the boxes behind the A/V and layer /// switches, the time navigator and the work area bar. pub tl_graph_selected: Color32, pub switch_well: Color32, pub tl_nav_bar: Color32, pub tl_work_area_bar: Color32, /// The Timeline's expression field and the colour of enabled expressions (their text and /// the names of properties that have one). pub expr_bg: Color32, pub expr_text: Color32, /// Label colours (`Label::ALL` order), from Settings ▸ Labels. pub labels: [Color32; 17], pub radius: f32, pub radius_sm: f32, pub gap: f32, pub tab_h: f32, pub row_h: f32, /// Settings ▸ Appearance ▸ Use Gradients: panel tab strips and buttons get a soft vertical /// gradient (off: flat fills). pub gradients: bool, } /// A vertical gradient fill (`top` → `bottom`) over `rect`. pub fn gradient_rect(painter: &egui::Painter, rect: egui::Rect, top: Color32, bottom: Color32) { let mut m = egui::Mesh::default(); m.colored_vertex(rect.left_top(), top); m.colored_vertex(rect.right_top(), top); m.colored_vertex(rect.right_bottom(), bottom); m.colored_vertex(rect.left_bottom(), bottom); m.add_triangle(0, 1, 2); m.add_triangle(0, 2, 3); painter.add(egui::Shape::mesh(m)); } impl Tokens { /// The gradient top colour for a fill (a little lighter), or the fill itself when gradients /// are off. pub fn grad_top(&self, c: Color32) -> Color32 { if self.gradients { c.lerp_to_gamma(Color32::WHITE, 0.06) } else { c } } } impl Tokens { pub fn for_kind(kind: ThemeKind) -> Self { let dark = Tokens { kind, app_bg: Color32::from_rgb(0x12, 0x12, 0x12), header_bg: Color32::from_rgb(0x1f, 0x1f, 0x1f), panel_bg: Color32::from_rgb(0x23, 0x23, 0x23), tab_text: Color32::from_rgb(0x9a, 0x9a, 0x9a), tab_text_active: Color32::from_rgb(0xe3, 0xe3, 0xe3), focus: Color32::from_rgb(0x2d, 0x8c, 0xeb), accent: Color32::from_rgb(0x2d, 0x8c, 0xeb), accent_hover: Color32::from_rgb(0x4a, 0xa0, 0xf5), text: Color32::from_rgb(0xc8, 0xc8, 0xc8), text_dim: Color32::from_rgb(0x9a, 0x9a, 0x9a), text_faint: Color32::from_rgb(0x8c, 0x8c, 0x8c), icon: Color32::from_rgb(0xb4, 0xb4, 0xb4), icon_active: Color32::from_rgb(0xf0, 0xf0, 0xf0), hover: Color32::from_rgb(0x30, 0x30, 0x30), pressed: Color32::from_rgb(0x45, 0x45, 0x45), field_bg: Color32::from_rgb(0x17, 0x17, 0x17), field_border: Color32::from_rgb(0x3a, 0x3a, 0x3a), separator: Color32::from_rgb(0x34, 0x34, 0x34), row: Color32::from_rgb(0x23, 0x23, 0x23), row_alt: Color32::from_rgb(0x27, 0x27, 0x27), row_selected: Color32::from_rgb(0x3a, 0x3a, 0x3a), hot_text: Color32::from_rgb(0x3d, 0x8f, 0xf5), tl_bg: Color32::from_rgb(0x1b, 0x1b, 0x1b), tl_ruler_bg: Color32::from_rgb(0x23, 0x23, 0x23), tl_ruler_tick: Color32::from_rgb(0x70, 0x70, 0x70), tl_ruler_text: Color32::from_rgb(0x9a, 0x9a, 0x9a), cti: Color32::from_rgb(0x2d, 0x8c, 0xeb), work_area: Color32::from_rgb(0x4a, 0x4a, 0x4a), cache_green: Color32::from_rgb(0x3c, 0xa6, 0x4c), cache_blue: Color32::from_rgb(0x3a, 0x6f, 0xd8), keyframe: Color32::from_rgb(0xa8, 0xa8, 0xa8), keyframe_selected: Color32::from_rgb(0x3d, 0x8f, 0xf5), pasteboard: Color32::from_rgb(0x1a, 0x1a, 0x1a), timecode: Color32::from_rgb(0x3d, 0x8f, 0xf5), danger: Color32::from_rgb(0xe0, 0x4a, 0x3c), warning: Color32::from_rgb(0xe8, 0x9a, 0x2c), fx_header: Color32::from_rgb(0x2a, 0x2a, 0x2a), fx_header_active: Color32::from_rgb(0x2f, 0x3a, 0x52), tl_graph_selected: Color32::from_rgb(0x2a, 0x2a, 0x2a), switch_well: Color32::from_rgb(0x19, 0x19, 0x19), tl_nav_bar: Color32::from_rgb(0x55, 0x55, 0x55), tl_work_area_bar: Color32::from_rgb(0x5c, 0x5c, 0x5c), expr_bg: Color32::from_rgb(0x1a, 0x1a, 0x1a), expr_text: Color32::from_rgb(0xe8, 0x7c, 0x5c), labels: default_labels(), radius: 6.0, radius_sm: 3.0, gap: 4.0, tab_h: 30.0, row_h: 19.0, gradients: true, }; match kind { ThemeKind::Dark => dark, ThemeKind::Darker => Tokens { app_bg: Color32::from_rgb(0x08, 0x08, 0x08), header_bg: Color32::from_rgb(0x16, 0x16, 0x16), panel_bg: Color32::from_rgb(0x19, 0x19, 0x19), row: Color32::from_rgb(0x19, 0x19, 0x19), row_alt: Color32::from_rgb(0x1d, 0x1d, 0x1d), tl_bg: Color32::from_rgb(0x12, 0x12, 0x12), tl_ruler_bg: Color32::from_rgb(0x19, 0x19, 0x19), field_bg: Color32::from_rgb(0x0e, 0x0e, 0x0e), pasteboard: Color32::from_rgb(0x11, 0x11, 0x11), ..dark }, ThemeKind::Light => Tokens { app_bg: Color32::from_rgb(0xb4, 0xb4, 0xb4), header_bg: Color32::from_rgb(0xd6, 0xd6, 0xd6), panel_bg: Color32::from_rgb(0xe6, 0xe6, 0xe6), tab_text: Color32::from_rgb(0x5a, 0x5a, 0x5a), tab_text_active: Color32::from_rgb(0x14, 0x14, 0x14), text: Color32::from_rgb(0x22, 0x22, 0x22), text_dim: Color32::from_rgb(0x5a, 0x5a, 0x5a), text_faint: Color32::from_rgb(0x68, 0x68, 0x68), hot_text: Color32::from_rgb(0x00, 0x5a, 0x9c), timecode: Color32::from_rgb(0x00, 0x5a, 0x9c), icon: Color32::from_rgb(0x3c, 0x3c, 0x3c), icon_active: Color32::from_rgb(0x10, 0x10, 0x10), hover: Color32::from_rgb(0xd0, 0xd0, 0xd0), pressed: Color32::from_rgb(0xc0, 0xc0, 0xc0), field_bg: Color32::from_rgb(0xfa, 0xfa, 0xfa), field_border: Color32::from_rgb(0xaa, 0xaa, 0xaa), separator: Color32::from_rgb(0xc8, 0xc8, 0xc8), row: Color32::from_rgb(0xe6, 0xe6, 0xe6), row_alt: Color32::from_rgb(0xde, 0xde, 0xde), row_selected: Color32::from_rgb(0xaa, 0xc8, 0xf0), tl_bg: Color32::from_rgb(0xd2, 0xd2, 0xd2), tl_ruler_bg: Color32::from_rgb(0xe2, 0xe2, 0xe2), tl_ruler_text: Color32::from_rgb(0x50, 0x50, 0x50), pasteboard: Color32::from_rgb(0xa8, 0xa8, 0xa8), // Keyframes have no outline: dark enough to stand out on the light time graph. keyframe: Color32::from_rgb(0x6c, 0x6c, 0x6c), // Custom-painted surfaces in the Light palette, so dark text stays readable on // them (#644). fx_header: Color32::from_rgb(0xd8, 0xd8, 0xd8), fx_header_active: Color32::from_rgb(0xb0, 0xcf, 0xec), tl_graph_selected: Color32::from_rgb(0xc2, 0xc2, 0xc2), switch_well: Color32::from_rgb(0xf2, 0xf2, 0xf2), tl_nav_bar: Color32::from_rgb(0x9a, 0x9a, 0x9a), tl_work_area_bar: Color32::from_rgb(0x94, 0x94, 0x94), expr_bg: Color32::from_rgb(0xf2, 0xf2, 0xf2), expr_text: Color32::from_rgb(0xa3, 0x34, 0x17), ..dark }, } } pub fn mono(size: f32) -> FontId { FontId::new(size, FontFamily::Monospace) } pub fn ui(size: f32) -> FontId { FontId::new(size, FontFamily::Proportional) } pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name("semibold".into())) } pub fn medium(size: f32) -> FontId { FontId::new(size, FontFamily::Name("medium".into())) } /// sRGB colour of an item/layer label (Settings ▸ Labels). pub fn label(&self, l: effectcraft_color::Label) -> Color32 { let i = effectcraft_color::Label::ALL.iter().position(|x| *x == l).unwrap_or(0); self.labels[i] } /// Theme tokens for the current settings: theme, UI brightness and label colours. /// The tokens for theme `kind` (resolved from the appearance mode) with the rest of the /// Appearance settings: brightness, label colours, gradients. pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs, kind: ThemeKind) -> Tokens { let mut t = Tokens::for_kind(kind).with_brightness(p.appearance.brightness as f32); for (i, l) in effectcraft_color::Label::ALL.iter().enumerate() { let [r, g, b] = p.label_rgb(*l); t.labels[i] = Color32::from_rgb(r, g, b); } t.gradients = p.appearance.use_gradients; t } /// Lighten (b > 0) or darken (b < 0) the interface greys, like After Effects' brightness /// slider. Accent colours, labels and keyframe colours are kept. pub fn with_brightness(mut self, b: f32) -> Tokens { let b = b.clamp(-1.0, 1.0); if b.abs() < 1e-3 { return self; } let adj = |c: &mut Color32| { let f = |v: u8| -> u8 { let v = v as f32; (if b > 0.0 { v + (110.0 - v).max(0.0) * b * 0.8 + 10.0 * b } else { v * (1.0 + b * 0.6) }).round().clamp(0.0, 255.0) as u8 }; *c = Color32::from_rgb(f(c.r()), f(c.g()), f(c.b())); }; for c in [ &mut self.app_bg, &mut self.header_bg, &mut self.panel_bg, &mut self.hover, &mut self.pressed, &mut self.field_bg, &mut self.field_border, &mut self.separator, &mut self.row, &mut self.row_alt, &mut self.row_selected, &mut self.tl_bg, &mut self.tl_ruler_bg, &mut self.pasteboard, &mut self.work_area, &mut self.fx_header, &mut self.tl_graph_selected, &mut self.switch_well, &mut self.expr_bg, ] { adj(c); } self } } fn default_labels() -> [Color32; 17] { effectcraft_color::Label::ALL.map(|l| { let [r, g, b] = l.rgb(); Color32::from_rgb(r, g, b) }) } static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf"); static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); static JETBRAINS_MONO: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); /// Install fonts (Inter, Inter Medium/SemiBold, JetBrains Mono) and egui visuals. `language` is the /// resolved interface language from [`crate::i18n::language`] (`en`, `ja`, `zh-hans` or `zh-hant`), /// not the stored `general.language` preference: `system` is already resolved there, so a Chinese /// system draws Chinese with the default setting. The CJK fallback that draws the interface's own /// script is registered first, so a Chinese interface is drawn by a Chinese face and a Japanese one /// by a Japanese face. `install` is called again when the language changes (see the `EffectcraftApp` /// frame loop), so the fonts do not need a restart. pub fn install(ctx: &egui::Context, t: &Tokens, language: &str) { let mut fonts = FontDefinitions::default(); fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(INTER_REGULAR))); fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(INTER_MEDIUM))); fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(INTER_SEMIBOLD))); fonts.font_data.insert("jbmono".into(), Arc::new(FontData::from_static(JETBRAINS_MONO))); fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "inter".into()); fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into()); fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]); fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]); // Inter and egui's own fallbacks have no small triangles (the `▸` of "File ▸ Import"), which // would draw as missing-glyph boxes: JetBrains Mono fills in the symbols the others lack. for (family, stack) in &mut fonts.families { if *family != FontFamily::Monospace { stack.push("jbmono".into()); } } install_script_fallbacks(&mut fonts, language); ctx.set_fonts(fonts); apply_visuals(ctx, t); } /// Scripts the bundled fonts lack beyond CJK, each probed with one of its letters: names typed in /// them (layers, items, comments) are drawn by an installed face instead of missing-glyph boxes /// (#394). egui shapes each run of one face, so Arabic letters join and a word reads right to /// left; it has no bidirectional layout, so the words of a name still follow each other left to /// right. #[cfg(not(target_arch = "wasm32"))] const SCRIPT_PROBES: &[(char, &str)] = &[ ('م', "arabic-system"), ('א', "hebrew-system"), ('क', "devanagari-system"), ('ক', "bengali-system"), ('ਕ', "gurmukhi-system"), ('ક', "gujarati-system"), ('க', "tamil-system"), ('క', "telugu-system"), ('ಕ', "kannada-system"), ('ക', "malayalam-system"), ('ก', "thai-system"), ]; /// Register the system fallbacks for scripts the bundled fonts lack in every font family, and /// return their names in registration order (empty when nothing installed covers a probe, so a /// machine without those fonts simply keeps the bundled ones). A face that covers several probes /// (Segoe UI draws Arabic and Hebrew, Nirmala UI every Indic script) is registered once. /// /// CJK comes first. The text engine has its own script-aware fallback (#84), but which face it /// picks for Han ideographs depends on the locale environment: a Chinese interface on a /// Japanese-locale machine would be drawn with the Japanese face. The interface language is the /// better signal, so the probe for its script comes first — kana for Japanese, a Han character for /// the Chinese catalogs. Both scripts are registered, so file, layer and template names in the /// other one still render; then [`SCRIPT_PROBES`], then Hangul, since the Japanese and Chinese faces /// may lack it (Hiragino Sans on macOS has none). The web build has no system fonts /// and installs nothing. fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec { #[cfg(target_arch = "wasm32")] { let _ = (family_fonts, language); Vec::new() } #[cfg(not(target_arch = "wasm32"))] { use effectcraft_text::fonts; // Kana is unambiguously Japanese; Han ideographs are shared, so the order decides which // language's face draws them. const KANA: (char, &str) = ('あ', "japanese-system"); // Hangul is probed last: with no Korean font installed the lookup can land on a catch-all // face (Unifont) that also covers Arabic or Indic text but can't shape it, so the // script-specific faces must be registered ahead of it. A face already seen is skipped. const HANGUL: (char, &str) = ('한', "korean-system"); // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; let han = (han_probe(language), "chinese-system"); let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] }; let mut names = Vec::new(); let mut seen = Vec::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { let id = fonts::fallback_for(probe, base); if seen.contains(&id) { continue; } let face = fonts::face(id); if !face.has_char(probe) { continue; // nothing installed covers this script } let Some(font) = face.font() else { continue }; seen.push(id); // Use the already-read, parsed bytes rather than reading a font file twice. let mut data = FontData::from_owned(font.data().as_bytes().to_vec()); data.index = face.info.index; family_fonts.font_data.insert(name.to_string(), Arc::new(data)); for family in family_fonts.families.values_mut() { family.push(name.to_string()); } names.push(name.to_string()); } names } } /// The CJK locale the text engine should prefer for Han text: the interface language when it names /// one, otherwise the empty string, which leaves the operating system's locale in charge. #[cfg(not(target_arch = "wasm32"))] fn cjk_locale(language: &str) -> &str { match language { "ja" | "zh-hans" | "zh-hant" => language, _ => "", } } /// The Han character that picks the Chinese fallback face. A Simplified Chinese interface asks for /// a simplified-only hanzi: a Japanese face covers the shared ideographs (文) but not 页, 选 or 删, /// and the fallback takes a face already loaded first, so the Japanese face loaded for kana would /// draw the Chinese interface with boxes (#1141). #[cfg(not(target_arch = "wasm32"))] fn han_probe(language: &str) -> char { if language == "zh-hans" { '页' } else { '文' } } /// Whether `language` is one of the Chinese catalogs. #[cfg(not(target_arch = "wasm32"))] fn is_chinese(language: &str) -> bool { matches!(language, "zh-hans" | "zh-hant") } /// egui's theme preference for an appearance mode showing a `light` (or dark) theme. Sync with /// System leaves it at System: a concrete preference makes egui set the native window's /// appearance, and on macOS a window with its own appearance no longer hears when the system's /// changes (#642). A fixed Light or Dark mode pins the window to match. pub fn theme_preference(appearance_mode: &str, light: bool) -> egui::ThemePreference { match appearance_mode { "auto" => egui::ThemePreference::System, _ if light => egui::ThemePreference::Light, _ => egui::ThemePreference::Dark, } } pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { let light = t.kind.is_light(); let mut v = if light { Visuals::light() } else { Visuals::dark() }; v.panel_fill = t.panel_bg; v.window_fill = t.panel_bg; v.extreme_bg_color = t.field_bg; v.faint_bg_color = t.row_alt; v.override_text_color = Some(t.text); v.selection.bg_fill = t.accent; v.selection.stroke = Stroke::new(1.0, Color32::WHITE); v.hyperlink_color = t.accent; v.window_stroke = Stroke::new(1.0, t.field_border); v.window_corner_radius = egui::CornerRadius::same(8); v.menu_corner_radius = egui::CornerRadius::same(6); v.popup_shadow = egui::epaint::Shadow { offset: [0, 6], blur: 20, spread: 0, color: Color32::from_black_alpha(150) }; v.window_shadow = egui::epaint::Shadow { offset: [0, 10], blur: 36, spread: 0, color: Color32::from_black_alpha(170) }; for w in [&mut v.widgets.noninteractive, &mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] { w.corner_radius = egui::CornerRadius::same(t.radius_sm as u8); } v.widgets.noninteractive.bg_fill = t.panel_bg; v.widgets.noninteractive.weak_bg_fill = t.panel_bg; v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator); v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text); v.widgets.inactive.bg_fill = t.field_bg; v.widgets.inactive.weak_bg_fill = t.field_bg; v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.field_border); v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); v.widgets.hovered.bg_fill = t.hover; v.widgets.hovered.weak_bg_fill = t.hover; v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_border); v.widgets.hovered.fg_stroke = Stroke::new(1.0, t.tab_text_active); v.widgets.active.bg_fill = t.pressed; v.widgets.active.weak_bg_fill = t.pressed; v.widgets.active.fg_stroke = Stroke::new(1.0, t.tab_text_active); v.widgets.open.bg_fill = t.hover; v.widgets.open.weak_bg_fill = t.hover; // egui keeps one style per light/dark theme and picks it by its theme preference (see // [`theme_preference`]); both get ours, so whichever it picks, egui's defaults never show. ctx.all_styles_mut(|s| { s.visuals = v.clone(); s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; s.spacing.menu_margin = egui::Margin::same(5); // Scroll bars always show (egui's float in only over the list), so long menus and lists // can be dragged without a mouse wheel or touchpad (#269). s.spacing.scroll = egui::style::ScrollStyle::solid(); // The handle takes the text colour: the fill it would otherwise get is the colour of its // track (both are the field background), which left it invisible until hovered. s.spacing.scroll.foreground_color = true; s.text_styles.insert(TextStyle::Body, FontId::new(12.0, FontFamily::Proportional)); s.text_styles.insert(TextStyle::Button, FontId::new(12.0, FontFamily::Proportional)); s.text_styles.insert(TextStyle::Small, FontId::new(11.0, FontFamily::Proportional)); s.text_styles.insert(TextStyle::Heading, FontId::new(15.0, FontFamily::Name("semibold".into()))); s.text_styles.insert(TextStyle::Monospace, FontId::new(12.0, FontFamily::Monospace)); s.animation_time = 0.1; }); } #[cfg(all(test, not(target_arch = "wasm32")))] mod script_font_tests { use effectcraft_text::fonts; /// Whether an installed face covers `probe` (else the glyph check is skipped). fn installed(probe: char) -> bool { let base = fonts::resolve("Inter", "Regular").face; fonts::face(fonts::fallback_for(probe, base)).has_char(probe) } /// Every character of `text` draws a real glyph in every UI font family with the `language` /// interface's fonts installed. fn assert_drawn(language: &str, text: &str) { let ctx = egui::Context::default(); super::install(&ctx, &super::Tokens::for_kind(super::ThemeKind::Dark), language); let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); // No renderer here: drop the frame's texture uploads (egui asserts on unhandled ones in debug). out.textures_delta.clear(); ctx.fonts_mut(|fonts| { for family in [ egui::FontFamily::Proportional, egui::FontFamily::Monospace, egui::FontFamily::Name("medium".into()), egui::FontFamily::Name("semibold".into()), ] { let font = egui::FontId::new(13.0, family); // epaint 0.36's has_glyph compares face keys, so it reports a false // negative when a real glyph shares the replacement face. // Check the rendered atlas glyph instead of that face-level predicate. let missing = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE); let missing_uv = missing.rows[0].glyphs[0].uv_rect; for ch in text.chars() { let rendered = fonts.layout_no_wrap(ch.to_string(), font.clone(), egui::Color32::WHITE); let uv = rendered.rows[0].glyphs[0].uv_rect; assert!(uv.size.x > 0.0 && uv.size.y > 0.0, "empty {ch} in {font:?}"); assert_ne!((uv.min, uv.max), (missing_uv.min, missing_uv.max), "replacement glyph for {ch} in {font:?}"); } } }); } #[test] fn installed_japanese_fallback_is_available_in_all_ui_families() { if !installed('あ') { eprintln!("no Japanese system font installed; skipping glyph coverage"); return; } assert_drawn("ja", "日本語コンポジションレイヤーエフェクト設定"); } /// #394: Arabic, Hebrew, Indic and Thai names are drawn by an installed face in an English /// interface: the UI fonts were Inter and the CJK fallbacks only, so a layer named in Arabic /// showed as missing-glyph boxes. #[test] fn installed_script_fallbacks_draw_arabic_hebrew_indic_and_thai_names() { // Letters only: a vowel sign or virama on its own is drawn on a dotted circle. for text in ["طبقةمرحبا", "שכבה", "परत", "কলম", "ਕਲਮ", "કલમ", "கடல", "కలమ", "ಕಲಮ", "കലമ", "กขค"] { let Some(probe) = text.chars().next() else { continue }; if !installed(probe) { eprintln!("no system font covers {text}; skipping its glyph coverage"); continue; } assert_drawn("en", text); } } /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English /// interface had already loaded a Japanese face for kana, and that face covers the Han probe, /// so it became the Chinese fallback too, without the simplified-only hanzi. #[test] fn simplified_chinese_interface_draws_simplified_hanzi_after_another_language() { let ctx = egui::Context::default(); let tokens = super::Tokens::for_kind(super::ThemeKind::Dark); super::install(&ctx, &tokens, "en"); super::install(&ctx, &tokens, "zh-hans"); let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); out.textures_delta.clear(); let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); let missing: String = ctx.fonts_mut(|fonts| { let replacement = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect; // Simplified-only hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面). "选删层设导页" .chars() .filter(|ch| { let uv = fonts.layout_no_wrap(ch.to_string(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect; (uv.min, uv.max) == (replacement.min, replacement.max) }) .collect() }); // Asked after the layout: the coverage lookup itself loads a face with these hanzi. let drawable: String = missing.chars().filter(|&ch| installed(ch)).collect(); assert!(drawable.is_empty(), "an installed face covers {drawable}, but the Simplified Chinese interface draws boxes"); } /// egui shapes each run of one face: Arabic letters take their joined forms, and an Arabic /// word reads right to left (its first letter drawn rightmost). #[test] fn arabic_words_are_joined_and_read_right_to_left() { if !installed('م') { eprintln!("no Arabic system font installed; skipping"); return; } let ctx = egui::Context::default(); super::install(&ctx, &super::Tokens::for_kind(super::ThemeKind::Dark), "en"); let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); out.textures_delta.clear(); ctx.fonts_mut(|fonts| { let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); let missing = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect; let alone = fonts.layout_no_wrap("م".into(), font.clone(), egui::Color32::WHITE); let word = fonts.layout_no_wrap("مرحبا".into(), font, egui::Color32::WHITE); let glyphs = &word.rows[0].glyphs; let meem = glyphs.iter().find(|g| g.chr == 'م').expect("the meem is laid out"); let alif = glyphs.iter().find(|g| g.chr == 'ا').expect("the alif is laid out"); assert!(meem.pos.x > alif.pos.x, "the first letter is drawn rightmost: {} vs {}", meem.pos.x, alif.pos.x); let isolated = alone.rows[0].glyphs[0].uv_rect; for uv in [meem.uv_rect, isolated] { assert_ne!((uv.min, uv.max), (missing.min, missing.max), "a real glyph, not the replacement"); } assert_ne!((meem.uv_rect.min, meem.uv_rect.max), (isolated.min, isolated.max), "the meem takes its initial form"); }); } } #[cfg(test)] mod tests { use super::*; /// The last `n` font names of `family`, in order. fn tail(family: &[String], n: usize) -> Vec<&str> { family.iter().rev().take(n).rev().map(String::as_str).collect() } #[test] fn cjk_fallbacks_follow_the_interface_language() { // A Chinese interface asks for the Han probe first, so Han text is drawn by a Chinese face // even when the operating system's locale is not Chinese. assert_eq!(cjk_locale("zh-hans"), "zh-hans"); assert_eq!(cjk_locale("zh-hant"), "zh-hant"); assert_eq!(cjk_locale("ja"), "ja"); assert_eq!(cjk_locale("en"), ""); assert_eq!(cjk_locale("system"), ""); let mut chinese = FontDefinitions::default(); let installed = install_script_fallbacks(&mut chinese, "zh-hans"); let cjk = |names: &[String]| names.iter().filter(|n| matches!(n.as_str(), "chinese-system" | "japanese-system")).cloned().collect::>(); if cjk(&installed).is_empty() { eprintln!("no CJK font is installed: only the language plumbing can be checked here"); } else { assert_eq!(installed.first().map(String::as_str), Some("chinese-system"), "the Han probe comes first"); assert!(chinese.font_data.contains_key("chinese-system")); } let expected: Vec<&str> = installed.iter().map(String::as_str).collect(); for (family, stack) in &chinese.families { assert_eq!(tail(stack, installed.len()), expected, "{family:?}"); } // A Japanese interface keeps the kana probe first, as it was before the language existed. let mut japanese = FontDefinitions::default(); let installed = install_script_fallbacks(&mut japanese, "ja"); if let Some(first) = cjk(&installed).first() { assert_eq!(installed.first(), Some(first)); assert_eq!(first, "japanese-system", "the kana probe comes first"); } // And an English interface leaves the operating system's locale in charge. let mut english = FontDefinitions::default(); install_script_fallbacks(&mut english, "en"); } #[test] fn every_family_reaches_the_symbol_fallback() { let ctx = egui::Context::default(); install(&ctx, &Tokens::for_kind(Default::default()), "en"); ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); for family in [FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] { let id = FontId::new(12.0, family); // Not has_glyph: it compares face keys, so Inter's `•` reads as missing when no face in // the stack has egui's `◻` and Inter's `?` becomes the replacement, as happens once // another test has loaded a system face without `◻` (the fallback tries loaded faces first). let uv = |s: String| ctx.fonts_mut(|f| f.layout_no_wrap(s, id.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect); let missing = uv("\u{10ffff}".into()); for c in "▸•›·▶◀●○⚠…×".chars() { let drawn = uv(c.to_string()); assert_ne!((drawn.min, drawn.max), (missing.min, missing.max), "{c} has no glyph in {id:?}"); } } } #[test] fn hangul_gets_a_face_when_one_is_installed() { use effectcraft_text::fonts; let mut fonts_def = FontDefinitions::default(); install_script_fallbacks(&mut fonts_def, "en"); let base = fonts::resolve("Inter", "Regular").face; let id = fonts::fallback_for('한', base); let face = fonts::face(id); if !face.has_char('한') { eprintln!("no Hangul font is installed: nothing to check here"); return; } let Some(font) = face.font() else { return }; let bytes = font.data().as_bytes(); let covered = fonts_def.font_data.values().any(|d| d.font.as_ref() == bytes && d.index == face.info.index); assert!(covered, "a face covering Hangul must be registered with egui"); } #[test] fn a_fallback_face_is_registered_at_most_once() { let mut fonts = FontDefinitions::default(); let installed = install_script_fallbacks(&mut fonts, "zh-hant"); let mut unique = installed.clone(); unique.sort(); unique.dedup(); assert_eq!(unique.len(), installed.len(), "no name may be registered twice: {installed:?}"); let faces: Vec<_> = installed .iter() .map(|name| { let data = fonts.font_data.get(name).unwrap_or_else(|| panic!("{name} is missing its font data")); (data.font.as_ref(), data.index) }) .collect(); for (i, a) in faces.iter().enumerate() { assert!(!faces[i + 1..].contains(a), "{} shares its face with a later fallback: {installed:?}", installed[i]); } // Leave the operating system's locale behind for whatever runs next. install_script_fallbacks(&mut fonts, "system"); } }