From c39f29882c1504da518d9685e32d9d621b94e143 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 11 Oct 2026 11:12:05 -0400 Subject: [PATCH 1/2] Shapes: drag gradient start and end points in the Composition viewer (#612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/gradient_handles.rs | 104 ++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/viewer.rs | 2 + crates/ui-egui/tests/ui_gradient_handles.rs | 158 ++++++++++++++++++ 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/panels/gradient_handles.rs create mode 100644 crates/ui-egui/tests/ui_gradient_handles.rs diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 501adf89..dec55ed9 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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::(kid)).unwrap_or(0) + 1; diff --git a/crates/ui-egui/src/panels/gradient_handles.rs b/crates/ui-egui/src/panels/gradient_handles.rs new file mode 100644 index 00000000..bfe87a47 --- /dev/null +++ b/crates/ui-egui/src/panels/gradient_handles.rs @@ -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.` (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 = 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; + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bc07c258..517b599c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c..6bb899d5 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1818,6 +1818,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 { diff --git a/crates/ui-egui/tests/ui_gradient_handles.rs b/crates/ui-egui/tests/ui_gradient_handles.rs new file mode 100644 index 00000000..f5d9d273 --- /dev/null +++ b/crates/ui-egui/tests/ui_gradient_handles.rs @@ -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 { + 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"); +} From f45f57da7dc32216e905a60f4d14550ac68ff30a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 11 Oct 2026 11:12:15 -0400 Subject: [PATCH 2/2] Docs: gaps.md records the #612 viewer gradient-handle tests --- docs/gaps.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index 1cb88403..6d501309 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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