mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
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.
159 lines
7.8 KiB
Rust
159 lines
7.8 KiB
Rust
//! #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");
|
||
}
|