Files
effectcraft/crates/ui-egui/src/theme.rs
2026-10-13 04:30:02 -04:00

758 lines
36 KiB
Rust
Raw Permalink 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
}
}
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<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();
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::<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());
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");
}
}