Files
effectcraft/crates/ui-egui/tests/ui_gradient_handles.rs
tylerjenningsw c39f29882c Shapes: drag gradient start and end points in the Composition viewer (#612)
With a shape layer's Gradient Fill or Gradient Stroke (or a property in
it) selected, its Start Point and End Point show in the viewer as
draggable crosshairs with a guide between them, as in After Effects. The
points are in the space of the shape group holding the gradient, so they
are mapped through the enclosing groups' transforms and the layer's
(shapes::item_matrix), and a drag converts back the same way. A drag is
one prop.set merge: one undo step, and on an animated point a keyframe at
the current time. The hook runs after the viewer's own gestures, like the
effect point controls, so a handle takes the press and the layer doesn't
move. They follow View ▸ Show Layer Controls.

Tests (ui_gradient_handles, only pre-existing APIs, all three fail without
this): dragging the End Point moves it by the drag in one undo step without
moving the layer; inside a moved, 200%-scaled group the handle sits where
the group draws the point and a drag converts to group space; no handles
unless the gradient or a property in it is selected, or with layer
controls off. ui_effect_controls still passes.
2026-10-11 11:12:05 -04:00

159 lines
7.8 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! #612: a shape layer's Gradient Fill start and end points are dragged in the Composition viewer,
//! as in After Effects. With the gradient (or a property in it) selected, `viewer.gradientPoint.*`
//! handles sit on the points, in the space of the group holding the gradient; a drag sets the
//! point in one undo step and doesn't move the layer. Uses only APIs that existed before the
//! handles, so it runs (and fails) on the code without them.
use effectcraft_engine::Session;
use effectcraft_engine::project::{LayerId, PropGroup};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::panels::viewer::comp_to_screen;
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
/// The Gradient Fill group in shape contents `g`, through nested groups.
fn gfill(g: &PropGroup) -> Option<&PropGroup> {
for x in g.groups() {
if x.match_id == "gfill" {
return Some(x);
}
if let Some(found) = gfill(x) {
return Some(found);
}
}
None
}
struct Ids {
layer: u64,
gfill: u64,
start: u64,
end: u64,
}
/// A 640×360 comp with a rectangle shape layer, a Gradient Fill added inside the rectangle's group
/// (`in_group`) or at the top of its contents, after `prep` runs on the session.
fn setup(in_group: bool, prep: impl FnOnce(&mut Session, u64, u64)) -> (Session, Ids) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap();
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
let group = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().groups().next().unwrap().uid;
let mut p = json!({"layer": l, "kind": "gfill"});
if in_group {
p["group"] = json!(group);
}
s.execute("layer.addShapeItem", p).unwrap();
prep(&mut s, l, group);
let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap();
let g = gfill(&layer.props).expect("a Gradient Fill");
let ids = Ids { layer: l, gfill: g.uid, start: g.get("start").unwrap().uid, end: g.get("end").unwrap().uid };
s.state.selected_layers = vec![LayerId(l)];
(s, ids)
}
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(4);
h
}
fn handle(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Option<Pos2> {
h.state().auto.find(&format!("viewer.gradientPoint.{uid}")).map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0))
}
fn point(h: &Harness<'_, EffectcraftApp>, ids: &Ids, uid: u64) -> [f64; 2] {
h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.find(uid).unwrap().value.as_vec2()
}
/// Screen pixels per comp pixel.
fn zoom(h: &Harness<'_, EffectcraftApp>) -> f32 {
let a = comp_to_screen(&h.ctx, [0.0, 0.0]).unwrap();
let b = comp_to_screen(&h.ctx, [100.0, 0.0]).unwrap();
(b.x - a.x) / 100.0
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=8 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn near(a: [f64; 2], b: [f64; 2], tol: f64) -> bool {
(a[0] - b[0]).abs() <= tol && (a[1] - b[1]).abs() <= tol
}
/// With the Gradient Fill selected, its End Point is a handle on the point; dragging it moves the
/// point by the drag (in comp pixels, the layer and group being unscaled), as one undo step,
/// without moving the layer.
#[test]
fn dragging_the_end_point_moves_it_in_one_undo_step() {
let (mut s, ids) = setup(false, |_, _, _| {});
s.state.selected_props = vec![(LayerId(ids.layer), ids.gfill)];
let mut h = harness(s);
let at = handle(&h, ids.end).unwrap_or_else(|| panic!("no viewer.gradientPoint.{}: the Gradient Fill's End Point isn't draggable in the viewer", ids.end));
assert!(handle(&h, ids.start).is_some(), "and its Start Point");
let (end, start) = (point(&h, &ids, ids.end), point(&h, &ids, ids.start));
let position = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().value.clone();
let undo = h.state().session.history.undo.len();
let z = zoom(&h);
drag(&mut h, at, at + vec2(60.0, 30.0) * z);
let moved = point(&h, &ids, ids.end);
assert!(near(moved, [end[0] + 60.0, end[1] + 30.0], 1.5), "End Point {end:?} moved by the drag: {moved:?}");
assert_eq!(point(&h, &ids, ids.start), start, "the Start Point stays");
let layer = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().clone();
assert_eq!(layer.props.prop("transform/position").unwrap().value, position, "the layer didn't move");
assert_eq!(h.state().session.history.undo.len(), undo + 1, "the drag is one undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(point(&h, &ids, ids.end), end, "one undo puts it back");
}
/// A gradient inside a moved and scaled group: its handles sit where the group draws the points,
/// and a drag converts back into the group's space (half as far at 200%).
#[test]
fn points_follow_the_group_transform() {
let (mut s, ids) = setup(true, |s, l, group| {
let tr = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(group).unwrap().sub("transform").unwrap().clone();
s.execute("prop.set", json!({"layer": l, "prop": tr.get("scale").unwrap().uid, "value": [200.0, 200.0]})).unwrap();
s.execute("prop.set", json!({"layer": l, "prop": tr.get("position").unwrap().uid, "value": [40.0, -20.0]})).unwrap();
});
s.state.selected_props = vec![(LayerId(ids.layer), ids.start)];
let mut h = harness(s);
// Layer at the comp's centre (320, 180); group: position (40, -20), scale 200%.
let start = point(&h, &ids, ids.start);
let want = comp_to_screen(&h.ctx, [(320.0 + 40.0 + 2.0 * start[0]) as f32, (180.0 - 20.0 + 2.0 * start[1]) as f32]).unwrap();
let at = handle(&h, ids.start).expect("a Start Point handle (selecting a property in the gradient shows it)");
assert!((at - want).length() < 1.5, "the handle is where the group draws the point: {at:?} vs {want:?}");
let z = zoom(&h);
drag(&mut h, at, at + vec2(40.0, 20.0) * z);
let moved = point(&h, &ids, ids.start);
assert!(near(moved, [start[0] + 20.0, start[1] + 10.0], 1.0), "in group space, half the comp drag: {start:?} → {moved:?}");
}
/// No handles unless the gradient (or a property in it) is selected, or with layer controls off.
#[test]
fn handles_show_only_for_a_selected_gradient() {
let (s, ids) = setup(false, |_, _, _| {});
let h = harness(s);
assert!(handle(&h, ids.end).is_none(), "only the layer is selected");
let (mut s, ids) = setup(false, |_, _, _| {});
let position = s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().uid;
s.state.selected_props = vec![(LayerId(ids.layer), position)];
let h = harness(s);
assert!(handle(&h, ids.end).is_none(), "another property is selected");
let (mut s, ids) = setup(false, |_, _, _| {});
s.state.selected_props = vec![(LayerId(ids.layer), ids.gfill)];
let mut h = harness(s);
assert!(handle(&h, ids.end).is_some());
h.state_mut().ui.viewer.show_layer_controls = false;
h.run_steps(3);
assert!(handle(&h, ids.end).is_none(), "View ▸ Show Layer Controls off hides them");
}