Merge pull request #862 from tylerjenningsw/enhance/gradient-viewer-handles

Shapes: drag gradient start and end points in the Composition viewer (#612)
This commit is contained in:
Brandon Thomas committed 2026-10-12 04:20:32 -04:00
commit ac1aca7225
6 files changed
+270 -2

No files matched your search

+1 -1
View File
@@ -121,7 +121,7 @@ pub fn choose_files_after_apply(app: &mut EffectcraftApp, applied: &serde_json::
/// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate
/// drags are separate undo steps while one drag is one step.
pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String {
pub(crate) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String {
let kid = id.with("gesture");
let n: u64 = if started {
let n = ui.data(|d| d.get_temp::<u64>(kid)).unwrap_or(0) + 1;
@@ -0,0 +1,104 @@
//! A shape layer's Gradient Fill / Gradient Stroke start and end points in the Composition viewer
//! (#612). With the gradient, or a property in it, selected, its Start Point and End Point show as
//! draggable ⊕ controls with a guide between them, as in After Effects. A drag sets the point with
//! `prop.set`: one undo step per drag, and on an animated point a keyframe at the current time.
//!
//! The points are in the space of the shape group holding the gradient, so they follow that
//! group's transform (and the groups around it) as well as the layer's.
//!
//! Automation ids: `viewer.gradientPoint.<uid>` (the Start Point / End Point property's uid).
use effectcraft_engine::geom::Mat3;
use effectcraft_engine::project::{Layer, LayerId, Node, PropGroup, Property};
use effectcraft_engine::render::EvalCtx;
use egui::{Color32, Rect, Sense, Stroke, vec2};
use serde_json::json;
use super::fx_widgets as fw;
use crate::EffectcraftApp;
/// Gradient Fill / Gradient Stroke groups in shape contents `g`, through nested groups.
fn gradient_paints<'a>(g: &'a PropGroup, depth: usize, out: &mut Vec<&'a PropGroup>) {
if depth >= 16 {
return;
}
for child in g.groups().filter(|c| c.enabled) {
match child.match_id.as_str() {
"gfill" | "gstroke" => out.push(child),
"group" | "contents" => gradient_paints(child, depth + 1, out),
_ => {}
}
}
}
/// `g` or something inside it is selected on `layer`.
fn selected_in(g: &PropGroup, layer: LayerId, selected: &[(LayerId, u64)]) -> bool {
fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool {
g.uid == uid || (depth < 16 && g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(c) if contains(c, uid, depth + 1))))
}
selected.iter().any(|(l, uid)| *l == layer && contains(g, *uid, 0))
}
/// Draw and drag the selected gradients' start and end points. `l2c` is the viewer's layer→comp
/// matrix (it knows the 3D view). Called after the viewer's own gestures, so a handle takes the
/// press before the layer would move.
pub fn viewer_hook(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &super::viewer::ViewerMap,
ectx: &EvalCtx,
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
) {
if !app.ui.viewer.show_layer_controls {
return;
}
let t = app.tokens;
let ctx = ui.ctx().clone();
let selected_props = app.session.state.selected_props.clone();
let selected_layers = app.session.state.selected_layers.clone();
let mut actions: Vec<(String, serde_json::Value)> = vec![];
for layer in ectx.comp.layers.iter().filter(|l| selected_layers.contains(&l.id) && l.is_active_at(ectx.time)) {
let Some(contents) = layer.props.sub("contents") else { continue };
let mut paints = vec![];
gradient_paints(contents, 0, &mut paints);
if paints.is_empty() {
continue;
}
let m = l2c(ectx, layer);
for g in paints.into_iter().filter(|g| selected_in(g, layer.id, &selected_props)) {
// Gradient (group) space → comp: the enclosing groups' transforms, then the layer's.
let pm = m * effectcraft_engine::render::shapes::item_matrix(ectx, layer, g.uid).unwrap_or(Mat3::IDENTITY);
let pts: Vec<&Property> = ["start", "end"].iter().filter_map(|k| g.get(k)).collect();
let screen: Vec<egui::Pos2> = pts.iter().map(|pr| map.to_screen(fw::layer_to_comp(&pm, ectx.value(layer, pr).as_vec2()))).collect();
if let [a, b] = screen[..] {
painter.line_segment([a, b], Stroke::new(1.0, Color32::from_white_alpha(110)));
}
for (pr, s) in pts.iter().zip(screen) {
let hr = Rect::from_center_size(s, vec2(16.0, 16.0));
let id = egui::Id::new(("viewer-gradient-pt", pr.uid));
let resp = ui.interact(hr, id, Sense::drag());
let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE };
fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160));
fw::draw_crosshair(painter, s, 5.0, col);
app.auto.add(&format!("viewer.gradientPoint.{}", pr.uid), hr, &pr.name);
if resp.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::Move);
resp.clone().on_hover_text(format!("{}: {}", g.name, pr.name));
}
if resp.dragged()
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some(lp) = fw::comp_to_layer(&pm, map.to_comp(pos))
{
let key = super::effect_controls::gesture_key(ui, id, resp.drag_started());
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": [lp[0], lp[1]], "merge": key})));
}
}
}
}
for (id, params) in actions {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
app.ui.status = e;
}
}
}
+1
View File
@@ -18,6 +18,7 @@ pub mod forms;
pub mod fx_editors;
pub mod fx_widgets;
pub mod gradient_editor;
pub mod gradient_handles;
pub mod graph;
pub mod graph_tools;
pub mod home;
+2
View File
@@ -1847,6 +1847,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
// Selected shape gradients' start and end points (#612).
crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
// Status chips (render time / caching).
if app.playback.playing && app.playback.waiting {
+158
View File
@@ -0,0 +1,158 @@
//! #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");
}
+4 -1
View File
@@ -141,7 +141,10 @@ The most important missing piece: it turns every other estimate here into a meas
Location keeps its selection and fields; a layer deleted while editing is reported; and
`prop.editGradient` is an error for a target that isn't a gradient. Unit tests in `keyframe`
and `gradient_editor` cover per-track sampling (no double opacity), the typed one-stop round
trip, and stops staying in order, and selected, while they move.
trip, and stops staying in order, and selected, while they move. The `ui_gradient_handles`
tests, checked headlessly on Windows, drag a selected Gradient Fill's start and end points in
the Composition viewer: one undo step, the layer stays, a point inside a moved and scaled group
converts to the group's space, and the handles show only for a selected gradient.
- 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