//! 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)> { 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 { 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, "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 = 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)); } /// 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"); }