mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-03 06:52:21 -06:00
Each half of a keyframe glyph had a 1 px near-black border, so keys that overlap read as a tangle of borders. Keys are now flat, as in After Effects: each half is filled and edged in the key's own colour (the hairline closes the seam between the halves), so overlapping keys read as one shape. The Light theme's keyframe grey is darker so unselected keys still stand out on its light time graph without the border. Test: keyframes_are_drawn_without_a_dark_outline (icons). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
400 lines
18 KiB
Rust
400 lines
18 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.
|
|
pub fn install(ctx: &egui::Context, t: &Tokens) {
|
|
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()]);
|
|
// Reuse the text engine's script-aware system fallback (#84), without embedding a CJK font.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
use effectcraft_text::fonts;
|
|
let base = fonts::resolve("Inter", "Regular").face;
|
|
let face = fonts::face(fonts::fallback_for('あ', base));
|
|
if face.has_char('あ')
|
|
&& let Some(font) = face.font()
|
|
{
|
|
// 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;
|
|
fonts.font_data.insert("japanese-system".into(), Arc::new(data));
|
|
for family in fonts.families.values_mut() {
|
|
family.push("japanese-system".into());
|
|
}
|
|
}
|
|
}
|
|
ctx.set_fonts(fonts);
|
|
apply_visuals(ctx, t);
|
|
}
|
|
|
|
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));
|
|
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:?}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|