Files
effectcraft/crates/ui-egui/tests/it/ui_gradient_editor.rs
T
Claude 89f33415bb #1436: take a preview back without taking other edits with it; sync the draft before OK keeps a preview
Cancel and Preview-off merge the checkpoint, the preview's result and the project now (new
preview_merge module), so an edit made while the dialog was open stays. OK first brings the
preview up to the draft it finds, and Composition Settings OK applies the Resolution after a kept
preview. The Gradient Editor's OK syncs and sets the draft when it differs from the last preview.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U
2026-10-29 12:05:43 +00:00

589 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::accesskit::Role;
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 near(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
}
/// The shared colour picker is showing (its colour field is in the accessibility tree).
fn picker_open(h: &Harness<'_, EffectcraftApp>) -> bool {
h.query_by_label("Color field").is_some()
}
/// Type `hex` into the open picker's Hex field, over the code it shows.
fn type_hex(h: &mut Harness<'_, EffectcraftApp>, hex: &str) {
let at = h.get_by_role_and_label(Role::TextInput, "#").rect().center();
click_at(h, at);
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND });
}
h.step();
h.input_mut().events.push(Event::Text(hex.into()));
h.run_steps(3);
}
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 shared colour picker over the modal editor. A click in its colour field
// reaches it (a modal blocks the layers below it), and so does typing a hex code.
click(&mut h, "dialog.gradient.color");
assert!(picker_open(&h), "the colour picker opened");
let field = h.get_by_label("Color field").rect().center();
click_at(&mut h, field + egui::vec2(20.0, -20.0));
assert_ne!(stop_color(&h, id), sampled, "a click in the picker's colour field changed the stop");
type_hex(&mut h, "336699");
let picked = stop_color(&h, id);
assert!(near(picked, [0.2, 0.4, 0.6, sampled[3]]), "the hex code's colour, and the stop kept its alpha: {picked:?}");
// Clicking outside the picker keeps the colour and closes it; the editor stays.
let title = h.get_by_label("Gradient Editor").rect().center();
click_at(&mut h, title);
assert!(!picker_open(&h), "the picker closed");
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor));
assert_eq!(stop_color(&h, id), picked);
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 + 1, "the live preview is one merged undo step while editing");
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the property shows the edit before OK");
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, Preview off: OK reports the failed set, without a
/// crash.
#[test]
fn a_layer_deleted_while_editing_is_reported() {
let (mut h, l, colors) = setup(&[]);
open_editor(&mut h, colors);
click(&mut h, "dialog.gradient.preview");
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");
}
/// The same with Preview on: the preview's set fails at the next edit and says so, and OK closes
/// the editor without a crash.
#[test]
fn a_layer_deleted_during_the_preview_is_reported_at_the_next_edit() {
let (mut h, l, colors) = setup(&[]);
open_editor(&mut h, colors);
h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap();
h.run_steps(2);
let p = along(&h, "dialog.gradient.addColor", 0.5);
click_at(&mut h, p);
assert!(!h.state().ui.status.is_empty(), "the failed preview set is reported");
click(&mut h, "dialog.gradient.ok");
assert_eq!(h.state().dialog, None);
}
/// Preview (on by default, as in After Effects): edits reach the property as they are made, in one
/// undo step, and Cancel and Escape roll them back to how the editor opened.
#[test]
fn the_preview_applies_edits_live_and_cancel_rolls_them_back() {
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!(h.state().gradient_draft().preview, "Preview starts on");
let p = along(&h, "dialog.gradient.addColor", 0.25);
click_at(&mut h, p);
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the new stop shows at once");
let p = along(&h, "dialog.gradient.addColor", 0.75);
click_at(&mut h, p);
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4);
assert_eq!(undo_len(&h), undo + 1, "both edits are one undo step");
click(&mut h, "dialog.gradient.cancel");
assert_eq!(h.state().dialog, None);
assert_eq!(colors_prop(&h, l, colors).value, before, "Cancel puts the gradient back");
assert_eq!(undo_len(&h), undo, "and leaves no undo step");
// Escape does the same.
open_editor(&mut h, colors);
let p = along(&h, "dialog.gradient.addColor", 0.5);
click_at(&mut h, p);
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3);
key(&mut h, Key::Escape);
assert_eq!(h.state().dialog, None);
assert_eq!(colors_prop(&h, l, colors).value, before);
assert_eq!(undo_len(&h), undo);
}
/// An edit made in the same frame as OK is not in the last preview: OK sets the draft it finds.
#[test]
fn ok_in_the_frame_of_an_edit_sets_the_draft_not_the_older_preview() {
let (mut h, l, colors) = setup(&[]);
open_editor(&mut h, colors);
let p = along(&h, "dialog.gradient.addColor", 0.25);
click_at(&mut h, p);
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the preview has three stops");
h.state_mut().gradient_draft_mut().add(Track::Color, 0.75);
let ctx = h.ctx.clone();
effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), true, &ctx);
assert_eq!(h.state().dialog, None);
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft it found");
}
/// With Preview off nothing is set until OK, and switching it off takes back what it applied.
#[test]
fn with_preview_off_nothing_is_set_until_ok() {
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);
let p = along(&h, "dialog.gradient.addColor", 0.25);
click_at(&mut h, p);
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "previewed");
click(&mut h, "dialog.gradient.preview");
assert!(!h.state().gradient_draft().preview);
assert_eq!(colors_prop(&h, l, colors).value, before, "switching Preview off takes the preview back");
assert_eq!(undo_len(&h), undo);
let p = along(&h, "dialog.gradient.addColor", 0.75);
click_at(&mut h, p);
assert_eq!(colors_prop(&h, l, colors).value, before, "nothing is set while editing");
click(&mut h, "dialog.gradient.ok");
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft");
assert_eq!(undo_len(&h), undo + 1, "in one undo step");
}
/// The editor opens at the lower right of the window, so the canvas it edits stays in view.
#[test]
fn the_editor_opens_at_the_lower_right() {
let (mut h, _, colors) = setup(&[]);
open_editor(&mut h, colors);
let ok = center(&h, "dialog.gradient.ok");
assert!(ok.x > 800.0 && ok.y > 500.0, "the editor's OK button is in the lower right of the 1600 × 1000 window: {ok:?}");
}
/// `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));
}
/// In the editor, the colour picker's Escape and Cancel go back to the stop's colour and close only
/// the picker: the editor and its other edits stay (dialogs leave Escape to an open picker).
#[test]
fn picker_escape_and_cancel_restore_the_stop_and_keep_the_editor() {
let (mut h, _, colors) = setup(&[]);
open_editor(&mut h, colors);
let added = along(&h, "dialog.gradient.addOpacity", 0.3);
click_at(&mut h, added);
click(&mut h, "dialog.gradient.colorStop.0");
let id = h.state().gradient_draft().selected.expect("a selected stop").1;
let original = stop_color(&h, id);
for how in ["escape", "cancel"] {
click(&mut h, "dialog.gradient.color");
assert!(picker_open(&h), "{how}: the picker opened");
type_hex(&mut h, "ff8000");
assert!(near(stop_color(&h, id), [1.0, 128.0 / 255.0, 0.0, original[3]]), "{how}: the picker previews as it goes");
if how == "escape" {
key(&mut h, Key::Escape);
} else {
// The picker's Cancel, not the editor's.
let editor_cancel = rect(&h, "dialog.gradient.cancel");
let cancel =
h.query_all_by_label("Cancel").map(|n| n.rect()).find(|r| (r.min.x - editor_cancel[0]).abs() > 1.0 || (r.min.y - editor_cancel[1]).abs() > 1.0);
click_at(&mut h, cancel.expect("the picker's Cancel").center());
}
assert!(!picker_open(&h), "{how}: the picker closed");
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "{how}: the editor stays open");
assert_eq!(stop_color(&h, id), original, "{how}: the stop's colour is back");
assert_eq!(h.state().gradient_draft().opacities.len(), 3, "{how}: the editor's other edits stay");
}
}
/// The Escape that closes the picker is its own: the next Escape, even in the very next pass,
/// cancels the editor (a closed picker doesn't keep the dialog from it).
#[test]
fn the_escape_after_the_picker_closes_cancels_the_editor() {
let (mut h, _, colors) = setup(&[]);
let project = h.state().session.project.clone();
open_editor(&mut h, colors);
click(&mut h, "dialog.gradient.colorStop.0");
click(&mut h, "dialog.gradient.color");
assert!(picker_open(&h), "the picker opened");
let escape = |h: &mut Harness<'_, EffectcraftApp>| {
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.step();
};
escape(&mut h);
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "the first Escape closed only the picker");
escape(&mut h);
assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor");
assert_eq!(h.state().session.project, project, "and changes nothing");
}