mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
UI: colour expression-driven values red in the Timeline, Effect Controls and Properties (#1306)
A property driven by an expression showed its value in the ordinary blue; After Effects shows it red (refs #1072). Tokens::for_value(expression) gives the value fields the expression colour (expr_text, dark and light) for both hot_text and accent_hover, so a hovered or dragged value stays red. The Timeline's value_editor, Effect Controls' parameter rows and the Properties panel's transform rows pass these value tokens to their number and angle fields (and the Timeline's vector separator and plain enum number), from Property::has_expression() every frame: a disabled, removed or empty expression is blue again. Names, stopwatches, keyframe buttons, link icons and dropdowns keep the ordinary tokens. Red means an expression drives the value, not that it evaluated; errors keep their own reporting. Tests (ui_expression_values, reading the painted text colour): a vector (Position, with its separator), an angle (Rotation, both parts) and a scalar (Opacity), on the layer and on the Transform effect, are red in all three panels, in dark and light themes, and Scale (no expression) stays blue; disabled, removed and whitespace-only expressions are blue and enabling again is red; a hovered expression value stays red while a hovered ordinary one takes the hover accent. All three fail without this change. ui_effect_controls and ui_inline_edit still pass.
This commit is contained in:
@@ -219,6 +219,8 @@ fn prop_row(
|
||||
actions: &mut Actions,
|
||||
) -> Vec<Rect> {
|
||||
let t = app.tokens;
|
||||
// The values themselves: red while an expression drives the parameter (#1306).
|
||||
let vt = t.for_value(prop.has_expression());
|
||||
let mut dims = vec![];
|
||||
let cy = r.center().y;
|
||||
let uid = prop.uid;
|
||||
@@ -312,7 +314,7 @@ fn prop_row(
|
||||
}
|
||||
}
|
||||
Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => {
|
||||
let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("ec-v", uid)), *v, 1, &t);
|
||||
let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("ec-v", uid)), *v, 1, &vt);
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1));
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name);
|
||||
if let Some(nv) = nv {
|
||||
@@ -342,7 +344,7 @@ fn prop_row(
|
||||
(min, max),
|
||||
dec.max(if dec == 0 { 0 } else { 1 }),
|
||||
suffix,
|
||||
&t,
|
||||
&vt,
|
||||
);
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name);
|
||||
if let Some(nv) = nv {
|
||||
@@ -370,7 +372,7 @@ fn prop_row(
|
||||
x += 22.0;
|
||||
}
|
||||
for d in 0..n.min(c.len()) {
|
||||
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t);
|
||||
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &vt);
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name);
|
||||
dims.push(vr);
|
||||
if let Some(nv) = nv {
|
||||
|
||||
@@ -193,6 +193,8 @@ fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, pr: &Property, ectx: &EvalCtx, r: Rect, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
// The values themselves: red while an expression drives the property (#1306).
|
||||
let vt = t.for_value(pr.has_expression());
|
||||
let cy = r.center().y;
|
||||
let uid = pr.uid;
|
||||
let lid = layer.id.0;
|
||||
@@ -258,7 +260,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
match &value {
|
||||
Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => {
|
||||
let (_, deg) = super::fx_widgets::split_angle(*v);
|
||||
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, decimals(deg), &t);
|
||||
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, decimals(deg), &vt);
|
||||
app.auto.add(&format!("{base}.value"), dr, &pr.name);
|
||||
app.auto.add(&format!("{base}.revolutions"), rr, &pr.name);
|
||||
if let Some(nv) = nv {
|
||||
@@ -267,7 +269,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
}
|
||||
Value::Scalar(v) => {
|
||||
let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) };
|
||||
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
|
||||
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &vt);
|
||||
app.auto.add(&format!("{base}.value"), vr, &pr.name);
|
||||
if let Some(nv) = nv {
|
||||
set(actions, json!(nv));
|
||||
@@ -293,7 +295,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
}
|
||||
for d in 0..n.min(c.len()) {
|
||||
let (vr, nv, _) =
|
||||
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
|
||||
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &vt);
|
||||
app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name);
|
||||
if let Some(nv) = nv {
|
||||
let mut nc = c.clone();
|
||||
|
||||
@@ -3987,6 +3987,8 @@ fn value_editor(
|
||||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) -> Vec<Rect> {
|
||||
let t = app.tokens;
|
||||
// The values themselves: red while an expression drives the property (#1306).
|
||||
let vt = t.for_value(prop.has_expression());
|
||||
let uid = prop.uid;
|
||||
let mut dims = vec![];
|
||||
let merge = format!("scrub-{uid}");
|
||||
@@ -4013,7 +4015,7 @@ fn value_editor(
|
||||
_ => (-1e9, 1e9, 1, 1.0),
|
||||
};
|
||||
if matches!(prop.ui, ParamUi::Angle) {
|
||||
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, 1, &t);
|
||||
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, 1, &vt);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
|
||||
if let Some(nv) = nv {
|
||||
@@ -4021,7 +4023,7 @@ fn value_editor(
|
||||
}
|
||||
} else {
|
||||
let suffix = if matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity" { "%" } else { "" };
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &vt);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
if let Some(nv) = nv {
|
||||
set(actions, json!(nv));
|
||||
@@ -4050,7 +4052,7 @@ fn value_editor(
|
||||
}
|
||||
for d in 0..n.min(c.len()) {
|
||||
let suffix = if pct && d + 1 == n { "%" } else { "" };
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &vt);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
|
||||
dims.push(r);
|
||||
if let Some(nv) = nv {
|
||||
@@ -4067,7 +4069,7 @@ fn value_editor(
|
||||
}
|
||||
x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
|
||||
if d + 1 < n {
|
||||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text);
|
||||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), vt.hot_text);
|
||||
x += 6.0;
|
||||
}
|
||||
}
|
||||
@@ -4123,7 +4125,7 @@ fn value_editor(
|
||||
set(actions, json!(ni));
|
||||
}
|
||||
} else {
|
||||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text);
|
||||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), vt.hot_text);
|
||||
}
|
||||
}
|
||||
Value::Path(_) => {
|
||||
|
||||
@@ -125,6 +125,16 @@ impl Tokens {
|
||||
pub fn grad_top(&self, c: Color32) -> Color32 {
|
||||
if self.gradients { c.lerp_to_gamma(Color32::WHITE, 0.06) } else { c }
|
||||
}
|
||||
|
||||
/// The tokens a property's value fields use (#1306): with an expression driving the property
|
||||
/// (`expression`, [`Property::has_expression`]) its values show in the expression colour, red
|
||||
/// as in After Effects, hovered and dragged too; otherwise these tokens. For the values only:
|
||||
/// the name, stopwatch and swatches keep the ordinary tokens.
|
||||
///
|
||||
/// [`Property::has_expression`]: effectcraft_engine::project::Property::has_expression
|
||||
pub fn for_value(self, expression: bool) -> Tokens {
|
||||
if expression { Tokens { hot_text: self.expr_text, accent_hover: self.expr_text, ..self } } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! #1306 (from #1072): a property driven by an expression shows its value in red, the expression
|
||||
//! colour, in the Timeline, Effect Controls and the Properties panel, as in After Effects: scalars,
|
||||
//! every part of a vector (with its separator) and both parts of an angle, hovered too, in dark and
|
||||
//! light themes. A disabled, removed or empty expression shows the ordinary colour again. The
|
||||
//! tests read the colour the value text is painted with.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::{LayerId, PropGroup};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use effectcraft_ui_egui::theme::ThemeKind;
|
||||
use egui::epaint::Shape;
|
||||
use egui::{Color32, Event, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
const PANELS: [&str; 3] = ["timeline.prop", "effectControls.prop", "properties.prop"];
|
||||
|
||||
/// A comp with one solid: (layer, transform Position / Rotation / Opacity / Scale uids, the
|
||||
/// Transform effect's Position / Rotation / Opacity uids).
|
||||
struct Ids {
|
||||
layer: u64,
|
||||
layer_props: [u64; 3],
|
||||
scale: u64,
|
||||
effect_props: [u64; 3],
|
||||
}
|
||||
|
||||
fn setup() -> (Harness<'static, EffectcraftApp>, Ids) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "E", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let fx = s.execute("effect.apply", json!({"layers": [l], "effect": "Transform"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
|
||||
let uid = |path: &str| layer.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid;
|
||||
let g: &PropGroup = layer.props.find_group(fx).unwrap();
|
||||
let fuid = |m: &str| g.get(m).unwrap_or_else(|| panic!("no effect {m}")).uid;
|
||||
let ids = Ids {
|
||||
layer: l,
|
||||
layer_props: [uid("transform/position"), uid("transform/rotation"), uid("transform/opacity")],
|
||||
scale: uid("transform/scale"),
|
||||
effect_props: [fuid("position"), fuid("rotation"), fuid("opacity")],
|
||||
};
|
||||
let transform = layer.props.sub("transform").unwrap().uid;
|
||||
s.state.selected_layers = vec![LayerId(l)];
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
app.ui.timeline.open_layers.insert(l);
|
||||
app.ui.timeline.open_groups.insert(transform);
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app);
|
||||
h.run_steps(4);
|
||||
(h, ids)
|
||||
}
|
||||
|
||||
fn set_expression(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, prop: u64, p: serde_json::Value) {
|
||||
let mut p = p;
|
||||
p["layer"] = json!(ids.layer);
|
||||
p["prop"] = json!(prop);
|
||||
h.state_mut().session.execute("prop.setExpression", p).unwrap();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// The value's area in `panel`: its value, revolutions and vector components together (so a
|
||||
/// vector's separator and an angle's sign are inside).
|
||||
fn value_rect(h: &Harness<'_, EffectcraftApp>, panel: &str, uid: u64) -> Option<Rect> {
|
||||
let ids = [
|
||||
format!("{panel}.{uid}.value"),
|
||||
format!("{panel}.{uid}.revolutions"),
|
||||
format!("{panel}.{uid}.value.0"),
|
||||
format!("{panel}.{uid}.value.1"),
|
||||
format!("{panel}.{uid}.value.2"),
|
||||
];
|
||||
ids.iter()
|
||||
.filter_map(|id| h.state().auto.find(id))
|
||||
.map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])))
|
||||
.reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// The colours of the text painted inside `area` in the last frame.
|
||||
fn text_colors(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec<Color32> {
|
||||
fn walk(s: &Shape, area: Rect, out: &mut Vec<Color32>) {
|
||||
match s {
|
||||
Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, out)),
|
||||
Shape::Text(t) if area.expand(1.0).contains_rect(t.visual_bounding_rect()) && !t.galley.text().trim().is_empty() => {
|
||||
out.push(t.override_text_color.unwrap_or(t.fallback_color));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for c in &h.output().shapes {
|
||||
walk(&c.shape, area, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The colours `uid`'s value is painted with in every panel (each panel must show it).
|
||||
fn value_colors(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec<(&'static str, Vec<Color32>)> {
|
||||
PANELS
|
||||
.iter()
|
||||
.filter_map(|panel| {
|
||||
let area = value_rect(h, panel, uid)?;
|
||||
Some((*panel, text_colors(h, area)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_all(h: &Harness<'_, EffectcraftApp>, uids: &[u64], want: Color32, what: &str) {
|
||||
for &uid in uids {
|
||||
let shown = value_colors(h, uid);
|
||||
assert!(!shown.is_empty(), "{what}: @{uid} is shown in a panel");
|
||||
for (panel, colors) in shown {
|
||||
assert!(!colors.is_empty(), "{what}: @{uid} in {panel} paints its value");
|
||||
assert!(colors.iter().all(|c| *c == want), "{what}: @{uid} in {panel}: {colors:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expression-driven values are red in every panel and both themes; other values stay blue.
|
||||
#[test]
|
||||
fn expression_driven_values_are_red_in_every_panel() {
|
||||
let (mut h, ids) = setup();
|
||||
for p in ids.layer_props.iter().chain(&ids.effect_props) {
|
||||
set_expression(&mut h, &ids, *p, json!({"expression": "value"}));
|
||||
}
|
||||
// Every value is shown somewhere: the layer's in the Timeline and Properties, the effect's in
|
||||
// Effect Controls.
|
||||
for p in ids.layer_props {
|
||||
assert!(value_rect(&h, "timeline.prop", p).is_some() && value_rect(&h, "properties.prop", p).is_some(), "@{p} in the Timeline and Properties");
|
||||
}
|
||||
for p in ids.effect_props {
|
||||
assert!(value_rect(&h, "effectControls.prop", p).is_some(), "@{p} in Effect Controls");
|
||||
}
|
||||
for theme in [ThemeKind::Dark, ThemeKind::Light] {
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, theme);
|
||||
h.run_steps(3);
|
||||
let t = h.state().tokens;
|
||||
assert_ne!(t.expr_text, t.hot_text);
|
||||
let all: Vec<u64> = ids.layer_props.iter().chain(&ids.effect_props).copied().collect();
|
||||
assert_all(&h, &all, t.expr_text, &format!("{theme:?} expression"));
|
||||
assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} no expression"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Disabled, removed or empty: the ordinary colour; enabled again: red.
|
||||
#[test]
|
||||
fn disabled_removed_and_empty_expressions_show_the_ordinary_colour() {
|
||||
let (mut h, ids) = setup();
|
||||
let [position, rotation, opacity] = ids.layer_props;
|
||||
for p in ids.layer_props {
|
||||
set_expression(&mut h, &ids, p, json!({"expression": "value"}));
|
||||
}
|
||||
let t = h.state().tokens;
|
||||
assert_all(&h, &ids.layer_props, t.expr_text, "enabled");
|
||||
set_expression(&mut h, &ids, position, json!({"enabled": false}));
|
||||
set_expression(&mut h, &ids, rotation, json!({"expression": ""}));
|
||||
set_expression(&mut h, &ids, opacity, json!({"expression": " "}));
|
||||
assert_all(&h, &ids.layer_props, t.hot_text, "disabled, removed, empty");
|
||||
set_expression(&mut h, &ids, position, json!({"enabled": true}));
|
||||
assert_all(&h, &[position], t.expr_text, "enabled again");
|
||||
}
|
||||
|
||||
fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(at));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Hovering a value (which turns an ordinary value's colour to the hover accent) keeps an
|
||||
/// expression-driven one red.
|
||||
#[test]
|
||||
fn a_hovered_expression_value_stays_red() {
|
||||
let (mut h, ids) = setup();
|
||||
let [_, _, opacity] = ids.layer_props;
|
||||
set_expression(&mut h, &ids, opacity, json!({"expression": "value"}));
|
||||
let t = h.state().tokens;
|
||||
for panel in ["timeline.prop", "properties.prop"] {
|
||||
let area = value_rect(&h, panel, opacity).expect("the value");
|
||||
hover(&mut h, area.center());
|
||||
let colors = text_colors(&h, area);
|
||||
assert!(!colors.is_empty() && colors.iter().all(|c| *c == t.expr_text), "{panel} hovered: {colors:?}");
|
||||
}
|
||||
// An ordinary value does take the hover accent there.
|
||||
let area = value_rect(&h, "timeline.prop", ids.scale).expect("Scale");
|
||||
hover(&mut h, area.left_center() + vec2(6.0, 0.0));
|
||||
let colors = text_colors(&h, area);
|
||||
assert!(colors.contains(&t.accent_hover), "an ordinary hovered value: {colors:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user