//! 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 { 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, } 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(0x66, 0x66, 0x66), 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: 22.0, }; 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(0x8c, 0x8c, 0x8c), 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), ..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 } /// 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()]); 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 = Color32::from_rgb(0x2e, 0x2e, 0x2e); 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 = Color32::from_rgb(0x3a, 0x3a, 0x3a); 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); 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; }); }