Files
effectcraft/crates/ui-egui/src/theme.rs
T
FeiLiuEM 5344f304c3 text, ui: borrow the app at the test call sites, and drop a redundant lifetime
Two things are one level deeper than the first pass at this review:

- Nine of the call sites that pass the resolved language are inside test functions, where `app`
  is owned, so `crate::i18n::language(app)` needs `&app`. `cargo check --all-targets` (which
  compiles the test modules as well) is what reported them; a check without it does not, which is
  how they survived the first round.
- `clippy -D warnings` rejects the explicit `'static` on `const KANA` / `const HAN`: a const
  already has one.

On this branch `cargo fmt --all --check`, `cargo check -p effectcraft-ui-egui --all-targets`,
`cargo clippy -p effectcraft-ui-egui --all-targets -- -D warnings` and
`cargo test -p effectcraft-ui-egui` (40 targets, 113 lib tests) are clean.
2026-10-09 20:34:03 +08:00

515 lines
23 KiB
Rust

//! 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,
}
}
}
#[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,
/// 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),
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),
..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.
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> Tokens {
let kind = ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
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,
] {
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()]);
install_cjk_fallbacks(&mut fonts, language);
ctx.set_fonts(fonts);
apply_visuals(ctx, t);
}
/// Register the CJK system fallbacks for `language` in every font family, and return their names in
/// registration order (empty when nothing installed covers a probe, so a machine without CJK fonts
/// simply keeps the bundled ones).
///
/// 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. The web
/// build has no system fonts and installs nothing.
fn install_cjk_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");
const HAN: (char, &str) = ('文', "chinese-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 probes = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] };
let mut names = Vec::new();
let mut seen = Vec::new();
for (probe, name) in probes {
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,
_ => "",
}
}
/// 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")
}
pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
let mut v = if t.kind == ThemeKind::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;
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
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();
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 japanese_font_tests {
#[test]
fn installed_japanese_fallback_is_available_in_all_ui_families() {
use effectcraft_text::fonts;
let base = fonts::resolve("Inter", "Regular").face;
if !fonts::face(fonts::fallback_for('あ', base)).has_char('あ') {
eprintln!("no Japanese system font installed; skipping glyph coverage");
return;
}
let ctx = egui::Context::default();
super::install(&ctx, &super::Tokens::for_kind(super::ThemeKind::Dark), "ja");
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 Japanese 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 "日本語コンポジションレイヤーエフェクト設定".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:?}");
}
}
});
}
}
#[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_cjk_fallbacks(&mut chinese, "zh-hans");
if 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_cjk_fallbacks(&mut japanese, "ja");
if let Some(first) = installed.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_cjk_fallbacks(&mut english, "en");
}
#[test]
fn a_cjk_face_is_registered_at_most_once() {
let mut fonts = FontDefinitions::default();
let installed = install_cjk_fallbacks(&mut fonts, "zh-hant");
let mut unique = installed.clone();
unique.sort();
unique.dedup();
assert_eq!(unique, installed, "no face may be registered twice");
for name in &installed {
assert!(fonts.font_data.contains_key(name), "{name} is missing its font data");
}
// Leave the operating system's locale behind for whatever runs next.
install_cjk_fallbacks(&mut fonts, "system");
}
}