diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bc26c705..6fe44659 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 90349cf3..cc7910b3 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 eeb2c03c..cffa2bb6 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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 { 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"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 66edfb47..eb3a8c34 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