mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
297 lines
13 KiB
Rust
297 lines
13 KiB
Rust
//! #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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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)"));
|
|
}
|
|
}
|