diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 05fea44c9..249c8462b 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -88,6 +88,11 @@ struct PropCx<'a> { } impl PropCx<'_> { + /// The tokens of the value fields: red while an expression drives the parameter (#1306). + fn value_tokens(&self) -> Tokens { + self.t.for_value(self.prop.has_expression()) + } + fn set(&self, actions: &mut Actions, v: serde_json::Value) { let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub); actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) @@ -243,8 +248,8 @@ fn value_angle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let Value::Scalar(v) = value else { return; }; - let PropCx { t, uid, cy, vx, prop, .. } = *cx; - let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); + let PropCx { uid, cy, vx, prop, .. } = *cx; + let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &cx.value_tokens()); 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 { @@ -257,7 +262,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let Value::Scalar(v) = value else { return; }; - let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let PropCx { uid, cy, vx, prop, .. } = *cx; let (min, max, dec) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), _ => (-1e9, 1e9, 1), @@ -273,7 +278,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: egui::Id::unique(("ec-v", uid)), *v, &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(if dec == 0 { 0 } else { 1 }), suffix }, - &t, + &cx.value_tokens(), ); app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); if let Some(nv) = nv { @@ -290,6 +295,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; let c = value.components(); let n = shown_dims(layer, prop, value); + let vt = cx.value_tokens(); let mut x = vx; if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); @@ -310,7 +316,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: egui::Id::unique(("ec-v", uid, d)), c[d], &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" }, - &t, + &vt, ); app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); dims.push(vr); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c35108937..b10604122 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -200,6 +200,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; @@ -276,7 +278,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::unique(("props-v", uid)), *v, decimals(deg), &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("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 { @@ -291,7 +293,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, egui::Id::unique(("props-v", uid)), *v, &widgets::HotSpec { speed: 0.5, range, decimals: decimals(*v), suffix }, - &t, + &vt, ); app.auto.add(&format!("{base}.value"), vr, &pr.name); if let Some(nv) = nv { @@ -323,7 +325,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, egui::Id::unique(("props-v", uid, d)), c[d], &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: decimals(c[d]), suffix }, - &t, + &vt, ); app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); if let Some(nv) = nv { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7fc613145..7ae50ac0d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -5530,7 +5530,8 @@ fn value_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) { let Value::Scalar(v) = value else { return }; let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid); - let t = app.tokens; + // The values themselves: red while an expression drives the property (#1306). + let vt = app.tokens.for_value(prop.has_expression()); let (x, y) = (at.x, at.y - 9.0); let (min, max, dec, speed) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)), @@ -5538,7 +5539,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> _ => (-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::unique(("v", uid, 0)), *v, 1, &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("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 { @@ -5554,7 +5555,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> egui::Id::unique(("v", uid, 0)), *v, &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(1), suffix }, - &t, + &vt, ); app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); if let Some(nv) = nv { @@ -5567,6 +5568,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) -> Vec { let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid); let t = app.tokens; + let vt = t.for_value(prop.has_expression()); let (mut x, y) = (at.x, at.y - 9.0); let is_3d = layer.is_3d(); let mut dims = vec![]; @@ -5599,7 +5601,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> egui::Id::unique(("v", uid, d)), c[d], &widgets::HotSpec { speed: if pct { 0.5 } else { 1.0 }, range, decimals: 1, suffix }, - &t, + &vt, ); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); dims.push(r); @@ -5622,7 +5624,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> } 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; } } @@ -5697,7 +5699,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, ve.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), t.for_value(prop.has_expression()).hot_text); } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 227866bcc..aa1b30350 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -129,6 +129,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/it/main.rs b/crates/ui-egui/tests/it/main.rs index 02c31ad80..54f641687 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -21,6 +21,7 @@ mod ui_drag_drop; mod ui_effect_controls; mod ui_effect_drop_highlight; mod ui_effect_names; +mod ui_expression_values; mod ui_font_search; mod ui_form_fit; mod ui_frame_step; diff --git a/crates/ui-egui/tests/it/ui_expression_values.rs b/crates/ui-egui/tests/it/ui_expression_values.rs new file mode 100644 index 000000000..b797e6cf6 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_expression_values.rs @@ -0,0 +1,296 @@ +//! #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 (with its sign), hovered +//! and dragged 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 (the frame's +//! text shapes: the numbers are painted with an override colour, the separator and sign as plain +//! text), and require every expected part to be there. + +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, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +const TIMELINE: &str = "timeline.prop"; +const EFFECT_CONTROLS: &str = "effectControls.prop"; +const PROPERTIES: &str = "properties.prop"; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + /// Two components (X, Y). + Vector, + /// Revolutions, sign and degrees. + Angle, + Scalar, +} + +/// A property whose value the tests check, and the panels that must show it. +#[derive(Clone, Copy, Debug)] +struct Checked { + uid: u64, + kind: Kind, + panels: &'static [&'static str], +} + +/// A comp with one solid and the Transform effect on it. +struct Ids { + layer: u64, + /// Transform Position, Rotation and Opacity (Timeline and Properties panel), and the Transform + /// effect's Position, Rotation and Opacity (Effect Controls). + driven: [Checked; 6], + /// Transform Scale: no expression. + scale: Checked, +} + +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; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + const EFFECT: &[&str] = &[EFFECT_CONTROLS]; + let c = |uid, kind, panels| Checked { uid, kind, panels }; + let ids = Ids { + layer: l, + driven: [ + c(uid("transform/position"), Kind::Vector, LAYER), + c(uid("transform/rotation"), Kind::Angle, LAYER), + c(uid("transform/opacity"), Kind::Scalar, LAYER), + c(fuid("position"), Kind::Vector, EFFECT), + c(fuid("rotation"), Kind::Angle, EFFECT), + c(fuid("opacity"), Kind::Scalar, EFFECT), + ], + scale: c(uid("transform/scale"), Kind::Vector, LAYER), + }; + 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.clone(); + p["layer"] = json!(ids.layer); + p["prop"] = json!(prop); + h.state_mut().session.execute("prop.setExpression", p).unwrap(); +} + +fn set_theme(h: &mut Harness<'_, EffectcraftApp>, theme: ThemeKind) { + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(3); +} + +fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}: the part isn't shown")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// The area of `c`'s value in `panel`, every expected part required: both components of a vector, +/// the revolutions and degrees of an angle (the separator and sign lie between them). +fn value_area(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked) -> Rect { + let uid = c.uid; + match c.kind { + Kind::Vector => rect_of(h, &format!("{panel}.{uid}.value.0")).union(rect_of(h, &format!("{panel}.{uid}.value.1"))), + Kind::Angle => rect_of(h, &format!("{panel}.{uid}.revolutions")).union(rect_of(h, &format!("{panel}.{uid}.value"))), + Kind::Scalar => rect_of(h, &format!("{panel}.{uid}.value")), + } +} + +/// The text painted inside `area` in the last frame, with its colour. +fn texts(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec<(String, Color32)> { + fn walk(s: &Shape, area: Rect, out: &mut Vec<(String, 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.galley.text().trim().to_string(), 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 +} + +/// `c`'s value in `panel` shows every part, all in `want`: two numbers and (in the Timeline) the +/// `,` of a vector, the revolutions, sign and degrees of an angle, the number of a scalar. +fn assert_value(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked, want: Color32, what: &str) { + let shown = texts(h, value_area(h, panel, c)); + let numbers = shown.iter().filter(|(s, _)| s.chars().any(|ch| ch.is_ascii_digit())).count(); + let has = |text: &str| shown.iter().any(|(s, _)| s == text); + let ctx = format!("{what}: {:?} @{} in {panel}: {shown:?}", c.kind, c.uid); + match c.kind { + Kind::Vector => { + assert!(numbers >= 2, "{ctx}: both components"); + if panel == TIMELINE { + assert!(has(","), "{ctx}: the separator"); + } + } + Kind::Angle => { + assert!(numbers >= 2, "{ctx}: revolutions and degrees"); + assert!(has("+") || has("-"), "{ctx}: the sign"); + } + Kind::Scalar => assert!(numbers >= 1, "{ctx}: the number"), + } + assert!(shown.iter().all(|(_, col)| *col == want), "{ctx}: want {want:?}"); +} + +/// Every checked property in every panel it belongs to. +fn assert_all(h: &Harness<'_, EffectcraftApp>, checked: &[Checked], want: Color32, what: &str) { + for c in checked { + for panel in c.panels { + assert_value(h, panel, *c, want, what); + } + } +} + +/// Expression-driven values are red in every panel, in both themes; Scale (none) stays blue. +#[test] +fn expression_driven_values_are_red_in_every_panel() { + let (mut h, ids) = setup(); + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + assert_ne!(t.expr_text, t.hot_text); + assert_all(&h, &ids.driven, t.expr_text, &format!("{theme:?} expression")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} no expression")); + } +} + +/// Disabled, removed or whitespace-only: the ordinary colour; enabled again: red. Every part of +/// every value is checked again after each change, in both themes. +#[test] +fn expression_state_changes_switch_the_colour() { + let (mut h, ids) = setup(); + let states = [ + (json!({"expression": "value"}), true, "enabled"), + (json!({"enabled": false}), false, "disabled"), + (json!({"enabled": true}), true, "enabled again"), + (json!({"expression": ""}), false, "removed"), + (json!({"expression": "value"}), true, "set again"), + (json!({"expression": " "}), false, "whitespace only"), + ]; + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for (p, red, what) in &states { + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, p); + } + h.run_steps(3); + let want = if *red { t.expr_text } else { t.hot_text }; + assert_all(&h, &ids.driven, want, &format!("{theme:?} {what}")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} {what}: Scale")); + } + } +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); +} + +/// Hovering a value (an ordinary one takes the hover accent) keeps an expression-driven one red, +/// in every panel and both themes. +#[test] +fn a_hovered_expression_value_stays_red() { + let (mut h, ids) = setup(); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for c in &opacity { + for panel in c.panels { + let area = value_area(&h, panel, *c); + hover(&mut h, area.center()); + let at = h.ctx.input(|i| i.pointer.hover_pos()); + assert!(at.is_some_and(|p| area.contains(p)), "{theme:?} {panel}: the pointer is over the value"); + assert_value(&h, panel, *c, t.expr_text, &format!("{theme:?} hovered")); + } + } + // An ordinary value does take the hover accent. + let area = value_area(&h, TIMELINE, ids.scale); + let x = rect_of(&h, &format!("{TIMELINE}.{}.value.0", ids.scale.uid)).center(); + hover(&mut h, x); + let shown = texts(&h, area); + assert!(shown.iter().any(|(_, c)| *c == t.accent_hover), "{theme:?} an ordinary hovered value: {shown:?}"); + hover(&mut h, pos2(5.0, 5.0)); + } +} + +/// Dragging (scrubbing) an expression-driven value keeps it red while the drag is on, in the +/// Timeline and Effect Controls. +#[test] +fn a_dragged_expression_value_stays_red() { + let (mut h, ids) = setup(); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); + let t = h.state().tokens; + for (c, panel) in [(opacity[0], TIMELINE), (opacity[1], EFFECT_CONTROLS)] { + let at = value_area(&h, panel, c).center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(at + vec2(3.0 * k as f32, 0.0))); + h.step(); + } + assert!(h.ctx.dragged_id().is_some(), "{panel}: a drag is on"); + assert_value(&h, panel, c, t.expr_text, &format!("{panel} dragged")); + let end = at + vec2(12.0, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + hover(&mut h, pos2(5.0, 5.0)); + } +} + +/// Separate Dimensions: X Position and Y Position are separate scalars, so an expression on X Position +/// turns only X red, in the Timeline and Properties panel and both themes; Y Position stays blue. +#[test] +fn separated_position_colours_each_dimension_on_its_own() { + let (mut h, ids) = setup(); + h.state_mut().session.execute("prop.separateDimensions", json!({"layer": ids.layer, "value": true})).unwrap(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| { + let s = &h.state().session; + s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid + }; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + let x = Checked { uid: uid(&h, "transform/positionX"), kind: Kind::Scalar, panels: LAYER }; + let y = Checked { uid: uid(&h, "transform/positionY"), kind: Kind::Scalar, panels: LAYER }; + set_expression(&mut h, &ids, x.uid, &json!({"expression": "value"})); + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + assert_all(&h, &[x], t.expr_text, &format!("{theme:?} X Position (expression)")); + assert_all(&h, &[y], t.hot_text, &format!("{theme:?} Y Position (none)")); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 0dbaa54fa..904c1d4e6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -169,6 +169,11 @@ The most important missing piece: it turns every other estimate here into a meas a Radial Stroke swatch on a selected shape layer and get the Gradient Editor on its Gradient Fill / Gradient Stroke Colors (OK saves); without such a gradient the swatch still opens the options, and the words, Solid swatches and Alt-click are unchanged. +- Regression evidence for [#1306](https://github.com/storytold/effectcraft/issues/1306) (from + #1072): the `ui_expression_values` tests, checked headlessly on Windows, read the colour the + values are painted in. Expression-driven vectors, angles and scalars are red in the Timeline, + Effect Controls and Properties panel, in dark and light themes, hovered too; disabled, removed + and empty expressions show the ordinary colour. - Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template