Files
effectcraft/crates/ui-egui/src/theme.rs
T
bflatasticandClaude Opus 5.5 6d2ef5a830 Menus: a long dropdown scrolls instead of running off the window (#269)
The menu bar's own menus (Edit has 30 entries) had no scroll area, only their submenus did, so
on a short screen Edit ran past the bottom of the window. The font menu was capped at 420
points and never moved up, the Preview panel's and the viewer's popup lists neither scrolled nor
moved, and the Timeline's Blending Mode and Expression Language submenus didn't scroll. egui's
scroll bars also only appeared while the pointer was over the list, so a list could only be
scrolled with a wheel or touchpad.

- widgets::popup_list: the one popup list behind popup_menu, the font menu, the Preview panel's
  dropdowns and the viewer's popups: as tall as the window allows, scrolling beyond, moved up to
  fit the window.
- widgets::menu_scroll: the same for menus and submenus (the menu bar's, Blending Mode, the
  Expression Language menu, the viewer's Select menu).
- Scroll bars always show (solid scroll style), so they can be dragged.
- A first window that doesn't fit the screen is maximized: the 1680 x 1020 default on a smaller
  screen is only shrunk to the monitor's size, so with its title bar it ran under the taskbar and
  cut menus off at the bottom of the screen even when they fitted the window.

Test: ui_scrolling a_menu_taller_than_the_window_scrolls (Edit in a 520-point window scrolls
its last entry into view and it takes the click); overflows_screen unit test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:40:05 +07:00

398 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),
..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:?}");
}
}
});
}
}