diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..a236e39e8 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -219,6 +219,8 @@ fn prop_row( actions: &mut Actions, ) -> Vec { 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 { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..ed5a80bd8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..88bd81eef 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3987,6 +3987,8 @@ fn value_editor( actions: &mut Vec<(String, serde_json::Value)>, ) -> Vec { 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(_) => { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..623c5ca5c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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 { diff --git a/crates/ui-egui/tests/ui_expression_values.rs b/crates/ui-egui/tests/ui_expression_values.rs new file mode 100644 index 000000000..9b226429b --- /dev/null +++ b/crates/ui-egui/tests/ui_expression_values.rs @@ -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 { + 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 { + fn walk(s: &Shape, area: Rect, out: &mut Vec) { + 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)> { + 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 = 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:?}"); +}