diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index 6d742dc4e..b7f05f023 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -419,20 +419,7 @@ pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, opt #[cfg(test)] mod contrast_tests { use super::*; - use crate::theme::ThemeKind; - - /// WCAG contrast ratio of two opaque sRGB colours. - fn contrast(a: Color32, b: Color32) -> f32 { - let lum = |c: Color32| { - let f = |v: u8| { - let v = v as f32 / 255.0; - if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } - }; - 0.2126 * f(c.r()) + 0.7152 * f(c.g()) + 0.0722 * f(c.b()) - }; - let (x, y) = (lum(a), lum(b)); - (x.max(y) + 0.05) / (x.min(y) + 0.05) - } + use crate::theme::{ThemeKind, contrast_ratio as contrast}; /// #644: on every theme, effect names stay readable on Effect Controls' headers, and the /// expression field's text (every syntax colour, enabled and disabled expressions, matched diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index be18999d4..3b883d191 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -14,7 +14,7 @@ use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; use super::timeline::TMap; -use crate::theme::Tokens; +use crate::theme::{Tokens, contrast_ratio}; use crate::{EffectcraftApp, widgets}; type Actions = Vec<(String, serde_json::Value)>; @@ -29,6 +29,84 @@ const PROP_COLORS: [Color32; 6] = [ Color32::from_rgb(0x9c, 0xd8, 0x50), Color32::from_rgb(0xf0, 0x70, 0x90), ]; +/// The same hues, darker, for the Light theme's pale graph (the bright ones vanish on it). +const DIM_COLORS_LIGHT: [Color32; 4] = + [Color32::from_rgb(0xb0, 0x20, 0x20), Color32::from_rgb(0x1a, 0x7a, 0x1a), Color32::from_rgb(0x1c, 0x50, 0xc0), Color32::from_rgb(0x8a, 0x6a, 0x00)]; +const PROP_COLORS_LIGHT: [Color32; 6] = [ + Color32::from_rgb(0x8a, 0x6a, 0x00), + Color32::from_rgb(0x90, 0x30, 0xa8), + Color32::from_rgb(0x00, 0x78, 0x78), + Color32::from_rgb(0xb0, 0x50, 0x00), + Color32::from_rgb(0x4a, 0x80, 0x10), + Color32::from_rgb(0xc0, 0x30, 0x60), +]; + +/// The Graph Editor's colours for the active theme (#3202): its surfaces come from the theme's +/// tokens (the plot is the Timeline's time graph, the buttons sit on the panel), and the curve, +/// key and handle colours have a darker set for a pale plot (Light), so all of it stays readable. +/// Built once per frame in [`show`] and passed down. +#[derive(Clone, Copy, Debug)] +pub(crate) struct GraphPalette { + /// The plot's background. + pub bg: Color32, + /// The horizontal value grid lines. + pub grid: Color32, + /// The value labels on the grid and the "select animated properties" hint. + pub label: Color32, + /// An unlit bar button's fill and label. + pub button: Color32, + pub button_text: Color32, + /// A lit bar button's fill and label. + pub button_on: Color32, + pub button_on_text: Color32, + /// Bezier handles (line and end). + pub handle: Color32, + /// A key's fill, unselected and muted-unselected. + pub key: Color32, + pub key_muted: Color32, + /// The transform box's outline and its handles' fill. + pub box_line: Color32, + pub box_fill: Color32, + /// Curve colours: one per dimension, or one per property. + pub dims: [Color32; 4], + pub props: [Color32; 6], +} + +/// The lowest contrast of any of `colors` against `bg`. +fn min_contrast(colors: &[Color32], bg: Color32) -> f32 { + colors.iter().map(|c| contrast_ratio(*c, bg)).fold(f32::INFINITY, f32::min) +} + +impl GraphPalette { + pub(crate) fn new(t: &Tokens) -> Self { + let bg = t.tl_bg; + // The darker marks for a pale plot: chosen by the plot itself, not the theme's name, as + // the brightness slider moves the plot (Dark lightened, Light darkened). Whichever set + // stands out more from the plot wins. + let bright: Vec = DIM_COLORS.iter().chain(PROP_COLORS.iter()).copied().collect(); + let dark: Vec = DIM_COLORS_LIGHT.iter().chain(PROP_COLORS_LIGHT.iter()).copied().collect(); + let light = min_contrast(&dark, bg) > min_contrast(&bright, bg); + let button_on = t.panel_bg.lerp_to_gamma(t.accent, 0.55); + // White on a deep blue; on a pale one (Light) the theme's dark text. + let button_on_text = if contrast_ratio(Color32::WHITE, button_on) >= contrast_ratio(t.text, button_on) { Color32::WHITE } else { t.text }; + GraphPalette { + bg, + grid: bg.lerp_to_gamma(t.text, 0.06), + label: t.text_dim, + button: t.panel_bg.lerp_to_gamma(t.text, 0.06), + button_text: t.text, + button_on, + button_on_text, + handle: if light { Color32::from_rgb(0x80, 0x70, 0x10) } else { Color32::from_rgb(0xd8, 0xd8, 0x60) }, + key: if light { t.keyframe } else { Color32::from_gray(0xe0) }, + key_muted: if light { Color32::from_gray(0xa0) } else { Color32::from_gray(0x70) }, + box_line: if light { Color32::from_gray(0x40) } else { Color32::from_gray(0xd8) }, + box_fill: if light { Color32::from_gray(0xf2) } else { Color32::from_gray(0x20) }, + dims: if light { DIM_COLORS_LIGHT } else { DIM_COLORS }, + props: if light { PROP_COLORS_LIGHT } else { PROP_COLORS }, + } + } +} struct Handle { out: bool, @@ -53,12 +131,12 @@ struct KeyPt { } /// A graph key's fill and edge colours: muted keys stay visible and selectable, dimmed. -fn key_colors(t: &Tokens, edge: Color32, k: &KeyPt) -> (Color32, Color32) { +fn key_colors(t: &Tokens, pal: &GraphPalette, edge: Color32, k: &KeyPt) -> (Color32, Color32) { let fill = match (k.muted, k.sel) { (true, true) => t.keyframe_selected.gamma_multiply(0.5), - (true, false) => Color32::from_gray(0x70), + (true, false) => pal.key_muted, (false, true) => t.keyframe_selected, - (false, false) => Color32::from_gray(0xe0), + (false, false) => pal.key, }; (fill, if k.muted { edge.gamma_multiply(0.4) } else { edge }) } @@ -131,7 +209,7 @@ fn speed_at(pr: &Property, spatial: bool, d: usize, lt: Tick) -> f64 { } /// Build the curves for the visible time span. -fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, plot: Rect, speed: bool) -> Vec> { +fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, plot: Rect, speed: bool, pal: &GraphPalette) -> Vec> { let sel = &app.session.state.selected_keys; let n = ((plot.width() / 2.0) as usize).max(2); let mut out = vec![]; @@ -141,7 +219,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl let spatial = pr.spatial && matches!(pr.value, Value::Vec2(_) | Value::Vec3(_)); let dims = if speed && spatial { 1 } else { shown_dims(l, pr) }; for d in 0..dims { - let color = if dims > 1 { DIM_COLORS[d % 4] } else { PROP_COLORS[pi % PROP_COLORS.len()] }; + let color = if dims > 1 { pal.dims[d % pal.dims.len()] } else { pal.props[pi % pal.props.len()] }; let mut pts = Vec::with_capacity(n); for i in 0..n { let x = plot.min.x + plot.width() * i as f32 / (n - 1) as f32; @@ -193,6 +271,9 @@ struct ButtonBar { hidden: Vec<(String, String, bool, String)>, /// The id picked from the "»" menu last frame, run by its button this frame. chosen: Option, + /// The bar's rectangle and the theme's Graph Editor colours. + bar: Rect, + pal: GraphPalette, } /// The "»" menu's popup and the id it picked (read by the next frame's buttons). @@ -204,22 +285,22 @@ impl ButtonBar { /// A bar starting 8 points into `bar`, stopping short of its right end by the "»" button's /// width. It takes the id picked from the "»" menu in the previous frame, so the button whose /// id that was reports a click on the frame after the pick. - fn new(ctx: &egui::Context, bar: Rect) -> Self { + fn new(ctx: &egui::Context, bar: Rect, pal: GraphPalette) -> Self { let chosen = ctx.data_mut(|d| d.remove_temp::(egui::Id::unique(MORE_POPUP).with("chosen"))); - ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen } + ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen, bar, pal } } /// The "»" button and its menu, when buttons did not fit. - fn more(&mut self, app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect) { + fn more(&mut self, app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter) { if self.hidden.is_empty() { return; } - let t = app.tokens; + let (t, pal, bar) = (app.tokens, self.pal, self.bar); let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0)); let pid = egui::Id::unique(MORE_POPUP); let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More")); - p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) }); - p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text); + p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { pal.button }); + p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), pal.button_text); app.auto.add("timeline.graph.more", r, "More"); if resp.clicked() { let open = widgets::popup_is_open(ui, pid); @@ -273,18 +354,19 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, b } let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(shown_tip); + let pal = &bar.pal; p.rect_filled( r, 3.0, if on { - t.accent.gamma_multiply(0.55) + pal.button_on } else if resp.hovered() { t.hover } else { - Color32::from_rgb(0x2c, 0x2c, 0x2c) + pal.button }, ); - p.text(r.center(), Align2::CENTER_CENTER, shown, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + p.text(r.center(), Align2::CENTER_CENTER, shown, Tokens::ui(11.0), if on { pal.button_on_text } else { pal.button_text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); bar.x += w + 4.0; resp.clicked() @@ -299,9 +381,9 @@ pub(crate) struct GraphView<'a> { } /// Show Reference Graph: the other graph type, faint, scaled to the plot. -fn reference_graph(app: &EffectcraftApp, view: GraphView, plot: Rect, speed: bool, pc: &egui::Painter) { +fn reference_graph(app: &EffectcraftApp, view: GraphView, plot: Rect, speed: bool, pc: &egui::Painter, pal: &GraphPalette) { let GraphView { comp, ectx, tm } = view; - let other = curves(app, comp, ectx, tm, plot, !speed); + let other = curves(app, comp, ectx, tm, plot, !speed, pal); let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY); for c in &other { for (_, v) in &c.pts { @@ -407,16 +489,17 @@ fn fit_on_open(app: &mut EffectcraftApp, ctx: &egui::Context, cs: &[Curve], tm: pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, view: GraphView, area: Rect, actions: &mut Actions) { let GraphView { comp, ectx, tm } = view; let t = app.tokens; + let pal = GraphPalette::new(&t); let ctx = ui.ctx().clone(); let footer_h = 28.0; let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h)); let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max); // Background first so keys and handles (added later) sit on top for hit-testing. let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag()); - p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d)); + p.rect_filled(area, 0.0, pal.bg); app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone()); let speed = speed_graph(app, comp); - let cs = curves(app, comp, ectx, tm, plot, speed); + let cs = curves(app, comp, ectx, tm, plot, speed, &pal); fit_on_open(app, &ctx, &cs, tm, plot, speed); // Vertical range: auto-zoom to the curves + handles, or the stored manual range. @@ -437,8 +520,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let mut gv = (lo / step).ceil() * step; while gv <= hi { let y = ymap(gv); - pc.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], Stroke::new(1.0, Color32::from_white_alpha(12))); - pc.text(pos2(plot.min.x + 4.0, y - 1.0), Align2::LEFT_BOTTOM, fmt(gv, step), Tokens::ui(10.0), t.text_faint); + pc.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], Stroke::new(1.0, pal.grid)); + pc.text(pos2(plot.min.x + 4.0, y - 1.0), Align2::LEFT_BOTTOM, fmt(gv, step), Tokens::ui(10.0), pal.label); gv += step; } if cs.is_empty() { @@ -447,12 +530,12 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte Align2::CENTER_CENTER, crate::i18n::tr("Select animated properties to show them in the Graph Editor"), Tokens::ui(12.0), - t.text_faint, + pal.label, ); } if app.ui.timeline.graph_reference && !cs.is_empty() { - reference_graph(app, view, plot, speed, &pc); + reference_graph(app, view, plot, speed, &pc, &pal); } // Curves. for c in &cs { @@ -465,7 +548,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte .flat_map(|c| c.keys.iter().filter(|k| k.sel).map(move |k| super::graph_tools::SelKey { t: k.t, v: k.v, dim: c.dim, editable: c.editable })) .collect(); let all_key_times: Vec<(u64, usize, f64)> = cs.iter().flat_map(|c| c.keys.iter().map(move |k| (c.prop.uid, k.idx, k.t))).collect(); - super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions); + super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, &pal, actions); // Keys and handles. let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![]; for c in &cs { @@ -484,8 +567,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte // Handles (selected keys). for h in &k.handles { let hp = pos2(tm.x(h.t), ymap(h.v)); - pc.line_segment([kp, hp], Stroke::new(1.0, Color32::from_rgb(0xd8, 0xd8, 0x60))); - pc.circle_filled(hp, 3.5, Color32::from_rgb(0xd8, 0xd8, 0x60)); + pc.line_segment([kp, hp], Stroke::new(1.0, pal.handle)); + pc.circle_filled(hp, 3.5, pal.handle); let side = if h.out { "out" } else { "in" }; let hr = Rect::from_center_size(hp, vec2(10.0, 10.0)); let hresp = ui.interact(hr, egui::Id::unique(("ghandle", uid, d, k.idx, side)), Sense::drag()); @@ -539,7 +622,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } // The key itself. - let (col, edge) = key_colors(&t, c.color, k); + let (col, edge) = key_colors(&t, &pal, c.color, k); pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col); pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, edge), StrokeKind::Outside); let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); @@ -597,25 +680,24 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } - super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions); + super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, &pal, actions); box_select(ui, &pc, &bg, &key_screens, t.accent, actions); wheel_zoom(app, ui, plot, (lo, hi)); - if button_bar(app, ui, p, comp, bar, speed, actions) { + if button_bar(app, ui, p, comp, ButtonBar::new(&ctx, bar, pal), speed, actions) { fit_selection(app, &cs, tm, plot, speed); } } /// The footer button bar: graph type, display toggles, fit, snap and keyframe interpolation. /// Returns whether Fit Selection was pressed. -fn button_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, bar: Rect, speed: bool, actions: &mut Actions) -> bool { +fn button_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, mut buttons: ButtonBar, speed: bool, actions: &mut Actions) -> bool { let t = app.tokens; - let ctx = ui.ctx().clone(); + let bar = buttons.bar; let mut fit_sel = false; p.rect_filled(bar, 0.0, t.panel_bg); p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); let y = bar.center().y; - let mut buttons = ButtonBar::new(&ctx, bar); let tl = &app.ui.timeline; let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom); if text_button( @@ -706,7 +788,7 @@ fn button_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, co actions.push((cmd.into(), params)); } } - buttons.more(app, ui, p, bar); + buttons.more(app, ui, p); fit_sel } @@ -890,6 +972,76 @@ fn shift_axes(shift: bool, dx: f32, dy: f32) -> (bool, bool) { if shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) } } +#[cfg(test)] +mod theme_tests { + use super::{Color32, DIM_COLORS, DIM_COLORS_LIGHT, GraphPalette, PROP_COLORS, PROP_COLORS_LIGHT, min_contrast}; + use crate::theme::{ThemeKind, Tokens, contrast_ratio as contrast}; + + /// #3202: on every theme the Graph Editor's surfaces come from the theme, the bar buttons' + /// labels (lit and unlit) are readable on their fills, and the curves, keys, handles and the + /// transform box show on the plot. Before, the plot was a fixed #1d1d1d and the unlit buttons + /// a fixed #2c2c2c under the theme's text colour: Light's #222 text on them measured 1.1:1. + #[test] + fn the_graph_editor_follows_the_theme_and_stays_readable() { + let mut fills = vec![]; + for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] { + let t = Tokens::for_kind(kind); + let pal = GraphPalette::new(&t); + assert_eq!(pal.bg, t.tl_bg, "{kind:?}: the plot is the Timeline's time graph background"); + for (what, fg, bg, min) in [ + ("unlit button label", pal.button_text, pal.button, 4.5), + ("lit button label", pal.button_on_text, pal.button_on, 4.5), + ("hovered button label", pal.button_text, t.hover, 4.5), + ("grid value labels", pal.label, pal.bg, 4.5), + ("Bezier handles", pal.handle, pal.bg, 3.0), + ("unselected keys", pal.key, pal.bg, 3.0), + ("transform box", pal.box_line, pal.bg, 3.0), + ("transform box handles", pal.box_line, pal.box_fill, 3.0), + ("unlit button on the bar", pal.button, t.panel_bg, 1.05), + ("grid lines", pal.grid, pal.bg, 1.08), + ] { + let r = contrast(fg, bg); + assert!(r >= min, "{kind:?}: {what}: {r:.2}:1, needs {min}:1"); + } + for (i, c) in pal.dims.iter().chain(pal.props.iter()).enumerate() { + let r = contrast(*c, pal.bg); + assert!(r >= 3.0, "{kind:?}: curve colour {i} {c:?} on the plot: {r:.2}:1"); + } + fills.push((pal.bg, pal.button)); + } + assert_ne!(fills[0], fills[2], "Dark and Light draw the graph differently, not one fixed grey"); + // The dark curve colours would not do on the Light plot: that is why it has its own set. + let light = GraphPalette::new(&Tokens::for_kind(ThemeKind::Light)); + assert!(DIM_COLORS.iter().chain(PROP_COLORS.iter()).any(|c| contrast(*c, light.bg) < 3.0)); + } + + /// The brightness slider moves the plot (Dark lightened towards mid grey, Light darkened): + /// the plot still follows it, the curve set is whichever stands out more from that plot (not + /// the one the theme's name picks), and the lit button's label stays the more readable one. + #[test] + fn the_curve_set_follows_the_plot_under_the_brightness_slider() { + let bright: Vec = DIM_COLORS.iter().chain(PROP_COLORS.iter()).copied().collect(); + let dark: Vec = DIM_COLORS_LIGHT.iter().chain(PROP_COLORS_LIGHT.iter()).copied().collect(); + for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] { + for b in [-1.0, -0.5, 0.0, 0.5, 1.0] { + let t = Tokens::for_kind(kind).with_brightness(b); + let pal = GraphPalette::new(&t); + assert_eq!(pal.bg, t.tl_bg, "{kind:?} at {b}: the plot follows the slider"); + let shown: Vec = pal.dims.iter().chain(pal.props.iter()).copied().collect(); + let best = min_contrast(&bright, pal.bg).max(min_contrast(&dark, pal.bg)); + assert_eq!(min_contrast(&shown, pal.bg), best, "{kind:?} at {b}: the curves are the set that stands out more from {:?}", pal.bg); + let lit = contrast(pal.button_on_text, pal.button_on); + assert!(lit >= contrast(Color32::WHITE, pal.button_on).max(contrast(t.text, pal.button_on)), "{kind:?} at {b}: lit label {lit:.2}:1"); + } + } + // Light darkened all the way is a dark plot: it gets the bright curves, as Dark does. + let dimmed = GraphPalette::new(&Tokens::for_kind(ThemeKind::Light).with_brightness(-1.0)); + assert_eq!(dimmed.props, PROP_COLORS); + let lit = GraphPalette::new(&Tokens::for_kind(ThemeKind::Light)); + assert_eq!(lit.props, PROP_COLORS_LIGHT); + } +} + #[cfg(test)] mod tests { use super::shift_axes; diff --git a/crates/ui-egui/src/panels/graph_tools.rs b/crates/ui-egui/src/panels/graph_tools.rs index 7950d4a66..d611051a0 100644 --- a/crates/ui-egui/src/panels/graph_tools.rs +++ b/crates/ui-egui/src/panels/graph_tools.rs @@ -71,6 +71,7 @@ pub(crate) fn transform_box( vmap: &dyn Fn(f32) -> f64, speed: bool, handles: bool, + pal: &super::graph::GraphPalette, actions: &mut Actions, ) { if !app.ui.timeline.graph_transform_box { @@ -79,7 +80,7 @@ pub(crate) fn transform_box( let Some(b) = bounds(keys) else { return }; let r = Rect::from_min_max(pos2(tm.x(b[0]), ymap(b[3])), pos2(tm.x(b[2]), ymap(b[1]))).expand(6.0); let value_ok = !speed && keys.iter().all(|k| k.editable); - let col = Color32::from_rgb(0xd8, 0xd8, 0xd8); + let col = pal.box_line; let ctx = ui.ctx().clone(); let drag_id = egui::Id::unique("graph-dragging"); let hp = handle_points(r); @@ -116,7 +117,7 @@ pub(crate) fn transform_box( continue; } let hr = Rect::from_center_size(*h, vec2(7.0, 7.0)); - p.rect_filled(hr, 0.0, Color32::from_gray(0x20)); + p.rect_filled(hr, 0.0, pal.box_fill); p.rect_stroke(hr, 0.0, Stroke::new(1.0, col), StrokeKind::Middle); let resp = ui.interact(hr.expand(2.0), egui::Id::unique(("graph-box-handle", i)), Sense::drag()); app.auto.add(&format!("timeline.graph.transformBox.{i}"), hr, "Transform box handle"); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index e67bd2d7d..e18d834ee 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -111,6 +111,21 @@ pub struct Tokens { pub gradients: bool, } +/// WCAG 2 relative luminance of an opaque sRGB colour (alpha is ignored). +pub fn relative_luminance(c: Color32) -> f32 { + let f = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * f(c.r()) + 0.7152 * f(c.g()) + 0.0722 * f(c.b()) +} + +/// WCAG 2 contrast ratio of two opaque sRGB colours (1 to 21). +pub fn contrast_ratio(a: Color32, b: Color32) -> f32 { + let (x, y) = (relative_luminance(a), relative_luminance(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) +} + /// 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(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 422f0223e..d8cdc5cb3 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -46,6 +46,7 @@ mod ui_gradient_editor; mod ui_gradient_handles; mod ui_gradient_value; mod ui_graph_fit; +mod ui_graph_theme; mod ui_group_rename; mod ui_guide_layers; mod ui_info_tracker_a11y; diff --git a/crates/ui-egui/tests/it/ui_graph_theme.rs b/crates/ui-egui/tests/it/ui_graph_theme.rs new file mode 100644 index 000000000..49c6689a0 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_graph_theme.rs @@ -0,0 +1,102 @@ +//! #3202: the Graph Editor follows the theme (egui_kittest, a real render). Its plot used to be a +//! fixed dark grey in every theme, and its bar buttons a fixed dark fill under the theme's text +//! colour, so in Light their labels (dark text) only showed while hovered. Now the plot takes the +//! Timeline's time graph background and every bar button's label is readable on its fill, lit or +//! not, in Dark and Light. `GRAPH_THEME_SNAPSHOT=/abs/dir` also saves the rendered window. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::theme::ThemeKind; +use egui::Color32; +use egui_kittest::Harness; +use serde_json::json; + +/// WCAG contrast ratio of two rendered pixels. +fn contrast(a: [u8; 4], b: [u8; 4]) -> f32 { + effectcraft_ui_egui::theme::contrast_ratio(Color32::from_rgb(a[0], a[1], a[2]), Color32::from_rgb(b[0], b[1], b[2])) +} + +fn close(got: [u8; 4], want: Color32) -> bool { + got[..3].iter().zip(want.to_array()).all(|(a, b)| a.abs_diff(b) <= 3) +} + +#[test] +fn the_graph_editor_follows_the_theme() { + let _one_at_a_time = crate::gpu_test::lock(); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Theme", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#204060", "width": 80, "height": 80})).unwrap(); + // An animated property on the selected layer, so the graph shows a curve and its keys. + let id = s.active_comp().unwrap().layers[0].id; + for (t, v) in [(0.5, 0.0), (2.0, 180.0), (3.5, 90.0)] { + s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/rotation", "time": t, "value": v})).unwrap(); + } + s.execute("layer.select", json!({"layers": [id.0]})).unwrap(); + + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + let ctx = h.ctx.clone(); + + let mut plots = vec![]; + let mut failures = vec![]; + for (kind, name) in [(ThemeKind::Dark, "dark"), (ThemeKind::Light, "light")] { + h.state_mut().set_theme(&ctx, kind); + h.run_steps(4); + let tokens = h.state().tokens; + assert_eq!(tokens.kind, kind); + let plot = h.state().auto.find("timeline.graph").unwrap_or_else(|| panic!("the Graph Editor is shown ({name})")).rect; + let buttons: Vec<(&str, [f32; 4])> = ["valueGraph", "speedGraph"] + .iter() + .map(|id| (*id, h.state().auto.find(&format!("timeline.graph.{id}")).unwrap_or_else(|| panic!("{id} is on the bar ({name})")).rect)) + .collect(); + let image = h.render().expect("the window renders"); + if let Some(dir) = std::env::var_os("GRAPH_THEME_SNAPSHOT") { + image.save(std::path::Path::new(&dir).join(format!("graph-theme-{name}.png"))).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let at = |px: f32, py: f32| { + let (x, y) = (((px * scale) as u32).min(image.width() - 1), ((py * scale) as u32).min(image.height() - 1)); + image.get_pixel(x, y).0 + }; + + // The plot: the commonest colour down a column near its right edge (clear of the value + // labels on the left and of the scroll bar; grid lines and the curve cross it only at a + // sample or two). + let x = plot[0] + plot[2] - 20.0; + let mut column: Vec<[u8; 4]> = (0..24).map(|i| at(x, plot[1] + 8.0 + (plot[3] - 16.0) * i as f32 / 23.0)).collect(); + column.sort_unstable(); + let mut runs: Vec<([u8; 4], usize)> = vec![]; + for px in column { + match runs.last_mut() { + Some((c, n)) if *c == px => *n += 1, + _ => runs.push((px, 1)), + } + } + let bg = runs.iter().max_by_key(|(_, n)| *n).map(|(c, _)| *c).unwrap(); + if !close(bg, tokens.tl_bg) { + failures.push(format!("{name}: the plot is {bg:?}, the theme's time graph background is {:?}", tokens.tl_bg)); + } + plots.push(bg); + + // Each button (Value is lit, Speed is not): its fill left of the label, and the label's + // ink, the pixel across its middle rows that stands out most from the fill. Only the + // label's span is scanned (7 points of padding each side), not the button's feathered + // edges, which blend the fill with the bar behind it. + for (id, r) in buttons { + let mid = r[1] + r[3] / 2.0; + let fill = at(r[0] + 2.5, mid); + let ink = (-3..=3) + .flat_map(|dy| (3..(r[2] as i32 - 3)).map(move |dx| (r[0] + dx as f32, mid + dy as f32))) + .map(|(px, py)| at(px, py)) + .max_by(|a, b| contrast(*a, fill).total_cmp(&contrast(*b, fill))) + .unwrap(); + let c = contrast(ink, fill); + if c < 3.0 { + failures.push(format!("{name}: the {id} button's label {ink:?} on its fill {fill:?} is {c:.2}:1")); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + assert_ne!(plots[0], plots[1], "the plot looks different in Dark and in Light, so it is not a fixed grey"); +}