Files
effectcraft/crates/ui-egui/src/theme.rs
T

847 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<ThemeKind> {
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
}
}
/// The default label colours, one RGB colour per [`effectcraft_color::Label`] in
/// `Label::ALL` order.
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");
/// Chrome symbols (`✔` U+2714, `▣` U+25A3, …) that Inter, Inter Medium/SemiBold and
/// JetBrains Mono do not cover. 0.37's default font set no longer includes the old emoji
/// fonts, and named families (`medium`, `semibold`) never received egui's default fallbacks.
static NOTO_SYMBOLS2: &[u8] = include_bytes!("../../../assets/fonts/NotoSansSymbols2-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.font_data.insert("noto-symbols2".into(), Arc::new(FontData::from_static(NOTO_SYMBOLS2)));
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.
// Noto Sans Symbols 2 follows it for the check/box glyphs (`✔`, `▣`) that JetBrains Mono
// lacks; without it, named families such as `medium` fall straight through to tofu.
for (family, stack) in &mut fonts.families {
if *family != FontFamily::Monospace {
stack.push("jbmono".into());
}
stack.push("noto-symbols2".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"),
('ა', "georgian-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<String> {
#[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();
// A font covering one Han probe may be a sparse subset. Register additional faces
// needed by the actual interface catalog instead of assuming whole-script coverage.
let extra = if is_chinese(language) { crate::i18n::catalog_characters(language) } else { Vec::new() };
let extra: Vec<_> = extra.into_iter().filter(|ch| *ch >= '\u{2e80}').map(|ch| (ch, "catalog-system")).collect();
// 256-character blocks where a catalog character found no face: with nothing installed
// covering them, each lookup would read every installed font file again.
let mut uncovered_blocks = std::collections::HashSet::new();
for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)).chain(&extra) {
let catalog = name == "catalog-system";
if catalog && (uncovered_blocks.contains(&(probe as u32 >> 8)) || seen.iter().any(|&id| fonts::face(id).has_char(probe))) {
continue;
}
// The interface asks for the platform's UI face first; the order a composed frame
// renders with is `fonts::fallback_for`'s and is not touched here. Another context may
// install a different language concurrently, so the shared fallback is asked with this
// context's own locale rather than the global request.
let id = interface_fallback_candidates(name, language)
.iter()
.find_map(|family| {
let r = fonts::resolve(family, "Regular");
(!r.missing && fonts::face(r.face).has_char(probe)).then_some(r.face)
})
.unwrap_or_else(|| fonts::fallback_for_locale(probe, base, cjk_locale(language)));
let face = fonts::face(id);
if !face.has_char(probe) {
if catalog {
uncovered_blocks.insert(probe as u32 >> 8);
}
continue; // nothing installed covers this script
}
if seen.contains(&id) {
continue;
}
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;
let name = if name == "catalog-system" { format!("{name}-{:x}", probe as u32) } else { name.to_string() };
family_fonts.font_data.insert(name.clone(), Arc::new(data));
for family in family_fonts.families.values_mut() {
family.push(name.to_string());
}
names.push(name.to_string());
}
names
}
}
/// The faces the interface asks for before it settles on the one a composed frame renders with.
///
/// Windows ships a UI variant of its Japanese and Chinese families - `Meiryo`/`Meiryo UI`, `Yu
/// Gothic`/`Yu Gothic UI`, `MS Gothic`/`MS UI Gothic`, `Microsoft YaHei`/`Microsoft YaHei UI`,
/// `Microsoft JhengHei`/`Microsoft JhengHei UI` - and its own link table for `Segoe UI`
/// (`FontLink\SystemLink`) picks the UI one for what it draws on screen. `PMingLiU` is the one face
/// here that is not a sibling: it is `MingLiU`'s proportional relative, and proportional spacing is
/// what makes the chrome read better in it.
///
/// Only the interface takes this order. `fonts::fallback_for` keeps the order a document renders
/// with, so an existing project's output does not move when the chrome changes.
#[cfg(not(target_arch = "wasm32"))]
fn interface_fallback_candidates(name: &str, language: &str) -> &'static [&'static str] {
match (name, cjk_locale(language)) {
// Kana, and the Han ideographs of a Japanese interface: the language asks for the Japanese
// face, so its UI sibling is the one the platform would draw chrome with.
("japanese-system", _) | ("chinese-system", "ja") => &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"],
("chinese-system", "zh-hant") => &["Microsoft JhengHei UI", "PMingLiU"],
("chinese-system", "zh-hans") => &["Microsoft YaHei UI"],
// The other scripts have no UI variant, and their shared order already picks the face made
// for the script - the one that draws it correctly at all.
_ => &[],
}
}
/// 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 hanzi used by its catalog. Some Japanese faces also cover 页 while lacking 选 or 删, so
/// coverage of this probe alone is not enough: the fallback must use the interface's own language
/// policy, even while another context is installing Japanese fonts (#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 super::{SCRIPT_PROBES, interface_fallback_candidates};
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);
}
}
#[test]
fn the_interface_asks_for_the_platforms_ui_faces() {
// The face a window draws its own chrome with, which is not the one a composed frame
// renders with: the two orders are deliberately separate.
let ja = &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"][..];
assert_eq!(interface_fallback_candidates("japanese-system", "ja"), ja);
assert_eq!(interface_fallback_candidates("chinese-system", "ja"), ja);
assert_eq!(interface_fallback_candidates("chinese-system", "zh-hans"), &["Microsoft YaHei UI"][..]);
assert_eq!(interface_fallback_candidates("chinese-system", "zh-hant"), &["Microsoft JhengHei UI", "PMingLiU"][..]);
// Every other script the install probes has no UI variant, so it keeps the shared order.
for &(_, name) in SCRIPT_PROBES.iter() {
assert!(interface_fallback_candidates(name, "en").is_empty(), "{name} asked for a UI face");
}
}
/// The Georgian interface draws its Mkhedruli text with an installed Georgian face (Sylfaen,
/// SF Georgian, Noto Sans Georgian…): Inter has no Georgian, so the labels showed as boxes.
#[test]
fn installed_georgian_fallback_draws_the_georgian_interface() {
if !installed('ა') {
eprintln!("no Georgian system font installed; skipping glyph coverage");
return;
}
assert_drawn("ka", "კომპოზიციაფენასაკვანძოკადრიეფექტიპარამეტრები");
}
/// #1141: switching the interface to Simplified Chinese drew most labels as boxes. A Japanese face
/// can cover the Han probe while lacking other catalog characters. The context must keep its
/// own language policy even when another context installs Japanese fallbacks concurrently.
#[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;
// Representative 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::<Vec<_>>();
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());
// Every chrome symbol the panels, menus, shortcut labels and buttons draw.
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.bytes() == 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.bytes(), 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");
}
}