636 lines
30 KiB
Rust
636 lines
30 KiB
Rust
//! Design tokens. Every widget reads colours/sizes from [`Tokens`] so themes apply everywhere.
|
|
//!
|
|
//! The default theme follows the look of Premiere Pro's current dark UI (values measured from
|
|
//! black-box screenshots, see `plan/premiere/02-ui-ux.md`); 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 {
|
|
/// Premiere-style darkest (default).
|
|
#[default]
|
|
Dark,
|
|
/// Slightly lighter grey panels (Premiere's brightness slider mid position).
|
|
Medium,
|
|
Light,
|
|
}
|
|
|
|
impl ThemeKind {
|
|
/// Settings ▸ Appearance ▸ Color Theme value (`darkest`, `dark`, `light`).
|
|
pub fn pref_name(self) -> &'static str {
|
|
match self {
|
|
ThemeKind::Dark => "darkest",
|
|
ThemeKind::Medium => "dark",
|
|
ThemeKind::Light => "light",
|
|
}
|
|
}
|
|
pub fn from_pref(s: &str) -> ThemeKind {
|
|
match s {
|
|
"dark" => ThemeKind::Medium,
|
|
"light" => ThemeKind::Light,
|
|
_ => ThemeKind::Dark,
|
|
}
|
|
}
|
|
pub fn from_name(s: &str) -> Option<ThemeKind> {
|
|
match s.to_ascii_lowercase().as_str() {
|
|
"dark" | "darkest" => Some(ThemeKind::Dark),
|
|
"medium" | "grey" | "gray" => Some(ThemeKind::Medium),
|
|
"light" => Some(ThemeKind::Light),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Tokens {
|
|
pub kind: ThemeKind,
|
|
/// Space between panels / app background.
|
|
pub app_bg: Color32,
|
|
/// Header bar (top).
|
|
pub header_bg: Color32,
|
|
/// Panel body.
|
|
pub panel_bg: Color32,
|
|
/// Panel tab strip.
|
|
pub tab_bg: Color32,
|
|
pub tab_text: Color32,
|
|
pub tab_text_active: Color32,
|
|
/// Blue focus rectangle around the active panel.
|
|
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_alt: Color32,
|
|
pub row_selected: Color32,
|
|
/// Scrubby (hot-text) numeric value colour.
|
|
pub hot_text: Color32,
|
|
/// Selected pills and compact toggle backgrounds.
|
|
pub pill_active_bg: Color32,
|
|
/// The selected clip bar in Effect Controls.
|
|
pub clip_bar_bg: Color32,
|
|
/// Raised audio control surfaces, such as mixer channel strips.
|
|
pub control_surface: Color32,
|
|
/// Recessed audio control tracks and panner wells.
|
|
pub control_well: Color32,
|
|
pub control_border: Color32,
|
|
pub control_handle_bg: Color32,
|
|
pub control_handle: Color32,
|
|
pub control_handle_dim: Color32,
|
|
/// Data-plot surfaces (keyframe, EQ and dynamics graphs), distinct from media canvases.
|
|
pub plot_bg: Color32,
|
|
pub plot_grid: Color32,
|
|
pub plot_axis: Color32,
|
|
pub plot_handle: Color32,
|
|
pub plot_handle_dim: Color32,
|
|
/// Meter wells and the separate loudness readout surface.
|
|
pub meter_bg: Color32,
|
|
pub meter_readout_bg: Color32,
|
|
pub meter_clip_off: Color32,
|
|
pub panner_bg: Color32,
|
|
pub panner_border: Color32,
|
|
pub panner_speaker: Color32,
|
|
pub fader_knob: Color32,
|
|
pub eq_track: Color32,
|
|
pub eq_tick: Color32,
|
|
pub eq_knob: Color32,
|
|
pub eq_knob_active: Color32,
|
|
pub eq_node: Color32,
|
|
pub eq_node_off: Color32,
|
|
pub crossover_handle: Color32,
|
|
pub keyframe_plot_bg: Color32,
|
|
pub keyframe_plot_grid: Color32,
|
|
pub keyframe_plot_axis: Color32,
|
|
pub keyframe_handle: Color32,
|
|
pub switch_fill: Color32,
|
|
pub switch_knob: Color32,
|
|
pub switch_border: Color32,
|
|
pub slider_disabled_track: Color32,
|
|
pub slider_disabled_knob: Color32,
|
|
pub button_hover: Color32,
|
|
pub button_border: Color32,
|
|
pub caption_track_bg: Color32,
|
|
pub scroll_thumb: Color32,
|
|
pub scroll_thumb_hover: Color32,
|
|
pub vertical_scroll_thumb: Color32,
|
|
pub disabled_clip_bg: Color32,
|
|
// timeline
|
|
pub tl_bg: Color32,
|
|
pub tl_track_bg: Color32,
|
|
pub tl_track_bg_alt: Color32,
|
|
pub tl_header_bg: Color32,
|
|
pub tl_ruler_bg: Color32,
|
|
pub tl_ruler_tick: Color32,
|
|
pub tl_ruler_text: Color32,
|
|
pub playhead: Color32,
|
|
pub in_out_shade: Color32,
|
|
pub clip_selected_border: Color32,
|
|
pub render_red: Color32,
|
|
pub render_yellow: Color32,
|
|
pub render_green: Color32,
|
|
pub monitor_bg: Color32,
|
|
pub timecode: Color32,
|
|
pub danger: Color32,
|
|
pub radius: f32,
|
|
pub radius_sm: f32,
|
|
pub gap: f32,
|
|
pub tab_h: f32,
|
|
}
|
|
|
|
impl Tokens {
|
|
pub fn for_kind(kind: ThemeKind) -> Self {
|
|
// Measured from Premiere 26 "Darkest" (plan/premiere/02-ui-ux.md §1).
|
|
let dark = Tokens {
|
|
kind,
|
|
app_bg: Color32::from_rgb(0, 0, 0),
|
|
header_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
|
panel_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
|
tab_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
|
tab_text: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
|
tab_text_active: Color32::from_rgb(0xd1, 0xd1, 0xd1),
|
|
focus: Color32::from_rgb(0x57, 0x94, 0xec),
|
|
accent: Color32::from_rgb(0x2f, 0x6b, 0xdf),
|
|
accent_hover: Color32::from_rgb(0x3f, 0x7c, 0xe8),
|
|
text: Color32::from_rgb(0xd1, 0xd1, 0xd1),
|
|
text_dim: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
|
text_faint: Color32::from_rgb(0x6e, 0x6e, 0x6e),
|
|
icon: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
|
icon_active: Color32::from_rgb(0xd1, 0xd1, 0xd1),
|
|
hover: Color32::from_rgb(0x2c, 0x2c, 0x2c),
|
|
pressed: Color32::from_rgb(0x4b, 0x4b, 0x4b),
|
|
field_bg: Color32::from_rgb(0x0e, 0x0e, 0x0e),
|
|
field_border: Color32::from_rgb(0x30, 0x30, 0x30),
|
|
separator: Color32::from_rgb(0x30, 0x30, 0x30),
|
|
row_alt: Color32::from_rgb(0x21, 0x21, 0x21),
|
|
row_selected: Color32::from_rgb(0x33, 0x33, 0x33),
|
|
hot_text: Color32::from_rgb(0x40, 0x96, 0xf3),
|
|
pill_active_bg: Color32::from_rgb(0x3a, 0x3a, 0x3a),
|
|
clip_bar_bg: Color32::from_rgb(58, 58, 70),
|
|
control_surface: Color32::from_rgb(0x24, 0x24, 0x24),
|
|
control_well: Color32::from_rgb(0x10, 0x10, 0x10),
|
|
control_border: Color32::from_rgb(0x50, 0x50, 0x50),
|
|
control_handle_bg: Color32::from_rgb(0x2a, 0x2a, 0x2a),
|
|
control_handle: Color32::from_rgb(0xe0, 0xe0, 0xe0),
|
|
control_handle_dim: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
|
plot_bg: Color32::from_rgb(0x18, 0x18, 0x18),
|
|
plot_grid: Color32::from_rgb(0x2c, 0x2c, 0x2c),
|
|
plot_axis: Color32::from_rgb(0x44, 0x44, 0x44),
|
|
plot_handle: Color32::from_rgb(0xe0, 0xe0, 0xe0),
|
|
plot_handle_dim: Color32::from_rgb(0x90, 0x90, 0x90),
|
|
meter_bg: Color32::BLACK,
|
|
meter_readout_bg: Color32::from_rgb(0x14, 0x14, 0x14),
|
|
meter_clip_off: Color32::from_rgb(0x30, 0x30, 0x30),
|
|
panner_bg: Color32::from_gray(22),
|
|
panner_border: Color32::from_gray(96),
|
|
panner_speaker: Color32::from_gray(144),
|
|
fader_knob: Color32::from_gray(90),
|
|
eq_track: Color32::from_gray(48),
|
|
eq_tick: Color32::from_gray(96),
|
|
eq_knob: Color32::from_gray(192),
|
|
eq_knob_active: Color32::from_gray(240),
|
|
eq_node: Color32::from_gray(232),
|
|
eq_node_off: Color32::from_gray(112),
|
|
crossover_handle: Color32::from_gray(255),
|
|
keyframe_plot_bg: Color32::from_gray(25),
|
|
keyframe_plot_grid: Color32::from_gray(42),
|
|
keyframe_plot_axis: Color32::from_gray(51),
|
|
keyframe_handle: Color32::from_gray(208),
|
|
switch_fill: Color32::from_gray(212),
|
|
switch_knob: Color32::from_gray(29),
|
|
switch_border: Color32::from_gray(138),
|
|
slider_disabled_track: Color32::from_gray(74),
|
|
slider_disabled_knob: Color32::from_gray(106),
|
|
button_hover: Color32::from_gray(42),
|
|
button_border: Color32::from_gray(75),
|
|
caption_track_bg: Color32::from_rgb(0x23, 0x20, 0x2a),
|
|
scroll_thumb: Color32::from_gray(0x4b),
|
|
scroll_thumb_hover: Color32::from_gray(0x6a),
|
|
vertical_scroll_thumb: Color32::from_gray(80),
|
|
disabled_clip_bg: Color32::from_gray(0x2a),
|
|
tl_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
|
tl_track_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
|
tl_track_bg_alt: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
|
tl_header_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
|
tl_ruler_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
|
tl_ruler_tick: Color32::from_rgb(0x8d, 0x8d, 0x8d),
|
|
tl_ruler_text: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
|
playhead: Color32::from_rgb(0x58, 0x95, 0xec),
|
|
in_out_shade: Color32::from_rgb(0x3f, 0x3f, 0x3f),
|
|
clip_selected_border: Color32::from_rgb(0xeb, 0xeb, 0xeb),
|
|
render_red: Color32::from_rgb(0xe3, 0x48, 0x50),
|
|
render_yellow: Color32::from_rgb(0xf0, 0xf0, 0x4f),
|
|
render_green: Color32::from_rgb(0x2d, 0x9d, 0x78),
|
|
monitor_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
|
timecode: Color32::from_rgb(0x40, 0x96, 0xf3),
|
|
danger: Color32::from_rgb(0xdc, 0x51, 0x3d),
|
|
radius: 0.0,
|
|
radius_sm: 4.0,
|
|
gap: 4.0,
|
|
tab_h: 32.0,
|
|
};
|
|
match kind {
|
|
ThemeKind::Dark => dark,
|
|
ThemeKind::Medium => Tokens {
|
|
app_bg: Color32::from_rgb(20, 20, 20),
|
|
header_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
|
panel_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
|
tab_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
|
field_bg: Color32::from_rgb(0x22, 0x22, 0x22),
|
|
tl_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
|
tl_track_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
|
tl_track_bg_alt: Color32::from_rgb(0x32, 0x32, 0x32),
|
|
tl_header_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
|
tl_ruler_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
|
monitor_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
|
row_alt: Color32::from_rgb(50, 50, 50),
|
|
hover: Color32::from_rgb(64, 64, 64),
|
|
..dark
|
|
},
|
|
ThemeKind::Light => Tokens {
|
|
app_bg: Color32::from_rgb(180, 180, 180),
|
|
header_bg: Color32::from_rgb(214, 214, 214),
|
|
panel_bg: Color32::from_rgb(232, 232, 232),
|
|
tab_bg: Color32::from_rgb(232, 232, 232),
|
|
tab_text: Color32::from_rgb(90, 90, 90),
|
|
tab_text_active: Color32::from_rgb(20, 20, 20),
|
|
text: Color32::from_rgb(34, 34, 34),
|
|
text_dim: Color32::from_rgb(90, 90, 90),
|
|
text_faint: Color32::from_rgb(140, 140, 140),
|
|
icon: Color32::from_rgb(60, 60, 60),
|
|
hover: Color32::from_rgb(210, 210, 210),
|
|
pressed: Color32::from_rgb(196, 196, 196),
|
|
field_bg: Color32::from_rgb(250, 250, 250),
|
|
field_border: Color32::from_rgb(170, 170, 170),
|
|
separator: Color32::from_rgb(200, 200, 200),
|
|
row_alt: Color32::from_rgb(224, 224, 224),
|
|
row_selected: Color32::from_rgb(170, 200, 240),
|
|
pill_active_bg: Color32::from_rgb(196, 196, 196),
|
|
clip_bar_bg: Color32::from_rgb(190, 203, 224),
|
|
control_surface: Color32::from_rgb(218, 218, 218),
|
|
control_well: Color32::from_rgb(196, 196, 196),
|
|
control_border: Color32::from_rgb(150, 150, 150),
|
|
control_handle_bg: Color32::from_rgb(205, 205, 205),
|
|
control_handle: Color32::from_rgb(50, 50, 50),
|
|
control_handle_dim: Color32::from_rgb(85, 85, 85),
|
|
plot_bg: Color32::from_rgb(245, 245, 245),
|
|
plot_grid: Color32::from_rgb(210, 210, 210),
|
|
plot_axis: Color32::from_rgb(170, 170, 170),
|
|
plot_handle: Color32::from_rgb(50, 50, 50),
|
|
plot_handle_dim: Color32::from_rgb(100, 100, 100),
|
|
meter_bg: Color32::from_rgb(205, 205, 205),
|
|
meter_readout_bg: Color32::from_rgb(218, 218, 218),
|
|
meter_clip_off: Color32::from_rgb(155, 155, 155),
|
|
panner_bg: Color32::from_gray(196),
|
|
panner_border: Color32::from_gray(150),
|
|
panner_speaker: Color32::from_gray(100),
|
|
fader_knob: Color32::from_gray(140),
|
|
eq_track: Color32::from_gray(196),
|
|
eq_tick: Color32::from_gray(150),
|
|
eq_knob: Color32::from_gray(85),
|
|
eq_knob_active: Color32::from_gray(34),
|
|
eq_node: Color32::from_gray(50),
|
|
eq_node_off: Color32::from_gray(140),
|
|
crossover_handle: Color32::from_gray(50),
|
|
keyframe_plot_bg: Color32::from_gray(245),
|
|
keyframe_plot_grid: Color32::from_gray(210),
|
|
keyframe_plot_axis: Color32::from_gray(170),
|
|
keyframe_handle: Color32::from_gray(50),
|
|
switch_fill: Color32::from_gray(60),
|
|
switch_knob: Color32::from_gray(232),
|
|
switch_border: Color32::from_gray(110),
|
|
slider_disabled_track: Color32::from_gray(190),
|
|
slider_disabled_knob: Color32::from_gray(140),
|
|
button_hover: Color32::from_gray(210),
|
|
button_border: Color32::from_gray(170),
|
|
icon_active: Color32::from_rgb(34, 34, 34),
|
|
caption_track_bg: Color32::from_rgb(222, 215, 233),
|
|
scroll_thumb: Color32::from_gray(150),
|
|
scroll_thumb_hover: Color32::from_gray(130),
|
|
vertical_scroll_thumb: Color32::from_gray(150),
|
|
disabled_clip_bg: Color32::from_gray(190),
|
|
in_out_shade: Color32::from_gray(185),
|
|
tl_bg: Color32::from_rgb(210, 210, 210),
|
|
tl_track_bg: Color32::from_rgb(222, 222, 222),
|
|
tl_track_bg_alt: Color32::from_rgb(228, 228, 228),
|
|
tl_header_bg: Color32::from_rgb(214, 214, 214),
|
|
tl_ruler_bg: Color32::from_rgb(226, 226, 226),
|
|
tl_ruler_text: Color32::from_rgb(80, 80, 80),
|
|
..dark
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Settings ▸ Appearance on top of a theme: the highlight colour (selections, focus, primary
|
|
/// buttons) and accessible colour contrast (brighter secondary text and borders).
|
|
pub fn with_appearance(mut self, highlight: Option<[u8; 3]>, accessible_contrast: bool) -> Self {
|
|
if let Some([r, g, b]) = highlight {
|
|
let c = Color32::from_rgb(r, g, b);
|
|
let lift = |v: u8| v.saturating_add(16);
|
|
self.accent = c;
|
|
self.accent_hover = Color32::from_rgb(lift(r), lift(g), lift(b));
|
|
}
|
|
if accessible_contrast {
|
|
if self.kind == ThemeKind::Light {
|
|
self.text_dim = Color32::from_rgb(40, 40, 40);
|
|
self.text_faint = Color32::from_rgb(80, 80, 80);
|
|
self.field_border = Color32::from_rgb(110, 110, 110);
|
|
} else {
|
|
self.text_dim = self.text;
|
|
self.text_faint = Color32::from_rgb(0xa8, 0xa8, 0xa8);
|
|
self.tab_text = self.tab_text_active;
|
|
self.field_border = Color32::from_rgb(0x6a, 0x6a, 0x6a);
|
|
self.separator = Color32::from_rgb(0x50, 0x50, 0x50);
|
|
}
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Mono font for timecode.
|
|
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()))
|
|
}
|
|
/// The big blue timecode over the timeline and under the monitors: regular weight, as in
|
|
/// Premiere (Premiere's reads 10 px tall and 76 px wide for 00:00:16:05 at 1x).
|
|
pub fn timecode() -> FontId {
|
|
FontId::new(16.0, FontFamily::Proportional)
|
|
}
|
|
}
|
|
|
|
/// Menu text: Premiere's menus are the system's, larger and brighter than panel text, one item
|
|
/// every 24 px.
|
|
pub const MENU_TEXT_SIZE: f32 = 13.0;
|
|
pub const MENU_TEXT: Color32 = Color32::from_rgb(0xde, 0xde, 0xde);
|
|
|
|
/// Style of the menu bar's menus and their submenus (on top of egui's own menu style).
|
|
pub fn menu_style(s: &mut egui::Style) {
|
|
egui::containers::menu::menu_style(s);
|
|
s.text_styles.insert(TextStyle::Button, FontId::new(MENU_TEXT_SIZE, FontFamily::Proportional));
|
|
s.text_styles.insert(TextStyle::Body, FontId::new(MENU_TEXT_SIZE, FontFamily::Proportional));
|
|
s.spacing.item_spacing.y = 0.0;
|
|
if s.visuals.dark_mode {
|
|
s.visuals.override_text_color = Some(MENU_TEXT);
|
|
}
|
|
}
|
|
|
|
/// Every font family the theme defines (fallback fonts such as the Japanese fonts are added to each
|
|
/// of them).
|
|
pub fn font_families() -> Vec<FontFamily> {
|
|
vec![FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("semibold".into()), FontFamily::Name("medium".into())]
|
|
}
|
|
|
|
/// Install the Latin UI fonts, Chinese fallback (craft-fonts or an installed face), Japanese
|
|
/// craft-fonts and egui visuals. Document names keep their glyphs in every interface language.
|
|
pub fn install(ctx: &egui::Context, t: &Tokens) {
|
|
let mut fonts = FontDefinitions::default();
|
|
fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));
|
|
fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_MEDIUM)));
|
|
fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_SEMIBOLD)));
|
|
fonts.font_data.insert("jbmono".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::JETBRAINS_MONO_REGULAR)));
|
|
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()]);
|
|
// Japanese before Chinese: the two share code points, and Japanese text must keep Japanese
|
|
// glyph forms; Chinese faces still cover the hanzi Japanese fonts lack
|
|
add_craft_fonts(&mut fonts);
|
|
crate::cjk::install(&mut fonts);
|
|
ctx.set_fonts(fonts);
|
|
apply_visuals(ctx, t);
|
|
}
|
|
|
|
/// Name of the egui font for a craft-fonts entry.
|
|
fn craft_font_name(f: &filmcraft_text::fonts::CraftFont) -> String {
|
|
format!("craft:{} {}", f.family, f.style)
|
|
}
|
|
|
|
/// Append the Japanese craft-fonts (empty unless built with `CRAFT_FONTS_DIR`) as the last fallbacks
|
|
/// of every font family, after the app's own fonts: BIZ UDPGothic first (bold before regular in
|
|
/// the "semibold" and "medium" families), then the other Japanese faces.
|
|
fn add_craft_fonts(fonts: &mut FontDefinitions) {
|
|
let jpan: Vec<_> = filmcraft_text::fonts::craft_japanese().collect();
|
|
for f in &jpan {
|
|
fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes)));
|
|
}
|
|
for (family, stack) in fonts.families.iter_mut() {
|
|
let heavy = matches!(family, FontFamily::Name(n) if matches!(n.as_ref(), "semibold" | "medium"));
|
|
let mut order = jpan.clone();
|
|
order.sort_by_key(|f| (!f.family.contains("Gothic"), (f.style == "Bold") != heavy));
|
|
stack.extend(order.iter().map(|f| craft_font_name(f)));
|
|
}
|
|
}
|
|
|
|
pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
|
|
let light = t.kind == ThemeKind::Light;
|
|
// FilmCraft decides between light and dark itself (Settings ▸ Appearance ▸ Appearance Mode), so
|
|
// egui must not switch to its other built-in style when the system appearance changes.
|
|
ctx.set_theme(if light { egui::Theme::Light } else { egui::Theme::Dark });
|
|
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(6);
|
|
v.menu_corner_radius = egui::CornerRadius::same(6);
|
|
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) };
|
|
v.window_shadow = v.popup_shadow;
|
|
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 = 24.0;
|
|
s.spacing.menu_margin = egui::Margin::same(4);
|
|
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.12;
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const JAPANESE: &str = "日本語の文字";
|
|
|
|
fn installed() -> egui::Context {
|
|
let ctx = egui::Context::default();
|
|
install(&ctx, &Tokens::for_kind(ThemeKind::default()));
|
|
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
|
out.textures_delta.clear();
|
|
ctx
|
|
}
|
|
|
|
#[test]
|
|
fn light_theme_has_light_chrome_and_plot_surfaces() {
|
|
let dark = Tokens::for_kind(ThemeKind::Dark);
|
|
let light = Tokens::for_kind(ThemeKind::Light);
|
|
let luminance = |c: Color32| u16::from(c.r()) + u16::from(c.g()) + u16::from(c.b());
|
|
|
|
for (name, dark_surface, light_surface) in [
|
|
("active pill", dark.pill_active_bg, light.pill_active_bg),
|
|
("clip bar", dark.clip_bar_bg, light.clip_bar_bg),
|
|
("control surface", dark.control_surface, light.control_surface),
|
|
("control well", dark.control_well, light.control_well),
|
|
("plot", dark.plot_bg, light.plot_bg),
|
|
("keyframe plot", dark.keyframe_plot_bg, light.keyframe_plot_bg),
|
|
("panner", dark.panner_bg, light.panner_bg),
|
|
("caption lane", dark.caption_track_bg, light.caption_track_bg),
|
|
("active toggle", dark.pressed, light.pressed),
|
|
("meter", dark.meter_bg, light.meter_bg),
|
|
("meter readout", dark.meter_readout_bg, light.meter_readout_bg),
|
|
] {
|
|
assert!(luminance(light_surface) > luminance(dark_surface), "{name} did not adapt to Light");
|
|
}
|
|
assert!(luminance(light.control_handle) < luminance(light.control_handle_bg));
|
|
assert!(luminance(light.plot_handle) < luminance(light.plot_bg));
|
|
assert!(luminance(light.icon_active) < luminance(light.pressed));
|
|
assert_eq!(Tokens::for_kind(ThemeKind::Medium).plot_bg, dark.plot_bg, "Medium keeps the established dark plot treatment");
|
|
}
|
|
|
|
#[test]
|
|
fn dark_and_medium_keep_existing_audio_and_effect_palettes() {
|
|
for kind in [ThemeKind::Dark, ThemeKind::Medium] {
|
|
let t = Tokens::for_kind(kind);
|
|
// Existing painted colors, recorded independently of the Light palette.
|
|
assert_eq!(t.pill_active_bg, Color32::from_gray(0x3a));
|
|
assert_eq!(t.clip_bar_bg, Color32::from_rgb(58, 58, 70));
|
|
assert_eq!(t.control_surface, Color32::from_gray(0x24));
|
|
assert_eq!(t.control_well, Color32::from_gray(0x10));
|
|
assert_eq!(t.control_handle, Color32::from_gray(0xe0));
|
|
assert_eq!(t.meter_bg, Color32::BLACK);
|
|
assert_eq!(t.plot_bg, Color32::from_gray(0x18));
|
|
assert_eq!(t.plot_grid, Color32::from_gray(0x2c));
|
|
assert_eq!(t.keyframe_plot_bg, Color32::from_gray(0x19));
|
|
assert_eq!(t.keyframe_plot_grid, Color32::from_gray(0x2a));
|
|
assert_eq!(t.keyframe_plot_axis, Color32::from_gray(0x33));
|
|
assert_eq!(t.keyframe_handle, Color32::from_gray(0xd0));
|
|
}
|
|
}
|
|
|
|
/// Chinese media and track names must render even when the interface stays in English.
|
|
/// Compare the rasterized glyphs with the missing-glyph box, including custom weight families.
|
|
#[test]
|
|
fn chinese_names_render_in_every_theme_and_font_family() {
|
|
const CHINESE: &str = "中文旁白音乐声轨测试简体汉语繁體漢語车";
|
|
filmcraft_text::fonts::scan_system();
|
|
let available = filmcraft_text::fonts::all_faces()
|
|
.iter()
|
|
.any(|f| matches!(f.info.origin, "system" | filmcraft_text::fonts::CRAFT_ORIGIN) && CHINESE.chars().all(|c| f.has_char(c)));
|
|
if !available {
|
|
eprintln!("SKIPPED: no craft-fonts or installed Chinese font");
|
|
return;
|
|
}
|
|
let ctx = egui::Context::default();
|
|
for kind in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] {
|
|
install(&ctx, &Tokens::for_kind(kind));
|
|
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
|
ctx.fonts_mut(|fonts| {
|
|
for family in font_families() {
|
|
let font = FontId::new(13.0, family);
|
|
let missing = fonts.layout_no_wrap("\u{fffd}".into(), font.clone(), Color32::WHITE);
|
|
let missing_uv = missing.rows[0].glyphs[0].uv_rect;
|
|
let names = fonts.layout_no_wrap(CHINESE.into(), font.clone(), Color32::WHITE);
|
|
assert_eq!(names.rows.iter().map(|r| r.glyphs.len()).sum::<usize>(), CHINESE.chars().count());
|
|
for glyph in names.rows.iter().flat_map(|r| &r.glyphs) {
|
|
assert!(!glyph.uv_rect.is_nothing(), "empty {} in {font:?}", glyph.chr);
|
|
assert_ne!(glyph.uv_rect, missing_uv, "missing {} in {kind:?} / {font:?}", glyph.chr);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// the layouts above put glyphs in the atlas: take that update so it isn't dropped unapplied
|
|
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
|
}
|
|
|
|
/// Built with craft-fonts: every font family ends with the Japanese faces (BIZ UDPGothic
|
|
/// first), and Japanese text gets real glyphs, not the replacement box.
|
|
#[test]
|
|
fn japanese_renders_with_craft_fonts() {
|
|
if filmcraft_text::fonts::craft_japanese().next().is_none() {
|
|
eprintln!("SKIPPED: built without craft-fonts (set CRAFT_FONTS_DIR to run)");
|
|
return;
|
|
}
|
|
let ctx = installed();
|
|
ctx.fonts_mut(|fonts| {
|
|
let defs = fonts.definitions().clone();
|
|
for (family, stack) in &defs.families {
|
|
let first = stack.iter().position(|n| n.starts_with("craft:")).unwrap_or(stack.len());
|
|
assert!(first > 0 && stack[first..].iter().all(|n| n.starts_with("craft:")), "{family:?}: {stack:?}");
|
|
let first_japanese = stack.iter().find(|n| n.starts_with("craft:BIZ") || n.starts_with("craft:Shippori"));
|
|
assert!(first_japanese.is_some_and(|n| n.starts_with("craft:BIZ UDPGothic")), "{family:?}: {stack:?}");
|
|
}
|
|
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
|
let font = FontId::new(13.0, family);
|
|
for ch in JAPANESE.chars() {
|
|
assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}");
|
|
}
|
|
}
|
|
});
|
|
// laid out and drawn: the replacement box (U+FFFD) would show as one glyph per character
|
|
// from the fallback face; a real face gives each character its own width
|
|
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(JAPANESE.into(), FontId::new(13.0, FontFamily::Proportional), Color32::WHITE));
|
|
assert_eq!(galley.rows.iter().map(|r| r.glyphs.len()).sum::<usize>(), JAPANESE.chars().count());
|
|
assert!(galley.size().x > 13.0 * 4.0, "{:?}", galley.size());
|
|
}
|
|
|
|
/// Latin text keeps its UI faces, with or without craft-fonts and system Chinese fonts.
|
|
#[test]
|
|
fn works_without_craft_fonts() {
|
|
let ctx = installed();
|
|
let n = filmcraft_text::fonts::craft_japanese().count() + crate::cjk::craft_fonts().count();
|
|
ctx.fonts_mut(|fonts| {
|
|
let defs = fonts.definitions().clone();
|
|
assert_eq!(defs.font_data.keys().filter(|k| k.starts_with("craft:")).count(), n);
|
|
assert_eq!(defs.families[&FontFamily::Proportional].first().map(String::as_str), Some("inter"));
|
|
assert_eq!(defs.families[&FontFamily::Monospace].first().map(String::as_str), Some("jbmono"));
|
|
assert!(fonts.has_glyph(&FontId::new(13.0, FontFamily::Proportional), 'A'));
|
|
if n == 0 {
|
|
assert!(defs.families.values().flatten().all(|name| !name.starts_with("craft:")));
|
|
}
|
|
});
|
|
}
|
|
}
|