Files
effectcraft/crates/ui-egui/tests/ui_gradient_editor.rs
T
tylerjenningsw ec1bc8cc99 Shapes: edit gradient color and opacity stops (#612)
The Timeline's "Edit Gradient…" on a gradient property (Gradient Fill /
Gradient Stroke ▸ Colors, Gradient Overlay) only painted its label. It now
opens a Gradient Editor (prop.editGradient): opacity stops above a preview
bar and colour stops below. Click to add a stop (it takes that track's
own value there), drag or type a Location to move it, set its opacity or
colour (egui's colour picker), Delete removes it (never the last of a
track, never while typing). The tracks stay in order while editing and the
selection follows its stop.

The editor works on a draft. OK sets the property once with prop.set at
the composition time it opened, snapped to the frame, with
timeBase: "comp" (offset and stretched layers get the key where it was
opened): one undo step, and nothing at all when the draft is unchanged, so
OK adds no keyframe. Cancel and Escape change nothing; Escape with the
picker open closes only the picker. On an animated gradient only the
keyframe at that time changes; when its stop counts no longer match a
neighbouring keyframe's (gradients only blend with equal counts) the
editor says so. The draft is saved in the typed form, so one colour stop
and each stop's RGBA survive.

prop.editGradient checks its target is a gradient property before the UI
opens anything, so agents get an error for a wrong one. New Gradient
color_at / opacity_at sample one track each (sample() multiplies opacity
in, which would apply it twice to a new stop).

Tests: ui_gradient_value (fails on main: the value had no automation id),
ui_gradient_editor (11), gradient_editor and keyframe unit tests.
2026-10-10 22:22:13 -04:00

419 lines
21 KiB
Rust

