Graph Editor: follow the theme (plot, bar buttons, grid, keys, handles, curves) (#3202)

The Graph Editor painted its own surfaces with fixed dark greys: the plot
#1d1d1d and the unlit bar buttons #2c2c2c under the theme's text colour. In
the Light theme the plot stayed dark and the button labels (#222 on #2c2c2c,
1.1:1) only showed while hovered; lit buttons put white on a pale blue.

GraphPalette builds the editor's colours from the theme tokens once per frame:
the plot takes the Timeline's time graph background, the buttons a fill just
off the panel with the more readable label, the grid an opaque tint of the
plot. Keys, Bezier handles, the transform box and the curves have a darker
set for a pale plot, picked by the plot colour itself so it holds under the
brightness slider. theme.rs gains WCAG relative_luminance/contrast_ratio,
also used by the expression editor's contrast test.

Fixes #3202
This commit is contained in:
vangel76
2026-11-07 14:13:23 +01:00
parent bb2d2005ce
commit e0d9e024bd
6 changed files with 307 additions and 49 deletions
+1 -14
View File
@@ -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
+185 -33
View File
@@ -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<Color32> = DIM_COLORS.iter().chain(PROP_COLORS.iter()).copied().collect();
let dark: Vec<Color32> = 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<Curve<'a>> {
fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, plot: Rect, speed: bool, pal: &GraphPalette) -> Vec<Curve<'a>> {
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<String>,
/// 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::<String>(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<Color32> = DIM_COLORS.iter().chain(PROP_COLORS.iter()).copied().collect();
let dark: Vec<Color32> = 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<Color32> = 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;
+3 -2
View File
@@ -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");
+15
View File
@@ -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();
+1
View File
@@ -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;
+102
View File
@@ -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");
}