//! The Gradient Editor (#612): the Timeline's "Edit Gradient…" opens it on a gradient property
//! (here a shape layer's Gradient Fill ▸ Colors). OK sets the edited gradient with one undo step
//! at the composition time it opened at (offset and stretched layers too); OK with no change, Cancel
//! and Escape change nothing; on an animated gradient only the keyframe at that time changes, with
//! a warning when its stop counts no longer match a neighbour's.
use effectcraft_engine::Session;
use effectcraft_engine::keyframe::{Gradient, Value};
use effectcraft_engine::project::{LayerId, PropGroup};
use effectcraft_engine::time::Tick;
use effectcraft_ui_egui::panels::gradient_editor::Track;
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
const WARNING: &str = "These adjacent keyframes cannot interpolate because their stop counts differ.";
/// The uids from the layer's root down to the Gradient Fill group, and its Colors property.
fn gradient_fill(g: &PropGroup) -> Option<(Vec<u64>, u64)> {
for x in g.groups() {
if x.match_id == "gfill" {
return Some((vec![x.uid], x.get("colors")?.uid));
}
if let Some((mut path, colors)) = gradient_fill(x) {
path.insert(0, x.uid);
return Some((path, colors));
}
}
None
}
/// A 20 s comp with a rectangle shape layer holding a Gradient Fill, its rows open in the
/// Timeline. `timing` runs on the layer first (start, stretch). Returns (harness, layer, Colors uid).
fn setup(timing: &[(&str, serde_json::Value)]) -> (Harness<'static, EffectcraftApp>, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 20})).unwrap();
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addShapeItem", json!({"layer": l, "kind": "gfill"})).unwrap();
for (cmd, p) in timing {
let mut p = p.clone();
p["layers"] = json!([l]);
s.execute(cmd, p).unwrap();
}
let (groups, colors) = gradient_fill(&s.active_comp().unwrap().layer(LayerId(l)).unwrap().props).expect("a Gradient Fill");
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.state_mut().ui.timeline.open_layers.insert(l);
h.state_mut().ui.timeline.open_groups.extend(groups);
h.run_steps(3);
(h, l, colors)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> [f32; 4] {
h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect
}
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let r = rect(h, id);
pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
/// The point at `f` (0..1) along the bar, at the height of automation id `row`.
fn along(h: &Harness<'_, EffectcraftApp>, row: &str, f: f32) -> Pos2 {
let bar = rect(h, "dialog.gradient.bar");
pos2(bar[0] + bar[2] * f, center(h, row).y)
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = center(h, id);
click_at(h, p);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=6 {
let f = k as f32 / 6.0;
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * f));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn key(h: &mut Harness<'_, EffectcraftApp>, k: Key) {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn colors_prop(h: &Harness<'_, EffectcraftApp>, l: u64, uid: u64) -> effectcraft_engine::project::Property {
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find(uid).unwrap().clone()
}
fn gradient(v: &Value) -> Gradient {
match v {
Value::Gradient(g) => g.clone(),
other => panic!("not a gradient: {other:?}"),
}
}
fn open_editor(h: &mut Harness<'_, EffectcraftApp>, colors: u64) {
let id = format!("timeline.prop.{colors}.value");
assert!(h.state().auto.find(&id).is_some(), "no automation id {id}: the Timeline's gradient value isn't clickable");
click(h, &id);
h.run_steps(2);
}
fn set_time(h: &mut Harness<'_, EffectcraftApp>, t: f64) {
h.state_mut().session.execute("time.set", json!({"time": t})).unwrap();
h.run_steps(2);
}
fn add_key(h: &mut Harness<'_, EffectcraftApp>, l: u64, colors: u64, comp_t: f64, g: &Gradient) {
h.state_mut()
.session
.execute("prop.addKey", json!({"layer": l, "prop": colors, "time": comp_t, "timeBase": "comp", "value": Value::Gradient(g.clone()).to_json()}))
.unwrap();
}
fn stop_color(h: &Harness<'_, EffectcraftApp>, id: u64) -> [f32; 4] {
h.state().gradient_draft().colors.iter().find(|s| s.id == id).expect("the stop").value
}
/// The numbers the editor's fields show (their accessibility values).
fn shown_numbers(h: &Harness<'_, EffectcraftApp>) -> Vec<f64> {
h.query_all_by(|n| n.numeric_value().is_some()).filter_map(|n| n.accesskit_node().numeric_value()).collect()
}
fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize {
h.state().session.history.undo.len()
}
#[test]
fn stop_edits_ok_sets_the_gradient_in_one_undo_step() {
let (mut h, l, colors) = setup(&[]);
let before = colors_prop(&h, l, colors).value.clone();
let undo = undo_len(&h);
open_editor(&mut h, colors);
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor));
// Add a colour stop a quarter along, recolour it, and drag the right opacity stop to the middle.
let p = along(&h, "dialog.gradient.addColor", 0.25);
click_at(&mut h, p);
let (track, id) = h.state().gradient_draft().selected.expect("the new stop is selected");
assert_eq!(track, Track::Color);
let sampled = stop_color(&h, id);
// The swatch opens the colour picker (a new layer over the modal); a click in its
// saturation/value square, below the current-colour strip, picks a new colour.
let layers = h.ctx.memory(|m| m.areas().visible_layer_ids());
click(&mut h, "dialog.gradient.color");
assert!(egui::Popup::is_any_open(&h.ctx), "the colour picker opened");
let popup = h.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| !layers.contains(l)).filter_map(|l| m.area_rect(l.id)).next());
let popup = popup.expect("the picker's layer");
let sp = h.ctx.global_style().spacing.clone();
let p = pos2(popup.min.x + 8.0 + sp.slider_width * 0.8, popup.min.y + 8.0 + sp.interact_size.y + sp.item_spacing.y + sp.slider_width * 0.3);
click_at(&mut h, p);
// Escape closes the picker, not the editor (its edits stay).
key(&mut h, Key::Escape);
assert!(!egui::Popup::is_any_open(&h.ctx), "the picker closed");
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "Escape closed only the picker");
let picked = stop_color(&h, id);
assert_ne!(picked, sampled, "the picker changed the stop's colour");
assert_eq!(picked[3], sampled[3], "and kept its alpha");
let from = center(&h, "dialog.gradient.opacityStop.1");
let to = along(&h, "dialog.gradient.opacityStop.1", 0.5);
drag(&mut h, from, to);
assert_eq!(undo_len(&h), undo, "nothing is set while editing");
click(&mut h, "dialog.gradient.ok");
assert_eq!(h.state().dialog, None);
let g = gradient(&colors_prop(&h, l, colors).value);
assert_eq!(g.colors.len(), 3, "{g:?}");
assert!((g.colors[1].0 - 0.25).abs() < 0.02 && g.colors[1].1 == picked, "{g:?}");
assert!((g.opacities[1].0 - 0.5).abs() < 0.02, "{g:?}");
assert_eq!(undo_len(&h), undo + 1, "one undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(colors_prop(&h, l, colors).value, before, "one undo restores it");
}
#[test]
fn cancel_and_escape_change_nothing() {
for cancel in ["button", "escape"] {
let (mut h, _, colors) = setup(&[]);
let project = h.state().session.project.clone();
let undo = undo_len(&h);
open_editor(&mut h, colors);
let p = along(&h, "dialog.gradient.addOpacity", 0.4);
click_at(&mut h, p);
assert_eq!(h.state().gradient_draft().opacities.len(), 3);
if cancel == "button" {
click(&mut h, "dialog.gradient.cancel");
} else {
key(&mut h, Key::Escape);
}
assert_eq!(h.state().dialog, None, "{cancel}");
assert_eq!(h.state().session.project, project, "{cancel} leaves the project as it was");
assert_eq!(undo_len(&h), undo);
}
}
/// OK without a change adds no keyframe between two (prop.set would add one).
#[test]
fn ok_without_changes_adds_no_keyframe() {
let (mut h, l, colors) = setup(&[]);
add_key(&mut h, l, colors, 1.0, &Gradient::default());
add_key(&mut h, l, colors, 3.0, &Gradient::default());
set_time(&mut h, 2.0);
let undo = undo_len(&h);
open_editor(&mut h, colors);
click(&mut h, "dialog.gradient.ok");
assert_eq!(h.state().dialog, None);
assert_eq!(colors_prop(&h, l, colors).keys.len(), 2, "still the two keyframes");
assert_eq!(undo_len(&h), undo, "no undo step");
}
/// On an animated gradient OK sets the keyframe at the edit time (adding one between keys) and no
/// other.
#[test]
fn animated_edits_change_only_the_keyframe_at_the_edit_time() {
let (mut h, l, colors) = setup(&[]);
let red = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], ..Gradient::default() };
add_key(&mut h, l, colors, 1.0, &Gradient::default());
add_key(&mut h, l, colors, 3.0, &red);
let first = colors_prop(&h, l, colors).keys[0].clone();
for (t, keys) in [(3.0, 2), (2.0, 3)] {
set_time(&mut h, t);
open_editor(&mut h, colors);
let d = &h.state().gradient_draft();
let id = d.id_at(Track::Color, 1).unwrap();
h.state_mut().gradient_draft_mut().set_rgb(id, [0.0, 0.0, 1.0]);
click(&mut h, "dialog.gradient.ok");
let pr = colors_prop(&h, l, colors);
assert_eq!(pr.keys.len(), keys, "at {t} s");
assert_eq!(pr.keys[0], first, "the keyframe at 1 s is untouched");
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
// The edit time snapped to the nearest frame (29.97 fps), as prop.set snaps it.
let at = h.state().session.active_comp().unwrap().frame_rate.snap_nearest(Tick::from_seconds_f64(t)).seconds();
let k = pr.keys.iter().find(|k| (layer.comp_time(k.time).seconds() - at).abs() < 1e-6).expect("a keyframe at the edit time");
assert_eq!(gradient(&k.value).colors[1].1, [0.0, 0.0, 1.0, 1.0], "at {t} s");
}
}
/// The key goes at the composition time the editor opened at, on a layer that starts late or is
/// stretched (layer time differs from composition time).
#[test]
fn offset_and_stretched_layers_get_the_keyframe_at_the_comp_time() {
for timing in
[vec![("layer.timing", json!({"start": 5.0}))], vec![("layer.timing", json!({"start": 5.0})), ("layer.timeStretch", json!({"percent": 200.0}))]]
{
let (mut h, l, colors) = setup(&timing);
set_time(&mut h, 5.5);
h.state_mut().session.execute("prop.toggleAnimation", json!({"layer": l, "prop": colors})).unwrap();
set_time(&mut h, 6.0);
open_editor(&mut h, colors);
let p = along(&h, "dialog.gradient.addColor", 0.5);
click_at(&mut h, p);
click(&mut h, "dialog.gradient.ok");
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let pr = layer.props.find(colors).unwrap();
let comp_times: Vec<f64> = pr.keys.iter().map(|k| layer.comp_time(k.time).seconds()).collect();
assert_eq!(pr.keys.len(), 2, "{timing:?}: {comp_times:?}");
// Composition time 6 s snapped to the nearest frame (29.97 fps: 6.006 s), as prop.set snaps
// it: the layer's start and stretch don't move it.
let at = h.state().session.active_comp().unwrap().frame_rate.snap_nearest(Tick::from_seconds_f64(6.0)).seconds();
let k = pr.keys.iter().find(|k| (layer.comp_time(k.time).seconds() - at).abs() < 1e-6);
let k = k.unwrap_or_else(|| panic!("{timing:?}: no keyframe at comp time {at} s: {comp_times:?}"));
assert_eq!(gradient(&k.value).colors.len(), 3, "{timing:?}");
}
}
/// Adding a stop next to keyframes with fewer stops warns that they can't interpolate; it still
/// saves, the neighbour keeps its stops, and the animation holds until the edited keyframe.
#[test]
fn mismatched_stop_counts_warn_and_neighbours_stay() {
let (mut h, l, colors) = setup(&[]);
add_key(&mut h, l, colors, 1.0, &Gradient::default());
add_key(&mut h, l, colors, 3.0, &Gradient::default());
set_time(&mut h, 3.0);
open_editor(&mut h, colors);
assert!(h.query_by_label(WARNING).is_none(), "same counts: no warning");
let p = along(&h, "dialog.gradient.addColor", 0.5);
click_at(&mut h, p);
assert!(h.query_by_label(WARNING).is_some(), "three colour stops next to a keyframe with two");
click(&mut h, "dialog.gradient.ok");
let pr = colors_prop(&h, l, colors);
assert_eq!(pr.keys.len(), 2);
assert_eq!(gradient(&pr.keys[0].value), Gradient::default(), "the neighbour keeps its stops");
assert_eq!(gradient(&pr.keys[1].value).colors.len(), 3);
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let mid = pr.value_at(layer.layer_time(Tick::from_seconds_f64(2.0)));
assert_eq!(gradient(&mid), Gradient::default(), "it holds the first keyframe instead of blending");
}
/// Delete removes the selected stop (never a layer, and never the last stop of a track), but not
/// while typing in a field.
#[test]
fn delete_removes_the_selected_stop_but_not_the_last_or_while_typing() {
let (mut h, _, colors) = setup(&[]);
let layers = h.state().session.active_comp().unwrap().layers.len();
open_editor(&mut h, colors);
click(&mut h, "dialog.gradient.colorStop.0");
key(&mut h, Key::Delete);
assert_eq!(h.state().gradient_draft().colors.len(), 1, "Delete removed the selected stop");
click(&mut h, "dialog.gradient.colorStop.0");
key(&mut h, Key::Delete);
click(&mut h, "dialog.gradient.delete");
assert_eq!(h.state().gradient_draft().colors.len(), 1, "the last colour stop stays");
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), layers, "Delete never reaches the layer");
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor));
// Typing in Location: Delete edits the text, not the stops.
click(&mut h, "dialog.gradient.opacityStop.0");
click(&mut h, "dialog.gradient.location");
assert!(h.ctx.egui_wants_keyboard_input(), "the Location field takes the keyboard");
key(&mut h, Key::Delete);
assert_eq!(h.state().gradient_draft().opacities.len(), 2, "typing doesn't delete a stop");
}
/// One colour stop saves (the typed form; plain JSON needs two) and one undo brings both back.
#[test]
fn a_single_colour_stop_saves_and_undoes() {
let (mut h, l, colors) = setup(&[]);
let before = colors_prop(&h, l, colors).value.clone();
let undo = undo_len(&h);
open_editor(&mut h, colors);
click(&mut h, "dialog.gradient.colorStop.1");
click(&mut h, "dialog.gradient.delete");
assert_eq!(h.state().gradient_draft().colors.len(), 1);
click(&mut h, "dialog.gradient.ok");
let g = gradient(&colors_prop(&h, l, colors).value);
assert_eq!(g.colors, gradient(&before).colors[..1].to_vec(), "{g:?}");
assert_eq!(g.opacities, gradient(&before).opacities, "opacity stops unchanged");
assert_eq!(undo_len(&h), undo + 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(colors_prop(&h, l, colors).value, before);
}
/// Typing a Location that moves a stop past its neighbour keeps the selection on that stop, and
/// in the same frame the Opacity field shows its value, not the neighbour's (now at its old index).
#[test]
fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() {
let (mut h, _, colors) = setup(&[]);
open_editor(&mut h, colors);
// Opacity 100% at 0 and 40% at 1, plus a stop at 0.5 sampled from them: 70%.
let right = h.state().gradient_draft().opacities[1].id;
h.state_mut().gradient_draft_mut().set_opacity(right, 0.4);
let p = along(&h, "dialog.gradient.addOpacity", 0.5);
click_at(&mut h, p);
let middle = h.state().gradient_draft().selected.expect("the new stop").1;
let i = h.state().gradient_draft().index_of(Track::Opacity, middle).unwrap();
assert!((h.state().gradient_draft().opacities[i].value - 0.7).abs() < 0.02, "it sampled 70%");
// Select the 100% stop at 0 and type 80 into Location: it passes the stop at 0.5.
let first = h.state().gradient_draft().opacities[0].id;
click(&mut h, "dialog.gradient.opacityStop.0");
click(&mut h, "dialog.gradient.location");
assert!(h.ctx.egui_wants_keyboard_input(), "the Location field is being typed in");
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
h.input_mut().events.push(Event::Text("80".into()));
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
let d = h.state().gradient_draft();
assert_eq!(d.selected, Some((Track::Opacity, first)), "the moved stop stays selected");
assert_eq!(d.index_of(Track::Opacity, first), Some(1), "it is now after the stop at 0.5");
// The frame that moved it: Opacity already shows this stop's 100%, never the neighbour's 70%
// that is now at its old index. (Location itself was drawn before the move: 0 until the next.)
let shown = shown_numbers(&h);
assert!(shown.contains(&100.0), "this stop's 100%: {shown:?}");
assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "never the neighbour's 70%: {shown:?}");
h.run_steps(2);
let shown = shown_numbers(&h);
assert!(shown.contains(&80.0) && shown.contains(&100.0), "Location 80 and Opacity 100%: {shown:?}");
assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}");
}
/// A layer deleted while the editor is open: OK reports it, without a crash.
#[test]
fn a_layer_deleted_while_editing_is_reported() {
let (mut h, l, colors) = setup(&[]);
open_editor(&mut h, colors);
let p = along(&h, "dialog.gradient.addColor", 0.5);
click_at(&mut h, p);
h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap();
h.run_steps(2);
click(&mut h, "dialog.gradient.ok");
assert_eq!(h.state().dialog, None);
assert!(!h.state().ui.status.is_empty(), "the failed set is reported");
}
/// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that
/// isn't a gradient property, from the engine and through the UI, and opens nothing.
#[test]
fn edit_gradient_by_id_rejects_bad_targets() {
let (mut h, l, colors) = setup(&[]);
let ctx = h.ctx.clone();
let opacity = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid;
for (p, err) in [(json!({"layer": 9999, "prop": colors}), "no layer"), (json!({"layer": l, "prop": opacity}), "not a gradient")] {
let e = h.state_mut().session.execute_checked("prop.editGradient", p.clone()).expect_err("the engine refuses it");
assert!(e.to_string().contains(err), "{p}: {e}");
let e = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "prop.editGradient", p.clone()).expect_err("so does the UI");
assert!(e.contains(err), "{p}: {e}");
h.run_steps(2);
assert_eq!(h.state().dialog, None, "{p}");
}
let r = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "prop.editGradient", json!({"layer": l, "prop": colors}));
assert!(r.is_ok(), "{r:?}");
h.run_steps(2);
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor));
}