diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 0af8f929b..f6d590e8c 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -327,7 +327,9 @@ impl EffectcraftApp { ctx.request_repaint(); return; } + panels::crypto_pick::pass_start(self, &ctx); self.frame(ui); + panels::crypto_pick::upkeep(self, &ctx); #[cfg(not(target_arch = "wasm32"))] pointer_wrap::finish_native(&ctx, frame); // wasm32: no frame threads; render queued frames now, between UI frames. diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 665922fcf..5a5a4e712 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -1,7 +1,7 @@ use super::common::{Actions, DIAL_ROW, PARAM_NAME_X, ROW, TREE_INDENT, gesture_key}; use super::{ - Align2, Color32, EffectcraftApp, EvalCtx, FxPick, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw, icons, json, - pos2, vec2, widgets, + Align2, Color32, EffectcraftApp, EvalCtx, FxPick, GroupKind, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw, + icons, json, pos2, vec2, widgets, }; fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { @@ -65,6 +65,9 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p app.ui.text_color_pick = None; app.ui.status = if kind == "point" { format!("Click in the Composition panel to set {}", prop.name) + } else if kind == crate::panels::crypto_pick::KIND { + let keys = crate::panels::crypto_pick::remove_keys(egui::os::OperatingSystem::from_target_os()); + crate::i18n::tr_args("Click IDs in the Composition panel: Shift adds, {} removes, Esc ends", &[&keys]) } else { format!("Click in the Composition panel to sample {}", prop.name) }; @@ -485,11 +488,27 @@ fn value_text(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: & let Value::Str(s) = value else { return; }; - let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx; - let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); + let PropCx { t, uid, cy, vx, r, layer, group, prop, .. } = *cx; + // Cryptomatte's Selection also has an eyedropper that picks IDs in the viewer (#473). + let crypto = matches!(&group.kind, GroupKind::Effect { effect } if effect == "ec.3d.cryptomatte") && prop.match_id == "selection"; + let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - if crypto { 36.0 } else { 12.0 }).clamp(80.0, 320.0), 18.0)); + if crypto { + let er = Rect::from_center_size(pos2(fr.max.x + 14.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == crate::panels::crypto_pick::KIND); + if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t) + .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) + .clicked() + { + toggle_pick(app, crate::panels::crypto_pick::KIND, layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); + } let bid = egui::Id::unique(("ec-text", uid)); let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.to_owned()); let resp = widgets::text_field(ui, fr, &mut buf, "", &t); + // Cut off by a narrow panel (a long Selection): the whole text on hover. + let cut = !resp.has_focus() && ui.painter().layout_no_wrap(buf.clone(), egui::TextStyle::Body.resolve(ui.style()), t.text).size().x > fr.width() - 17.0; + let resp = if cut { resp.on_hover_text(buf.clone()) } else { resp }; // The field has no visible hint: give the screen reader the parameter's name. resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name)); app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); diff --git a/crates/ui-egui/src/panels/effect_controls/viewer.rs b/crates/ui-egui/src/panels/effect_controls/viewer.rs index 6f59d8c96..6d4eb7ffd 100644 --- a/crates/ui-egui/src/panels/effect_controls/viewer.rs +++ b/crates/ui-egui/src/panels/effect_controls/viewer.rs @@ -221,9 +221,19 @@ pub fn viewer_hook( let ctx = ui.ctx().clone(); crate::color_picker::note_viewer(&ctx, map.area); let mut actions: Actions = vec![]; - let point_preview = match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) { - ControlFlow::Break(()) => return, - ControlFlow::Continue(p) => p, + let point_preview = if let Some(pick) = app.ui.fx_pick.clone().filter(|p| p.kind == crate::panels::crypto_pick::KIND) { + match ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)) { + Some(l) => { + crate::panels::crypto_pick::viewer_hook(app, ui, painter, map, ectx, l, &crate::panels::viewer::from_effect_space(ectx, l, l2c(ectx, l)), &pick) + } + None => app.ui.fx_pick = None, + } + None + } else { + match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) { + ControlFlow::Break(()) => return, + ControlFlow::Continue(p) => p, + } }; text_color_pick(app, ui, painter, map, ectx, &mut actions); popup_eyedropper(app, ui, painter, map); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9769b879c..20a7856d5 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -830,9 +830,9 @@ fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m // Interaction. let resp = ui.interact(area, egui::Id::unique("viewer-interact"), Sense::click_and_drag()); let menu_hits_id = egui::Id::unique(("viewer-menu-hits", cid.0)); - if resp.secondary_clicked() - && let Some(pos) = resp.interact_pointer_pos() - { + // A right-click an overlay took (the Cryptomatte eyedropper's) opens the menu too. + let forwarded = ui.data_mut(|d| d.remove_temp::(egui::Id::unique(FORWARDED_SECONDARY))); + if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.secondary_clicked()).or(forwarded) { // Picking an already selected layer preserves the group; an unselected hit // becomes the target, using the same visible/unlocked hit test as left-click. pick(app, ectx, map.to_comp(pos), false); @@ -844,7 +844,9 @@ fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect(); let mut context_actions = Vec::new(); let copy_frame = crate::i18n::label(app, "comp.copyFrame", "Copy Frame to Clipboard"); - crate::widgets::context_menu(&resp, |ui| { + let menu = egui::Popup::context_menu(&resp).style(crate::widgets::menu_style); + let open = forwarded.map(|_| egui::containers::SetOpenCommand::Bool(true)); + if open.is_some() { menu.open_memory(open) } else { menu }.show(|ui| { let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); if !hits.is_empty() { ui.menu_button(crate::i18n::tr("Select"), |ui| { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 2de7ebee7..01fe565c3 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -23,6 +23,8 @@ mod ui_color_hex; mod ui_comp_presets; mod ui_contrast; mod ui_control_modifiers; +mod ui_crypto_pick; +mod ui_crypto_pick_undo; mod ui_dialog_preview; mod ui_dock_resize; mod ui_drag_drop; diff --git a/crates/ui-egui/tests/it/ui_crypto_pick.rs b/crates/ui-egui/tests/it/ui_crypto_pick.rs new file mode 100644 index 000000000..f3a01aea3 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_crypto_pick.rs @@ -0,0 +1,1878 @@ +//! The Cryptomatte eyedropper (#473): the Selection's button arms it, the viewer names (and +//! highlights) the ID under the cursor, and clicks pick into the Selection: plain replaces, Shift +//! adds, Alt or Ctrl removes. It stays armed until Esc or the button. Checked over window and +//! panel sizes, display scales, viewer zoom, pan and resolution, proxies, overlapping popups, and +//! the asynchronous probe (hover and clicks before it is built, edits while it builds), playback +//! and scrubbing, navigation tools, errors and disarming. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value as KV; +use effectcraft_engine::project::{LayerId, Resolution}; +use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash}; +use effectcraft_ui_egui::dock::{self, PanelKind, SplitSize}; +use effectcraft_ui_egui::panels::viewer::comp_to_screen; +use effectcraft_ui_egui::state::Tool; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +const READING: &str = "Reading Cryptomatte IDs…"; +const HINT: &str = "Click: replace · Shift: add · Alt or Ctrl: remove"; +const MAC_HINT: &str = "Click: replace · Shift: add · Option or Cmd: remove"; +const PAUSED: &str = "Stop playback to pick"; + +/// The ID of the 37 × 29 px cell at pixel (x, y) of a full-size frame. +fn cell(x: usize, y: usize) -> String { + format!("c{}_{}", x / 37, y / 29) +} + +/// A `w` × `h` EXR (white RGBA) whose CryptoObject has an ID per cell of a `k`-times larger frame +/// (`k` = 2: a half-size proxy), all named in its manifest. +fn crypto_exr(dir: &std::path::Path, name: &str, w: usize, h: usize, k: usize) -> String { + use exr::prelude::*; + let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(&cell(i % w * k, i / w * k)))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let one = || vec![1.0f32; w * h]; + let channels = AnyChannels::sort( + vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(), + ); + let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let names: serde_json::Map = (0..(w * k).div_ceil(37)) + .flat_map(|x| (0..(h * k).div_ceil(29)).map(move |y| cell(x * 37, y * 29))) + .map(|n| (n.clone(), json!(format!("{:08x}", crypto_hash(&n))))) + .collect(); + for (key, v) in [("name", "CryptoObject".to_string()), ("manifest", serde_json::Value::Object(names).to_string())] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{key}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + let path = dir.join(name); + Image::from_layer(layer).write().to_file(&path).unwrap(); + path.to_string_lossy().to_string() +} + +struct Ids { + layer: u64, + sel: u64, + footage: u64, + dir: std::path::PathBuf, +} + +/// A `w` × `h` comp with the EXR (and its half-size proxy file) under a Cryptomatte effect (after +/// a heavy blur with `blur`: its probe then takes a while to build). +fn crypto_session(tag: &str, w: usize, h: usize, blur: bool) -> (Session, Ids) { + let dir = std::env::temp_dir().join(format!("ec-ui-crypick-{tag}-{}", std::process::id())); + std::fs::create_dir_all(dir.join("proxy")).unwrap(); + let path = crypto_exr(&dir, "plate.exr", w, h, 1); + crypto_exr(&dir.join("proxy"), "plate_proxy.exr", w.div_ceil(2), h.div_ceil(2), 2); + let mut s = effectcraft_host::session(); + let r = s.execute("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let footage = r["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Crypto", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap(); + let layer = s.execute("layer.addItem", json!({"item": footage})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(layer)]; + if blur { + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + s.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": 40.0})).unwrap(); + } + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().last().unwrap(); + let sel = fx.get("selection").unwrap().uid; + (s, Ids { layer, sel, footage, dir }) +} + +/// The app with the Composition viewer and Effect Controls (`ec` points wide) side by side. +fn harness(s: Session, size: egui::Vec2, ppp: f32, ec: f32) -> Harness<'static, EffectcraftApp> { + harness_dt(s, size, ppp, ec, 0.25) +} + +/// [`harness`] whose steps advance the clock by `dt` seconds. +fn harness_dt(s: Session, size: egui::Vec2, ppp: f32, ec: f32, dt: f32) -> Harness<'static, EffectcraftApp> { + let app = app(s, ec); + let mut h = Harness::builder().with_size(size).with_pixels_per_point(ppp).with_step_dt(dt).build_eframe(|_| app); + settle(&mut h); + h +} + +fn app(s: Session, ec: f32) -> EffectcraftApp { + let mut app = EffectcraftApp::new(s); + app.ui.viewer.show_layer_controls = false; + app.ui.dock = dock::docked(dock::vsplit( + SplitSize::FixedSecond(160.0), + dock::hsplit(SplitSize::FixedSecond(ec), dock::tabs(&[PanelKind::Composition], 0), dock::tabs(&[PanelKind::EffectControls], 0)), + dock::tabs(&[PanelKind::Timeline], 0), + )); + app +} + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..1500 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().is_ok_and(|v| *v > 0.0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(4); +} + +fn chip(h: &Harness<'_, EffectcraftApp>) -> Option { + h.state().auto.find("viewer.cryptoPick").map(|e| e.label.clone()) +} + +/// Step until the chip stops saying the probe is being built (the job runs on its own thread). +fn wait_probe(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..3000 { + h.step(); + if chip(h).as_deref() != Some(READING) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the probe never finished"); +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) -> String { + h.event(Event::PointerMoved(pos)); + h.step(); + wait_probe(h); + chip(h).unwrap_or_default() +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2, modifiers: Modifiers) { + h.event(Event::PointerMoved(pos)); + h.event(Event::ModifiersChanged(modifiers)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + wait_probe(h); + h.run_steps(2); +} + +fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5) +} + +fn selection(h: &Harness<'_, EffectcraftApp>, ids: &Ids) -> String { + let s = &h.state().session; + match &s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.find(ids.sel).unwrap().value { + KV::Str(t) => t.clone(), + v => panic!("{v:?}"), + } +} + +fn arm(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids) { + let eye = at(h, &format!("effectControls.prop.{}.eyedropper", ids.sel)); + click(h, eye, Modifiers::NONE); + assert!(h.state().ui.fx_pick.as_ref().is_some_and(|p| p.kind == "crypto" && p.prop == ids.sel), "armed"); +} + +/// The resolution of the frame the viewer shows: every n-th pixel (its key keeps thousandths). +fn shown_scale(h: &Harness<'_, EffectcraftApp>) -> f64 { + let s = &h.state().session; + 1.0 / (1000.0 / h.state().shown_series(s.active_comp_id().unwrap()).scale as f64).round() +} + +/// The comp point the viewer maps screen point `p` to (its own arithmetic). +fn to_comp(h: &Harness<'_, EffectcraftApp>, p: Pos2) -> [f64; 2] { + let (o, k) = (comp_to_screen(&h.ctx, [0.0, 0.0]).unwrap(), comp_to_screen(&h.ctx, [1000.0, 1000.0]).unwrap()); + let z = (k - o) / 1000.0; + [((p.x - o.x) / z.x) as f64, ((p.y - o.y) / z.y) as f64] +} + +/// The rendered matte of ID `name` (as the viewer renders the frame at `scale`) at comp point `p`. +/// A downsampled proxy blends cells at their edges, so an edge pixel can be a mix of IDs. +fn lit(h: &mut Harness<'_, EffectcraftApp>, name: &str, p: [f64; 2], scale: f64) -> f32 { + let s = &mut h.state_mut().session; + s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": name})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let opts = effectcraft_engine::render::RenderOpts { roi: None, guides: false, ..h.state().frame_opts(cid, scale) }; + let img = h.state().session.render(cid, effectcraft_engine::time::Tick::ZERO, opts); + img.get((p[0] * scale).floor() as i64, (p[1] * scale).floor() as i64)[0] +} + +/// Whether ID `got` (a cell name) has the largest coverage of the shown pixel at comp point `p` +/// (a downsampled or scaled layer blends the cells around it). +fn largest(h: &mut Harness<'_, EffectcraftApp>, got: &str, p: [f64; 2], scale: f64) -> bool { + let v = lit(h, got, p, scale); + if v >= 0.5 { + return true; + } + let (cx, cy) = ((p[0] / 37.0).floor() as i64, (p[1] / 29.0).floor() as i64); + let others: Vec = + (-2..=2).flat_map(|dx| (-2..=2).map(move |dy| (cx + dx, cy + dy))).filter(|c| c.0 >= 0 && c.1 >= 0).map(|c| format!("c{}_{}", c.0, c.1)).collect(); + v > 0.0 && others.iter().filter(|o| *o != got).all(|o| lit(h, o, p, scale) <= v + 1e-4) +} + +#[test] +fn eyedropper_arms_hovers_picks_with_modifiers_and_stays_armed_until_esc() { + let (s, ids) = crypto_session("flow", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + let steps = h.state().session.history.undo.len(); + arm(&mut h, &ids); + assert!(h.state().ui.status.contains("Shift adds"), "{}", h.state().ui.status); + let hint = comp_to_screen(&h.ctx, [-3.0, 90.0]).unwrap(); + assert_eq!(hover(&mut h, hint), HINT, "off the layer: the hint"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let b = comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + assert_eq!(hover(&mut h, b), "c3_3"); + click(&mut h, a, Modifiers::NONE); + assert_eq!(selection(&h, &ids), "c1_1"); + assert!(h.state().ui.status.contains("c1_1"), "{}", h.state().ui.status); + assert_eq!(hover(&mut h, a), "c1_1 (selected)"); + click(&mut h, b, Modifiers::SHIFT); + assert_eq!((selection(&h, &ids), pick_steps(&h)), ("c1_1, c3_3".to_string(), 2)); + click(&mut h, a, Modifiers::ALT); + assert_eq!(selection(&h, &ids), "c3_3"); + click(&mut h, b, Modifiers::COMMAND); + assert_eq!(selection(&h, &ids), ""); + // The modifiers of the press count: Shift let go before the button still adds. + click(&mut h, a, Modifiers::NONE); + h.event(Event::PointerMoved(b)); + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.step(); + h.event(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + h.event(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(selection(&h, &ids), "c1_1, c3_3", "Shift held at the press adds"); + // On a Mac the hint says Option or Cmd, and Ctrl (no Cmd there) removes too. + h.ctx.set_os(egui::os::OperatingSystem::Mac); + assert_eq!(hover(&mut h, hint), MAC_HINT); + click(&mut h, a, Modifiers::CTRL); + assert_eq!(selection(&h, &ids), "c3_3", "Ctrl on a Mac"); + h.ctx.set_os(egui::os::OperatingSystem::Nix); + click(&mut h, b, Modifiers::ALT); + assert_eq!(selection(&h, &ids), ""); + assert_eq!(h.state().session.history.undo.len(), steps + 8, "one undo step per pick"); + // The pointer leaves the viewer: the hint again. + let away = at(&h, &format!("effectControls.prop.{}.eyedropper", ids.sel)) + vec2(0.0, 60.0); + h.event(Event::PointerMoved(away)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(HINT), "hover ends with the pointer away"); + // Outside the layer: nothing to pick, no step; still armed. + let off = comp_to_screen(&h.ctx, [-30.0, 40.0]).unwrap(); + if h.state().auto.find("viewer.fxPick").is_some_and(|e| off.x > e.rect[0]) { + click(&mut h, off, Modifiers::NONE); + assert_eq!(h.state().session.history.undo.len(), steps + 8); + } + assert!(h.state().ui.fx_pick.is_some(), "stays armed"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none(), "Esc ends it"); + assert!(chip(&h).is_none()); + // The button toggles it too. + arm(&mut h, &ids); + let eye = at(&h, &format!("effectControls.prop.{}.eyedropper", ids.sel)); + click(&mut h, eye, Modifiers::NONE); + assert!(h.state().ui.fx_pick.is_none()); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Window and panel sizes × display scales × viewer zoom / pan × Resolution (and a proxy): hover +/// and click name the ID of the pixel the viewer shows, next to cell edges too. Each hover is +/// checked against the rendered matte of the named ID at the point the screen maps back to. +#[test] +fn picks_what_the_viewer_shows_across_sizes_scales_zoom_pan_resolution_and_proxies() { + let mut checked = 0; + let mut failures = vec![]; + let cases = [(vec2(900.0, 640.0), 1.0, 300.0), (vec2(1280.0, 720.0), 1.25, 420.0), (vec2(1600.0, 1000.0), 1.5, 360.0), (vec2(2400.0, 1400.0), 2.0, 600.0)]; + for (i, (size, ppp, ec)) in cases.into_iter().enumerate() { + let (s, ids) = crypto_session(&format!("matrix{i}"), 1919, 1079, false); + let mut h = harness(s, size, ppp, ec); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + arm(&mut h, &ids); + for proxy in [false, true] { + if proxy { + let p = ids.dir.join("proxy").join("plate_proxy.exr"); + h.state_mut().session.execute("file.setProxy", json!({"item": ids.footage, "path": p.to_string_lossy()})).unwrap(); + } + for res in [Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter, Resolution::Custom(6)] { + for (zoom, pan) in [(None, [0.0, 0.0]), (Some(0.5), [0.0, 0.0]), (Some(2.0), [-300.0, 120.0])] { + let cid = h.state().session.active_comp_id().unwrap(); + h.state_mut().session.set_resolution(cid, res).unwrap(); + h.state_mut().ui.viewer.zoom = zoom.map(|z: f32| z / ppp); + h.state_mut().ui.viewer.pan = pan; + settle(&mut h); + let area = h.state().auto.find("viewer.fxPick").unwrap().rect; + let scale = shown_scale(&h); + // Points next to cell edges, in the middle and at the far corner. + // (Cell edges far from the origin: 925, 1044 shows the 1/3 vs 0.333 rounding.) + for p in [[36.6, 28.7], [37.4, 29.3], [959.7, 539.2], [1918.4, 1078.6], [74.1, 57.9], [925.4, 1044.4], [1110.6, 1043.6]] { + let Some(sp) = comp_to_screen(&h.ctx, [p[0] as f32, p[1] as f32]) else { continue }; + if sp.x < area[0] + 4.0 || sp.y < area[1] + 40.0 || sp.x > area[0] + area[2] - 4.0 || sp.y > area[1] + area[3] - 4.0 { + continue; + } + let got = hover(&mut h, sp); + let got = got.trim_end_matches(" (selected)").to_string(); + // The comp point under the cursor, as the viewer maps it back. + let q = to_comp(&h, sp); + checked += 1; + // The named ID has the largest coverage of the shown pixel (a blended edge + // pixel of a proxy at Quarter has several). No ID named: the frame shows + // (almost) no matte there either (an odd size's last half-resolution pixel + // samples just outside the layer). + let ok = if got.starts_with('c') { largest(&mut h, &got, q, scale) } else { got == HINT && lit(&mut h, "*", q, scale) < 0.5 }; + if !ok { + failures.push(format!( + "{size:?} ppp {ppp} proxy {proxy} {res:?} zoom {zoom:?} (scale {scale}): at {q:?} hover {got:?} is not lit there" + )); + } + } + } + } + // Frames from Cache Frames When Idle (the first case): rendered on the grid the + // viewer renders and the pick reads (1/3, not the key's 0.333), so frame 20 from the + // idle cache is frame 0 as the viewer rendered it, and hovers there are right too. + if i == 0 && !proxy { + (h.state_mut().ui.viewer.zoom, h.state_mut().ui.viewer.pan) = (None, [0.0, 0.0]); + for res in [Resolution::Third, Resolution::Custom(6)] { + let fails = idle_cached_frames_are_shown_frames(&mut h, res); + checked += 1; + failures.extend(fails); + } + } + // A click picks what the hover named. + (h.state_mut().ui.viewer.zoom, h.state_mut().ui.viewer.pan) = (None, [0.0, 0.0]); + settle(&mut h); + let sp = comp_to_screen(&h.ctx, [959.7, 539.2]).unwrap(); + let named = hover(&mut h, sp); + click(&mut h, sp, Modifiers::NONE); + assert_eq!(selection(&h, &ids), named.trim_end_matches(" (selected)")); + if proxy { + h.state_mut().session.execute("file.setProxy", json!({"item": ids.footage, "path": null})).ok(); + } + } + let _ = std::fs::remove_dir_all(&ids.dir); + } + assert!(failures.is_empty(), "{} of {checked} hovers wrong:\n{}", failures.len(), failures.join("\n")); + assert!(checked > 100, "{checked}"); +} + +fn set_frame(h: &mut Harness<'_, EffectcraftApp>, f: i64) { + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(f as f64 / 24.0)); +} + +/// The viewer's CPU frame `f` of the series shown. +fn cpu_frame(h: &Harness<'_, EffectcraftApp>, f: i64) -> Option<([usize; 2], Vec)> { + let key = h.state().shown_series(h.state().session.active_comp_id().unwrap()); + match h.state().frames.get_for_viewer(&effectcraft_ui_egui::frames::FrameKey { frame: f, ..key }, None) { + Some((_, effectcraft_ui_egui::frames::FrameImage::Cpu(c))) => Some((c.size, c.pixels.clone())), + _ => None, + } +} + +/// At `res`, with the Colors display: frame 20 cached when idle against frame 0 rendered by the +/// viewer (the footage is a still), then hovers on frame 20. Returns what is wrong. +fn idle_cached_frames_are_shown_frames(h: &mut Harness<'_, EffectcraftApp>, res: Resolution) -> Vec { + let cid = h.state().session.active_comp_id().unwrap(); + let display = h.state().session.active_comp().unwrap().layers[0].effects().unwrap().groups().last().unwrap().get("display").unwrap().value.to_json(); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": false})).unwrap(); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": 0})).unwrap(); + h.state_mut().session.set_resolution(cid, res).unwrap(); + set_frame(h, 0); + settle(h); + let shown = cpu_frame(h, 0).expect("frame 0 shown"); + h.state_mut().ui.cache_when_idle = true; + for _ in 0..2000 { + h.step(); + let key = h.state().shown_series(cid); + if h.state().frames.is_cached(&effectcraft_ui_egui::frames::FrameKey { frame: 20, ..key }) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.state_mut().ui.cache_when_idle = false; + let mut out = vec![]; + match cpu_frame(h, 20) { + None => out.push(format!("{res:?}: frame 20 was never cached when idle")), + Some(c) if c.0 != shown.0 => out.push(format!("{res:?}: the idle cache rendered {:?}, the viewer {:?}", c.0, shown.0)), + Some(c) if c.1 != shown.1 => out.push(format!("{res:?}: the idle-cached frame differs from the viewer's")), + Some(_) => {} + } + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": display})).unwrap(); + set_frame(h, 20); + settle(h); + let scale = shown_scale(h); + for p in [[959.7, 539.2], [925.4, 1044.4], [1110.6, 1043.6]] { + let sp = comp_to_screen(&h.ctx, [p[0] as f32, p[1] as f32]).unwrap(); + let got = hover(h, sp).trim_end_matches(" (selected)").to_string(); + let q = to_comp(h, sp); + if !(got.starts_with('c') && largest(h, &got, q, scale)) { + out.push(format!("{res:?} idle-cached frame 20: at {q:?} hover {got:?} is not lit there")); + } + } + set_frame(h, 0); + settle(h); + out +} + +/// Press and release at `pos` without waiting for the probe. +fn press(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.run_steps(2); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } +} + +/// An edit that drops the probe: the blur before the Cryptomatte effect changes, so the next +/// hover or click has to render it again to build the probe. +/// (Each blurriness `v` once: an earlier one would come from the layer cache.) +fn edit(h: &mut Harness<'_, EffectcraftApp>, v: f64) { + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": v})).unwrap(); +} + +/// Let a probe still being built finish with nothing waiting on it. +fn drain(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..100 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(20)); + } +} + +/// Waiting clicks: the first hover starts the probe; a click made meanwhile waits then picks; +/// an edit, Esc, disarming, or hiding the viewer while it waits drops it. +fn waiting_clicks_are_dropped_by_edits_esc_disarm_and_hiding_the_viewer(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, a: Pos2, b: Pos2) { + // A click before the probe is built waits for it. + // (egui reports the hover a pass after the pointer moves.) + h.event(Event::PointerMoved(a)); + h.run_steps(2); + assert_eq!(chip(h).as_deref(), Some(READING), "the first hover starts the probe off the UI thread"); + press(h, a); + assert_eq!(selection(h, ids), "", "the click waits"); + wait_probe(h); + h.run_steps(2); + assert_eq!(selection(h, ids), "c27_20", "then picks"); + assert_eq!(hover(h, b), "c50_40"); + let steps = h.state().session.history.undo.len(); + // An edit while a click waits drops the click. + edit(h, 41.0); + press(h, b); + assert_eq!(chip(h).as_deref(), Some(READING)); + edit(h, 42.0); + wait_probe(h); + h.run_steps(2); + assert_eq!(selection(h, ids), "c27_20", "not picked after the edit"); + assert_eq!(h.state().session.history.undo.len(), steps + 2, "only the two edits"); + // Esc while a click waits: no pick, disarmed. + edit(h, 43.0); + press(h, b); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none() && chip(h).is_none()); + drain(h); + assert_eq!(selection(h, ids), "c27_20"); + // Disarmed while a click waits, then armed again: the old click is not made. + edit(h, 44.0); + arm(h, ids); + press(h, b); + assert_eq!(chip(h).as_deref(), Some(READING)); + h.state_mut().ui.fx_pick = None; + h.run_steps(2); + arm(h, ids); + drain(h); + assert_eq!(selection(h, ids), "c27_20", "the old click stays unmade"); + assert_eq!(hover(h, b), "c50_40", "still picks"); + // The viewer hidden while a click waits (Effect Controls maximised), then shown again: the + // pick still on, the old click is not made either. + edit(h, 45.0); + press(h, b); + assert_eq!(chip(h).as_deref(), Some(READING)); + h.state_mut().ui.maximized = Some(PanelKind::EffectControls); + h.run_steps(2); + assert!(h.state().auto.find("viewer.fxPick").is_none(), "the viewer is hidden"); + drain(h); + h.state_mut().ui.maximized = None; + h.run_steps(2); + drain(h); + assert!(h.state().ui.fx_pick.is_some()); + assert_eq!(selection(h, ids), "c27_20", "the click made before the viewer was hidden stays unmade"); +} + +/// Popups and dialogs over the viewer take the pointer. Another selected layer keeps the pick; +/// deleting the layer or switching comps ends it. +fn popups_dialogs_and_comp_switches_end_or_block_them(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, b: Pos2) { + // A popup over the viewer takes the pointer: no hover, its click is its own. + let area = h.state().auto.find("viewer.fxPick").unwrap().rect; + let inside = |p: Pos2| p.x > area[0] && p.y > area[1] && p.x < area[0] + area[2] && p.y < area[1] + area[3]; + let res = at(h, "viewer.resolution"); + click(h, res, Modifiers::NONE); + let item = at(h, "viewer.resolutionItem.0"); + assert!(inside(item), "the Resolution popup is over the viewer: {item:?} {area:?}"); + assert_eq!(hover(h, item), HINT, "nothing hovered under the popup"); + click(h, item, Modifiers::NONE); + settle(h); + assert_eq!(h.state().viewer_res(), effectcraft_ui_egui::state::Resolution::Full); + assert_eq!(selection(h, ids), "c27_20", "the popup's click picked nothing"); + assert!(h.state().ui.fx_pick.is_some(), "still armed"); + assert_eq!(hover(h, b), "c50_40"); + // A click on the viewer while the popup is open only closes the popup. + click(h, res, Modifiers::NONE); + assert!(h.state().auto.find("viewer.resolutionItem.0").is_some(), "open again"); + click(h, b, Modifiers::SHIFT); + assert!(h.state().auto.find("viewer.resolutionItem.0").is_none(), "closed"); + assert_eq!(selection(h, ids), "c27_20", "the closing click picked nothing"); + // So does a dialog. + h.state_mut().dialog = Some(Dialog::CompSettings); + h.run_steps(3); + let d = at(h, "dialog.comp.name"); + assert!(inside(d), "the dialog is over the viewer: {d:?} {area:?}"); + assert_eq!(hover(h, d), HINT, "nothing hovered under the dialog"); + h.state_mut().dialog = None; + h.run_steps(2); + // Another layer selected: the pick stays with its layer. + let solid = h.state_mut().session.execute("layer.newSolid", json!({"name": "Other", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.state.selected_layers = vec![LayerId(solid)]; + settle(h); + assert!(h.state().ui.fx_pick.as_ref().is_some_and(|p| p.layer == ids.layer)); + click(h, b, Modifiers::SHIFT); + assert_eq!(selection(h, ids), "c27_20, c50_40"); + // The layer deleted: the pick ends; undone, it is not armed again. + h.state_mut().session.state.selected_layers = vec![LayerId(ids.layer)]; + h.state_mut().session.execute_checked("edit.clear", json!({"layers": [ids.layer]})).unwrap(); + h.run_steps(3); + assert!(h.state().ui.fx_pick.is_none() && chip(h).is_none(), "ended with its layer"); + h.state_mut().session.execute_checked("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert!(h.state().ui.fx_pick.is_none()); + // Another comp: the pick ends. + h.state_mut().session.state.selected_layers = vec![LayerId(ids.layer)]; + h.run_steps(3); + arm(h, ids); + h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap(); + h.run_steps(3); + assert!(h.state().ui.fx_pick.is_none() && chip(h).is_none(), "ended with the comp switch"); +} + +/// The asynchronous probe (a 4K plate, so it takes a while): the first hover says it is reading, +/// a click made meanwhile waits and then picks; an edit, Esc or disarming while it waits drops +/// it. Popups and dialogs over the viewer take the pointer. Another selected layer keeps the +/// pick; deleting the layer or switching comps ends it. +#[test] +fn clicks_wait_for_the_probe_and_edits_popups_dialogs_and_comp_switches_end_or_block_them() { + let (s, ids) = crypto_session("async", 3840, 2160, true); + let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let (a, b) = (comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(), comp_to_screen(&h.ctx, [1868.5, 1174.5]).unwrap()); + waiting_clicks_are_dropped_by_edits_esc_disarm_and_hiding_the_viewer(&mut h, &ids, a, b); + popups_dialogs_and_comp_switches_end_or_block_them(&mut h, &ids, b); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// On the GPU compositor (egui_kittest with wgpu on the headless compositor's adapter, as in +/// `ui_gpu.rs`; lavapipe on CI-like boxes): hover and click name what the GPU viewer shows, at +/// each Resolution. Skips without a GPU adapter. +#[test] +fn picks_on_the_gpu_viewer() { + let Some(probe) = effectcraft_gpu::Gpu::headless() else { + eprintln!("no GPU adapter: skipping"); + return; + }; + let adapter = effectcraft_engine::render::Accelerator::name(&probe); + drop(probe); + let mut setup = egui_kittest::wgpu::default_wgpu_setup(); + if let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut setup { + let name = adapter.clone(); + c.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, _| { + adapters + .iter() + .find(|a| format!("{} ({:?})", a.get_info().name, a.get_info().backend) == name) + .or(adapters.first()) + .cloned() + .ok_or_else(|| "no adapter".to_owned()) + })); + } + let (s, ids) = crypto_session("gpu", 1919, 1079, false); + let app = app(s, 420.0); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_pixels_per_point(1.0).wgpu_setup(setup).build_eframe(|_| app); + settle(&mut h); + if h.state().gpu_adapter().is_none() { + eprintln!("the viewer stayed on the CPU ({adapter}): skipping"); + return; + } + eprintln!("GPU viewer on {:?}", h.state().gpu_adapter()); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + arm(&mut h, &ids); + let mut checked = 0; + for res in [Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter] { + let cid = h.state().session.active_comp_id().unwrap(); + h.state_mut().session.set_resolution(cid, res).unwrap(); + settle(&mut h); + let scale = shown_scale(&h); + for p in [[36.6, 28.7], [37.4, 29.3], [959.7, 539.2], [925.4, 1044.4]] { + let sp = comp_to_screen(&h.ctx, [p[0] as f32, p[1] as f32]).unwrap(); + let got = hover(&mut h, sp).trim_end_matches(" (selected)").to_string(); + let q = to_comp(&h, sp); + assert!(got.starts_with('c') && lit(&mut h, &got, q, scale) >= 0.5, "{res:?}: at {q:?} hover {got:?} is not lit there"); + checked += 1; + } + } + let sp = comp_to_screen(&h.ctx, [959.7, 539.2]).unwrap(); + let named = hover(&mut h, sp); + // The highlight is drawn: a pixel of the hovered ID (just up-left of the pointer) is tinted + // while hovered, not with the pointer off the layer (the frame itself unchanged). + settle(&mut h); + let px = |h: &mut Harness<'_, EffectcraftApp>| *h.render().expect("render").get_pixel(sp.x as u32 - 2, sp.y as u32 - 2); + let lit_px = px(&mut h); + let off = comp_to_screen(&h.ctx, [-3.0, 540.0]).unwrap_or(pos2(2.0, 2.0)); + hover(&mut h, off); + let plain = px(&mut h); + assert_eq!(hover(&mut h, sp), named); + assert_eq!(px(&mut h), lit_px, "the same frame"); + assert_ne!(lit_px, plain, "the highlight tints the hovered ID"); + click(&mut h, sp, Modifiers::NONE); + assert_eq!(selection(&h, &ids), named.trim_end_matches(" (selected)")); + assert_eq!(checked, 16); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// How many probes the session built so far, and whether one is kept. +/// Waits until the build thread started after `jobs` jobs has counted itself (it may not have +/// run yet when the pass that spawned it returns). +fn started(h: &Harness<'_, EffectcraftApp>, jobs: u64) { + for _ in 0..2500 { + if h.state().session.crypto_probe_jobs() > jobs { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("no build started"); +} + +fn builds(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.crypto_probe_stats().1 +} + +/// Clicks made while the IDs are being read are all made, in order, once they are. +#[test] +fn every_click_made_while_reading_is_made_in_order() { + let (s, ids) = crypto_session("multi", 3840, 2160, true); + let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let (a, b) = (comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(), comp_to_screen(&h.ctx, [1868.5, 1174.5]).unwrap()); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + let steps = h.state().session.history.undo.len(); + for (p, m) in [(a, Modifiers::NONE), (b, Modifiers::SHIFT), (a, Modifiers::ALT), (a, Modifiers::SHIFT)] { + h.event(Event::PointerMoved(p)); + h.event(Event::ModifiersChanged(m)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: m }); + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + } + assert_eq!(chip(&h).as_deref(), Some(READING), "all four made while reading"); + assert_eq!(selection(&h, &ids), ""); + wait_probe(&mut h); + h.run_steps(3); + assert_eq!(selection(&h, &ids), "c50_40, c27_20", "replace, add, remove, add, in order"); + assert_eq!(h.state().session.history.undo.len(), steps + 4); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A click that drifts past egui's drag threshold still picks at its press, and the Selection +/// tool under the pick never moves the layer. +#[test] +fn a_drifting_click_picks_and_never_moves_the_layer() { + let (s, ids) = crypto_session("drag", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + let position = |h: &Harness<'_, EffectcraftApp>| { + format!( + "{:?}", + h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().transform().and_then(|t| t.get("position")).map(|p| p.value.clone()) + ) + }; + let (steps, before) = (h.state().session.history.undo.len(), position(&h)); + let drag = |h: &mut Harness<'_, EffectcraftApp>, to: egui::Vec2| { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(a + to * k as f32 / 4.0)); + h.step(); + } + h.event(Event::PointerButton { pos: a + to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + drag(&mut h, vec2(12.0, 0.0)); + assert_eq!(selection(&h, &ids), "c1_1", "picked at the press"); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "only the pick"); + // A real drag picks nothing and moves nothing either. + drag(&mut h, vec2(120.0, 40.0)); + assert_eq!((h.state().session.history.undo.len(), position(&h)), (steps + 1, before), "the layer stays put"); + assert_eq!(h.state().ui.status, "Drag cancelled the pick", "not silently"); + assert!(h.state().ui.fx_pick.is_some()); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Frame steps on a still (behind a blur that isn't animated) reuse the probe: no re-read. +#[test] +fn frame_steps_on_a_still_reuse_the_probe() { + let (s, ids) = crypto_session("time", 3840, 2160, true); + let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(); + assert_eq!(hover(&mut h, a), "c27_20"); + let n = builds(&h); + for f in 1..=4 { + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(f as f64 / 24.0)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some("c27_20"), "frame {f}"); + } + assert_eq!(builds(&h), n, "no probe built for the frame steps"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// With an animated blur before the Cryptomatte effect each frame needs its own probe: none is +/// built while playing, and a scrub builds one once it settles, not one per frame. +#[test] +fn no_probe_builds_while_playing_and_one_per_settled_scrub() { + let (mut s, ids) = crypto_session("play", 960, 540, true); + for (t, v) in [(0.0, 2.0), (1.0, 6.0)] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": t, "value": v})).unwrap(); + } + let mut h = harness_dt(s, vec2(1280.0, 800.0), 1.0, 420.0, 0.02); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [260.0, 150.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c7_5"); + let n = builds(&h); + let set = |h: &mut Harness<'_, EffectcraftApp>, f: i64| h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(f as f64 / 24.0)); + // Scrubbing: a frame per 20 ms step; nothing built until it stays put for a while. + // (The viewer names what it shows: a new frame takes a step or two to arrive.) + let mut read = false; + for f in 1..=6 { + set(&mut h, f); + h.run_steps(2); + read |= chip(&h).as_deref() == Some(READING); + } + assert!(read, "the scrub reached frames without a probe"); + assert_eq!(builds(&h), n, "no build while scrubbing"); + wait_probe(&mut h); + assert_eq!(chip(&h).as_deref(), Some("c7_5")); + assert_eq!(builds(&h), n + 1, "one build for the settled frame"); + // Playing: no builds, the chip says why; stopped, it reads. + h.state_mut().playback.playing = true; + set(&mut h, 9); + for _ in 0..20 { + h.step(); + } + assert_eq!(chip(&h).as_deref(), Some(PAUSED)); + assert_eq!(builds(&h), n + 1, "no build while playing"); + // An audio-only preview plays with the frame held: still no builds. + let now = h.ctx.input(|i| i.time); + let pb = &mut h.state_mut().playback; + (pb.start_wall, pb.start_frame) = (now, 9); + pb.plan = Some(effectcraft_engine::preview::PreviewPlan { + start: 0, + end: 47, + first: 9, + step: 1, + fps: 24.0, + looping: true, + cache_first: false, + video: false, + audio: true, + origin: effectcraft_engine::time::Tick::ZERO, + }); + for _ in 0..20 { + h.step(); + } + assert!(h.state().playback.playing); + assert_eq!(chip(&h).as_deref(), Some(PAUSED)); + assert_eq!(builds(&h), n + 1, "no build while an audio preview holds the frame"); + h.state_mut().playback.plan = None; + h.state_mut().playback.playing = false; + h.run_steps(2); + wait_probe(&mut h); + assert_eq!(chip(&h).as_deref(), Some("c7_5")); + assert_eq!(builds(&h), n + 2); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Disarming lets go of the probe (and the aux channels it holds): Esc, the button, or the pick +/// ending any other way. +#[test] +fn disarming_releases_the_probe() { + let (s, ids) = crypto_session("release", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let kept = |h: &Harness<'_, EffectcraftApp>| h.state().session.crypto_probe_stats().0; + for how in ["esc", "button", "other"] { + arm(&mut h, &ids); + assert_eq!(hover(&mut h, a), "c1_1"); + assert!(kept(&h), "{how}: kept while armed"); + match how { + "esc" => h.key_press(egui::Key::Escape), + "button" => { + let eye = at(&h, &format!("effectControls.prop.{}.eyedropper", ids.sel)); + click(&mut h, eye, Modifiers::NONE); + } + _ => h.state_mut().ui.fx_pick = None, + } + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none() && !kept(&h), "{how}: released"); + } + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Space (the temporary Hand) and the Hand and Zoom tools keep their clicks: no pick. +#[test] +fn space_hand_and_zoom_clicks_do_not_pick() { + let (s, ids) = crypto_session("nav", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.event(space(true)); + h.step(); + click(&mut h, a, Modifiers::NONE); + h.event(space(false)); + h.run_steps(2); + assert_eq!(selection(&h, &ids), "", "Space + click"); + for tool in [Tool::Zoom, Tool::Hand] { + h.state_mut().ui.tool = tool; + h.run_steps(2); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + click(&mut h, a, Modifiers::NONE); + assert_eq!(selection(&h, &ids), "", "{tool:?} click"); + } + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + // The middle button pans, armed as unarmed; the right button opens the viewer's menu. + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let pan0 = h.state().ui.viewer.pan; + h.event(Event::PointerMoved(a)); + h.run_steps(2); + h.event(Event::PointerButton { pos: a, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=5 { + h.event(Event::PointerMoved(a + vec2(10.0 * k as f32, 0.0))); + h.step(); + } + h.event(Event::PointerButton { pos: a + vec2(50.0, 0.0), button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let pan = h.state().ui.viewer.pan; + assert!((pan[0] - pan0[0] - 50.0).abs() < 1.0 && (pan[1] - pan0[1]).abs() < 1.0, "a middle drag pans: {pan0:?} -> {pan:?}"); + assert!(h.state().ui.fx_pick.is_some() && selection(&h, &ids).is_empty(), "and picks nothing"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx), "a right-click opens the viewer's context menu"); + assert!(h.state().ui.fx_pick.is_some() && selection(&h, &ids).is_empty(), "and picks nothing"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(!egui::Popup::is_any_open(&h.ctx)); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + click(&mut h, a, Modifiers::NONE); + assert_eq!(selection(&h, &ids), "c1_1", "the Selection tool picks"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A layer without Cryptomatte channels says so on hover, in a chip no wider than allowed (the +/// long message ends in an ellipsis; the whole text stays in its automation label). +#[test] +fn a_layer_without_cryptomatte_shows_the_error_in_a_bounded_chip() { + let (mut s, ids) = crypto_session("nocrypto", 320, 180, false); + let solid = s.execute("layer.newSolid", json!({"name": "Plain", "width": 320, "height": 180})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(solid)]; + s.execute_checked("effect.apply", json!({"layers": [solid], "effect": "Cryptomatte"})).unwrap(); + let sel = s.active_comp().unwrap().layer(LayerId(solid)).unwrap().effects().unwrap().groups().next().unwrap().get("selection").unwrap().uid; + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &Ids { layer: solid, sel, footage: ids.footage, dir: ids.dir.clone() }); + let p = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let got = hover(&mut h, p); + // The message for the user, without the command's prefix, whole (its hint included). + assert_eq!(got, "This layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"); + let w = h.state().auto.find("viewer.cryptoPick").unwrap().rect[2]; + assert!(w <= 480.0 + 24.0, "chip {w} wide"); + let e = h.state().auto.find("viewer.cryptoPick.error").expect("shown as an error"); + assert_eq!(e.label, "whole", "the error isn't cut off"); + // A narrow viewer still shows it whole, on more lines. + h.set_size(vec2(760.0, 800.0)); + h.run_steps(4); + let e = h.state().auto.find("viewer.cryptoPick.error").expect("shown as an error").clone(); + assert!(e.label == "whole" && e.rect[3] > 30.0, "{e:?}"); + // Narrower still (a viewer about 200 px wide): more lines, still whole. + h.set_size(vec2(630.0, 800.0)); + h.run_steps(4); + let q = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + hover(&mut h, q); + let e = h.state().auto.find("viewer.cryptoPick.error").expect("shown as an error").clone(); + assert_eq!(e.label, "whole", "{e:?} in a viewer {} wide", h.state().auto.find("viewer.fxPick").map_or(0.0, |v| v.rect[2])); + // A click there: the same message in the status line. + click(&mut h, q, Modifiers::NONE); + assert!(h.state().ui.status.starts_with("This layer has no Cryptomatte channels"), "{}", h.state().ui.status); + // A hover with an answer isn't an error. + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Footage whose Cryptomatte reader panics on the probe's thread. +struct Broken(std::sync::Arc); +impl effectcraft_engine::render::FootageSource for Broken { + fn frame( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + ) -> Option> { + self.0.frame(i, f, t) + } + fn frame_with( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + size: Option<(u32, u32)>, + float: effectcraft_engine::render::FloatCurve, + ) -> Option> { + self.0.frame_with(i, f, t, size, float) + } + fn aux( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + ) -> Option> { + assert_ne!(std::thread::current().name(), Some("crypto-probe"), "a broken Cryptomatte reader"); + self.0.aux(i, f, t) + } +} + +/// A panic while the probe is built shows as an error in the chip, and the pick keeps working. +#[test] +fn a_panicking_probe_build_shows_an_error() { + let (mut s, ids) = crypto_session("panic", 320, 180, false); + s.footage = std::sync::Arc::new(Broken(s.footage.clone())); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let p = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let got = hover(&mut h, p); + assert!(got.contains("Reading the Cryptomatte IDs failed") && got.contains("a broken Cryptomatte reader"), "{got}"); + let p = comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap(); + assert_eq!(hover(&mut h, p), got, "not retried for the frame"); + assert!(h.state().ui.fx_pick.is_some()); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A long Selection cut off by a narrow Effect Controls panel shows whole on hover. +#[test] +fn a_cut_off_selection_shows_whole_on_hover() { + use egui_kittest::kittest::Queryable; + let (mut s, ids) = crypto_session("tip", 320, 180, false); + let long = (0..8).map(|i| format!("c{i}_{i}")).collect::>().join(", "); + s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": long})).unwrap(); + let mut h = harness(s, vec2(1100.0, 800.0), 1.0, 300.0); + let field = at(&h, &format!("effectControls.prop.{}.value", ids.sel)); + h.event(Event::PointerMoved(field)); + h.run_steps(8); + assert!(h.query_by_label(&long).is_some(), "the whole Selection on hover"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The highlight's screen corners (its automation label), when one is drawn. +fn highlight(h: &Harness<'_, EffectcraftApp>) -> Option> { + let e = h.state().auto.find("viewer.cryptoPick.highlight")?; + Some(e.label.split(' ').map(|c| c.split_once(',').map(|(x, y)| pos2(x.parse().unwrap(), y.parse().unwrap())).unwrap()).collect()) +} + +/// The hovered ID's highlight covers the layer as the viewer shows it: unscaled, and scaled and +/// rotated; none off the IDs. +#[test] +fn the_highlight_covers_the_layer_as_shown() { + let (s, ids) = crypto_session("quad", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let corners = [[0.0, 0.0], [320.0, 0.0], [320.0, 180.0], [0.0, 180.0]]; + for (scale, rot) in [(100.0, 0.0_f64), (50.0, 30.0), (150.0, -10.0)] { + let s = &mut h.state_mut().session; + s.execute_checked("prop.set", json!({"path": "transform/scale", "value": [scale, scale]})).unwrap(); + s.execute_checked("prop.set", json!({"path": "transform/rotation", "value": rot})).unwrap(); + settle(&mut h); + // Layer point → comp: about the anchor (the centre), at the position (the comp's centre). + let (k, (sn, cs)) = (scale / 100.0, rot.to_radians().sin_cos()); + let comp = |p: [f64; 2]| { + let (x, y) = ((p[0] - 160.0) * k, (p[1] - 90.0) * k); + [(160.0 + x * cs - y * sn) as f32, (90.0 + x * sn + y * cs) as f32] + }; + let a = comp_to_screen(&h.ctx, comp([50.0, 40.0])).unwrap(); + assert_eq!(hover(&mut h, a).trim_end_matches(" (selected)"), "c1_1", "scale {scale} rotation {rot}"); + let got = highlight(&h).expect("a highlight"); + for (g, c) in got.iter().zip(corners) { + let want = comp_to_screen(&h.ctx, comp(c)).unwrap(); + assert!(g.distance(want) < 0.6, "scale {scale} rotation {rot}: corner {c:?} at {g:?}, the layer's at {want:?}"); + } + } + let s = &mut h.state_mut().session; + s.execute_checked("prop.set", json!({"path": "transform/scale", "value": [100.0, 100.0]})).unwrap(); + s.execute_checked("prop.set", json!({"path": "transform/rotation", "value": 0.0})).unwrap(); + settle(&mut h); + let off = comp_to_screen(&h.ctx, [-3.0, 90.0]).unwrap(); + assert_eq!(hover(&mut h, off), HINT); + assert!(highlight(&h).is_none(), "no highlight off the IDs"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A click waits for its frame's probe (the build has started and is held), then the frame steps +/// with the pointer kept on the viewer (each frame its own probe: an animated blur). The hover +/// and the waiting click never take turns cancelling each other's build: the click, for a frame +/// no longer shown, is dropped with a message, the hover reads the new frame once the held build +/// ends, and a new click picks, all within a few builds and without a thread per pass. +#[test] +fn a_frame_step_while_a_click_waits_settles_within_a_few_builds() { + let (mut s, ids) = crypto_session("livelock", 960, 540, true); + for (t, v) in [(0.0, 2.0), (1.0, 6.0)] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": t, "value": v})).unwrap(); + } + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), Default::default())); + let mut h = harness_dt(s, vec2(1280.0, 800.0), 1.0, 420.0, 0.02); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [260.0, 150.0]).unwrap(); + set_frame(&mut h, 3); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + h.event(Event::PointerMoved(a)); + h.run_steps(10); // settled: the build for frame 3 starts (and is held) + assert_eq!(chip(&h).as_deref(), Some(READING)); + started(&h, before); + let b0 = builds(&h); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + let jobs = |h: &Harness<'_, EffectcraftApp>| h.state().session.crypto_probe_jobs(); + let j0 = jobs(&h); + set_frame(&mut h, 4); + let step = |h: &mut Harness<'_, EffectcraftApp>| { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + // 100 passes with the pointer on the viewer while frame 3's build is held. + for _ in 0..100 { + step(&mut h); + } + assert_eq!(chip(&h).as_deref(), Some(READING)); + assert_eq!((builds(&h), jobs(&h)), (b0, j0), "nothing else started while one build runs"); + // (Asked now: a status message goes after 5 s.) + assert!(h.state().ui.status.starts_with("Pick not made"), "the click for frame 3 says it wasn't made: {:?}", h.state().ui.status); + hold.store(false, std::sync::atomic::Ordering::Release); + for _ in 0..1000 { + step(&mut h); + if chip(&h).as_deref() != Some(READING) { + break; + } + } + assert_eq!(chip(&h).as_deref(), Some("c7_5"), "frame 4's IDs are read"); + assert_eq!(selection(&h, &ids), "", "the click for frame 3 isn't made on frame 4"); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + for _ in 0..500 { + step(&mut h); + if !selection(&h, &ids).is_empty() { + break; + } + } + assert_eq!(selection(&h, &ids), "c7_5", "the click on frame 4 picks"); + // Frame 4's build, and no thread that did nothing. + let (n, j) = (builds(&h) - b0, jobs(&h) - j0); + assert!(n <= 2 && j == n, "{n} builds, {j} jobs (threads) after frame 3's"); + // Then nothing more is started. + for _ in 0..50 { + step(&mut h); + } + assert_eq!((builds(&h) - b0, jobs(&h) - j0), (n, j)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Footage whose Cryptomatte read on the probe's thread waits while `hold` is set. It logs the +/// probe threads' reads: (thread, a picture frame rather than the IDs). +struct Held(std::sync::Arc, std::sync::Arc, ReadLog); +type ReadLog = std::sync::Arc>>; +impl Held { + fn log(&self, frame: bool) { + if std::thread::current().name() == Some("crypto-probe") { + self.2.lock().unwrap().push((std::thread::current().id(), frame)); + } + } +} +impl effectcraft_engine::render::FootageSource for Held { + fn frame( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + ) -> Option> { + self.log(true); + self.0.frame(i, f, t) + } + fn frame_with( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + size: Option<(u32, u32)>, + float: effectcraft_engine::render::FloatCurve, + ) -> Option> { + self.log(true); + self.0.frame_with(i, f, t, size, float) + } + fn aux( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + ) -> Option> { + self.log(false); + while std::thread::current().name() == Some("crypto-probe") && self.1.load(std::sync::atomic::Ordering::Acquire) { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + self.0.aux(i, f, t) + } +} + +/// Disarmed and armed again while a probe is built: the new hover waits for that build to end +/// before it starts its own (one build at a time, so one probe's memory). +#[test] +fn rearming_during_a_build_never_runs_two_builds() { + let (mut s, ids) = crypto_session("rearm", 320, 180, false); + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), Default::default())); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + started(&h, before); + let b1 = builds(&h); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none()); + arm(&mut h, &ids); + h.event(Event::PointerMoved(a)); + for _ in 0..20 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(chip(&h).as_deref(), Some(READING)); + assert_eq!(builds(&h), b1, "a second build started while the first runs"); + hold.store(false, std::sync::atomic::Ordering::Release); + assert_eq!(hover(&mut h, a), "c1_1"); + assert_eq!(builds(&h), b1 + 1, "then one build for the new pick"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Esc while typing in the Selection field ends the typing (the text goes back), not the pick. +#[test] +fn esc_in_the_selection_field_keeps_the_pick() { + let (s, ids) = crypto_session("escfield", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let field = at(&h, &format!("effectControls.prop.{}.value", ids.sel)); + click(&mut h, field, Modifiers::NONE); + h.event(Event::Text("c9_9".into())); + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_some(), "still armed"); + assert_eq!(selection(&h, &ids), "", "the typing was dropped"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none(), "Esc outside the field ends the pick"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A build cancelled while it reads the IDs (the pick turned off, or another project opened) +/// stops there: it doesn't go on to render the effect's input (a 4K plate behind a blur), so +/// the next pick's build, waiting for it (one at a time), starts at once. +#[test] +fn a_cancelled_build_does_not_keep_the_next_pick_waiting() { + for how in ["disarm", "open"] { + let (mut s, ids) = crypto_session(&format!("cancel-{how}"), 3840, 2160, true); + let (hold, log) = (std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ReadLog::default()); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), log.clone())); + let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + started(&h, before); + while log.lock().unwrap().is_empty() { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let held = log.lock().unwrap()[0].0; + match how { + "disarm" => h.key_press(egui::Key::Escape), + _ => { + let p = (*h.state().session.project).clone(); + h.state_mut().session.replace_project(p, None); + h.state_mut().session.state.selected_layers = vec![LayerId(ids.layer)]; + } + } + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none(), "{how}: the pick ended"); + arm(&mut h, &ids); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING), "{how}: waits for the cancelled build"); + let jobs = h.state().session.crypto_probe_jobs(); + hold.store(false, std::sync::atomic::Ordering::Release); + assert_eq!(hover(&mut h, a), "c27_20", "{how}"); + assert_eq!(h.state().session.crypto_probe_jobs(), jobs + 1, "{how}: one more build"); + let frames = log.lock().unwrap().iter().filter(|(t, f)| *t == held && *f).count(); + assert_eq!(frames, 0, "{how}: the cancelled build rendered its input"); + let _ = std::fs::remove_dir_all(&ids.dir); + } +} + +/// While playing, a frame whose IDs aren't read yet can't be picked: the chip says to stop, and +/// a click there says it wasn't made, at once, instead of waiting for a frame that is gone. A +/// frame whose IDs are read (here the one shown before playing) still picks while playing. +#[test] +fn a_click_while_playing_says_to_stop_playback() { + let (mut s, ids) = crypto_session("playclick", 960, 540, true); + for (t, v) in [(0.0, 2.0), (1.0, 6.0)] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": t, "value": v})).unwrap(); + } + let mut h = harness_dt(s, vec2(1280.0, 800.0), 1.0, 420.0, 0.02); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [260.0, 150.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c7_5"); + let (steps, n) = (h.state().session.history.undo.len(), builds(&h)); + // Playing with the frame held at 9 (an audio-only preview), so a queued click could be made. + let play = |h: &mut Harness<'_, EffectcraftApp>| { + set_frame(h, 9); + let now = h.ctx.input(|i| i.time); + let pb = &mut h.state_mut().playback; + (pb.playing, pb.start_wall, pb.start_frame) = (true, now, 9); + let origin = effectcraft_engine::time::Tick::ZERO; + let plan = effectcraft_engine::preview::PreviewPlan { + start: 0, + end: 47, + first: 9, + step: 1, + fps: 24.0, + looping: true, + cache_first: false, + video: false, + audio: true, + origin, + }; + pb.plan = Some(plan); + }; + let stop = |h: &mut Harness<'_, EffectcraftApp>| (h.state_mut().playback.plan, h.state_mut().playback.playing) = (None, false); + play(&mut h); + for _ in 0..500 { + h.step(); + if chip(&h).as_deref() == Some(PAUSED) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert_eq!(chip(&h).as_deref(), Some(PAUSED)); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + assert_eq!(h.state().ui.status, "Pick not made: stop playback to pick", "said at the click"); + // Not queued: stopped on the same frame, its IDs are read and nothing is picked. + stop(&mut h); + h.run_steps(2); + wait_probe(&mut h); + h.run_steps(4); + assert_eq!(chip(&h).as_deref(), Some("c7_5")); + assert_eq!((selection(&h, &ids), h.state().session.history.undo.len(), builds(&h)), (String::new(), steps, n + 1)); + // Playing over a frame whose IDs are read: the click picks. + play(&mut h); + h.run_steps(4); + assert_eq!(chip(&h).as_deref(), Some("c7_5")); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + assert_eq!((selection(&h, &ids), h.state().session.history.undo.len()), ("c7_5".to_string(), steps + 1)); + stop(&mut h); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A 320 × 180 still whose probe builds are held (`hold`), the eyedropper armed and reading. +fn held_session(tag: &str) -> (Harness<'static, EffectcraftApp>, Ids, std::sync::Arc) { + let (mut s, ids) = crypto_session(tag, 320, 180, false); + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), Default::default())); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + h.event(Event::PointerMoved(comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap())); + h.run_steps(2); + started(&h, before); + (h, ids, hold) +} + +/// Lets the held builds go and runs passes for a second, then returns whether one said a pick +/// wasn't made, and the undo labels. +fn let_go(h: &mut Harness<'static, EffectcraftApp>, hold: &std::sync::atomic::AtomicBool) -> (bool, Vec) { + hold.store(false, std::sync::atomic::Ordering::Release); + let mut said = false; + for _ in 0..200 { + h.step(); + said |= h.state().ui.status.starts_with("Pick not made"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + (said, h.state().session.history.undo.iter().map(|(l, _)| l.clone()).collect()) +} + +fn button(h: &mut Harness<'static, EffectcraftApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); +} + +/// A click is the frame and the project shown at its press: a frame step or an edit before its +/// release (an arrow key or PageDown that the window delivers before the release, Windows +/// round 3) drops it and says so, and it isn't made afterwards. Keys with the release, after +/// the click, drop it too. Without a change it is made (the control). +#[test] +fn a_click_is_dropped_when_the_frame_or_project_changes_before_its_release_or_ids() { + type Change = Box)>; + let exec = |c: &'static str, p: serde_json::Value| -> Change { Box::new(move |h| drop(h.state_mut().session.execute_checked(c, p.clone()).unwrap())) }; + let key = |key: egui::Key| -> Change { + Box::new(move |h| { + for pressed in [true, false] { + h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } + }) + }; + let cases: Vec<(&str, Option, bool)> = vec![ + ("control", None, false), + ("time.nextFrame before the release", Some(exec("time.nextFrame", json!({}))), false), + ("layer.nudge before the release", Some(exec("layer.nudge", json!({"x": 2}))), false), + ("PageDown with the release", Some(key(egui::Key::PageDown)), true), + ("Right with the release", Some(key(egui::Key::ArrowRight)), true), + ]; + // And with the key in the pass whose end makes the click (its IDs stored just before). + for k in [egui::Key::PageDown, egui::Key::ArrowRight] { + let (mut h, ids, hold) = held_session(&format!("atomic-arrive-{k:?}")); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + hold.store(false, std::sync::atomic::Ordering::Release); + for _ in 0..2500 { + if h.state().session.crypto_probe_stats().0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(h.state().session.crypto_probe_stats().0, "{k:?}: the probe is stored, no pass since"); + key(k)(&mut h); + h.step(); + let said = h.state().ui.status.starts_with("Pick not made"); + let (_, labels) = let_go(&mut h, &hold); + assert_eq!(selection(&h, &ids), "", "{k:?} as the IDs arrive: made ({labels:?})"); + assert!(said && !labels.iter().any(|l| l == "Pick Cryptomatte"), "{k:?}: {labels:?} said {said}"); + let _ = std::fs::remove_dir_all(&ids.dir); + } + for (case, change, with_release) in cases { + let (mut h, ids, hold) = held_session(&format!("atomic-{}", case.len())); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let (t0, rev0, steps) = (h.state().session.time().seconds(), h.state().session.revision, h.state().session.history.undo.len()); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + if let Some(c) = change.as_ref().filter(|_| !with_release) { + c(&mut h); + } + button(&mut h, a, false, Modifiers::NONE); + if let Some(c) = change.as_ref().filter(|_| with_release) { + c(&mut h); + } + h.step(); + let (said, labels) = let_go(&mut h, &hold); + if change.is_none() { + assert_eq!((selection(&h, &ids), labels.len(), said), ("c1_1".to_string(), steps + 1, false), "{case}: the click is made"); + } else { + assert!(h.state().session.time().seconds() != t0 || h.state().session.revision != rev0, "{case}: changed nothing"); + assert_eq!(selection(&h, &ids), "", "{case}: the stale click was made ({labels:?})"); + assert!(!labels.iter().any(|l| l == "Pick Cryptomatte"), "{case}: {labels:?}"); + assert!(said, "{case}: not said"); + } + let _ = std::fs::remove_dir_all(&ids.dir); + } +} + +/// A waiting click keeps the resolution it was made at: when the viewer then shows the frame at +/// another (a zoom with Auto, or a frame cached at another resolution arriving), it is still +/// made, before the next click (Windows round 3: a plain click lost, 1 run in 51). +#[test] +fn a_waiting_click_is_made_when_the_shown_resolution_changes() { + let (mut h, ids, hold) = held_session("rescale"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let (before, steps) = (shown_scale(&h), h.state().session.history.undo.len()); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.state_mut().ui.viewer.zoom = Some(0.3); + for _ in 0..400 { + h.step(); + if shown_scale(&h) != before { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_ne!(shown_scale(&h), before, "the shown resolution changes"); + let b = comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap(); + h.event(Event::PointerMoved(b)); + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.step(); + for pressed in [true, false] { + button(&mut h, b, pressed, Modifiers::SHIFT); + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + let (said, labels) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), labels.len() - steps, said), ("c1_1, c3_3".to_string(), 2, false), "{labels:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A Shift+click still held when the click before it is made (its build ends while the button +/// is down) is kept: the eyedropper's own pick is not "the project changed". +/// It is made after the first, in order. +#[test] +fn a_click_held_while_the_one_before_is_made_is_kept() { + let (mut h, ids, hold) = held_session("held-second"); + let (a, b) = (comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(), comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap()); + let steps = h.state().session.history.undo.len(); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.event(Event::PointerMoved(b)); + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.step(); + button(&mut h, b, true, Modifiers::SHIFT); + h.step(); + hold.store(false, std::sync::atomic::Ordering::Release); + for _ in 0..400 { + if selection(&h, &ids) == "c1_1" { + break; + } + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(selection(&h, &ids), "c1_1", "the first click is made while the second is held"); + button(&mut h, b, false, Modifiers::SHIFT); + h.step(); + let at_release = h.state().ui.status.clone(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + let (said, labels) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), labels.len() - steps, said), ("c1_1, c3_3".to_string(), 2, false), "status at the release: {at_release:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A drop clears every waiting click at once: a click made right after it starts a queue of its +/// own (on the new revision) and is made. +#[test] +fn a_drop_clears_the_whole_queue() { + let (mut h, ids, hold) = held_session("drop-all"); + let (a, b) = (comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(), comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap()); + let steps = h.state().session.history.undo.len(); + for p in [a, b] { + for pressed in [true, false] { + button(&mut h, p, pressed, Modifiers::NONE); + h.step(); + } + } + // The edit drops both; a press in the pass that drops them and its release in the next are + // the next click's, made on its own (not dropped with a click left over from the queue). + h.state_mut().session.execute_checked("layer.nudge", json!({"x": 2})).unwrap(); + let c = comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerMoved(c)); + ev.push(Event::PointerButton { pos: c, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + assert!(h.state().ui.status.starts_with("Pick not made"), "{:?}", h.state().ui.status); + button(&mut h, c, false, Modifiers::NONE); + h.step(); + let (_, labels) = let_go(&mut h, &hold); + assert_eq!(selection(&h, &ids), "c3_3", "the click after the drop is made: {labels:?}"); + assert_eq!(labels.iter().filter(|l| *l == "Pick Cryptomatte").count(), 1, "{labels:?} (from {steps})"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The drag distance is the press to the release: the pointer moving on after the release, in +/// the same pass, doesn't cancel the click (Windows round 4, B). +#[test] +fn a_click_measures_its_drag_to_the_release_not_the_pointer_after() { + let (mut h, ids, hold) = held_session("release-pos"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + // In one pass (`Harness::event` would give each event its own pass). + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + ev.push(Event::PointerMoved(a + vec2(80.0, 0.0))); + h.step(); + let at_release = h.state().ui.status.clone(); + let (said, _) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), said), ("c1_1".to_string(), false), "status at the release: {at_release:?}"); + assert_ne!(at_release, "Drag cancelled the pick"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Playing, a click whose frame moved on between its press and release says "stop playback" +/// (not that the frame changed), and the message stays its 5 s even though another message +/// (the eyedropper's hint) showed for longer before it (Windows round 4, C). +#[test] +fn a_click_while_playing_says_stop_playback_and_the_message_stays() { + let (s, _ids) = crypto_session("playmove", 960, 540, true); + let mut h = harness_dt(s, vec2(1280.0, 800.0), 1.0, 420.0, 0.02); + arm(&mut h, &_ids); + let a = comp_to_screen(&h.ctx, [260.0, 150.0]).unwrap(); + h.event(Event::PointerMoved(a)); + let hint = h.state().ui.status.clone(); + assert!(!hint.is_empty(), "the eyedropper's hint shows"); + // The hint has shown for 4.6 s when the click comes. + let t0 = h.ctx.input(|i| i.time); + while h.ctx.input(|i| i.time) - t0 < 4.6 { + h.step(); + } + assert_eq!(h.state().ui.status, hint); + play_at(&mut h, 9); + h.step(); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + play_at(&mut h, 10); + button(&mut h, a, false, Modifiers::NONE); + h.step(); + assert!(h.state().playback.playing); + assert_eq!(h.state().ui.status, "Pick not made: stop playback to pick", "said at the release"); + let t1 = h.ctx.input(|i| i.time); + while h.ctx.input(|i| i.time) - t1 < 1.5 { + h.step(); + } + assert_eq!(h.state().ui.status, "Pick not made: stop playback to pick", "still shown 1.5 s later"); + let _ = std::fs::remove_dir_all(&_ids.dir); +} + +/// A press held still past egui's click time (0.8 s) and let go before any pass sees it held +/// that long is neither a click nor a drag to egui (an idle app doesn't repaint while a mouse +/// button is held still): it is still a click here, and is made. +#[test] +fn a_long_still_press_with_no_pass_in_between_is_a_click() { + let (mut h, ids, hold) = held_session("long-press"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + // Passes 0.25 s apart: the last one held is at 0.75 s, the release at 1.0 s. + let t0 = h.ctx.input(|i| i.time); + while h.ctx.input(|i| i.time) - t0 + 0.25 < 0.8 { + h.step(); + } + button(&mut h, a, false, Modifiers::NONE); + h.step(); + let at_release = h.state().ui.status.clone(); + let (said, labels) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), said), ("c1_1".to_string(), false), "status at the release: {at_release:?}, {labels:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +// Several primary-button events in one pass (Windows round 5, c). +/// An armed eyedropper with the IDs read and hovered at two points (c1_1 and c3_3). +fn ready_at_two_ids(tag: &str) -> (Harness<'static, EffectcraftApp>, Ids, Pos2, Pos2) { + let (s, ids) = crypto_session(tag, 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let (a, b) = (comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(), comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap()); + assert_eq!(hover(&mut h, a), "c1_1"); + assert_eq!(hover(&mut h, b), "c3_3"); + (h, ids, a, b) +} + +fn pick_steps(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.iter().filter(|(l, _)| l == "Pick Cryptomatte").count() +} + +/// One click's release and the next one's Shift-press in one pass (a loaded UI thread, Windows +/// round 5 c): both are made, in order, the second as an add. +#[test] +fn a_release_and_the_next_press_in_one_pass_are_both_made() { + let (mut h, ids, a, b) = ready_at_two_ids("batch1"); + h.event(Event::PointerMoved(a)); + h.step(); + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + ev.push(Event::PointerMoved(b)); + ev.push(Event::ModifiersChanged(Modifiers::SHIFT)); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }); + h.step(); + let mid = (selection(&h, &ids), h.state().ui.status.clone()); + h.event(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::SHIFT }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(4); + assert_eq!(selection(&h, &ids), "c1_1, c3_3", "mid {mid:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Two whole clicks in one pass: both are made, two steps. +#[test] +fn two_whole_clicks_in_one_pass_are_both_made() { + let (mut h, ids, a, b) = ready_at_two_ids("batch2"); + h.event(Event::PointerMoved(a)); + h.step(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + ev.push(Event::PointerMoved(b)); + ev.push(Event::ModifiersChanged(Modifiers::SHIFT)); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::SHIFT }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(4); + assert_eq!((selection(&h, &ids), pick_steps(&h)), ("c1_1, c3_3".to_string(), 2)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Arm, Esc (which clears the status early), re-arm 6 s later: the hint shows again for its own +/// time instead of inheriting the old one's. +#[test] +fn the_hint_shows_again_after_esc_and_rearm() { + let (s, ids) = crypto_session("hint", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let hint = h.state().ui.status.clone(); + assert!(!hint.is_empty()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none() && h.state().ui.status.is_empty(), "Esc ends it: {:?}", h.state().ui.status); + h.run_steps(24); + arm(&mut h, &ids); + let after: Vec = (0..4) + .map(|_| { + h.step(); + h.state().ui.status.clone() + }) + .collect(); + assert_eq!(after[3], hint, "the hint after re-arming"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// One whole click (press and release) in one pass, the IDs read: it is made. +#[test] +fn a_whole_click_in_one_pass_is_made() { + let (mut h, ids, a, _) = ready_at_two_ids("onepass"); + h.event(Event::PointerMoved(a)); + h.step(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + h.run_steps(4); + assert_eq!((selection(&h, &ids), pick_steps(&h)), ("c1_1".to_string(), 1)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The same while the IDs are being read: the click waits and is made. +#[test] +fn a_whole_click_in_one_pass_waits_while_reading_and_is_made() { + let (mut h, ids, hold) = held_session("s6-onepass-held"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + let (said, labels) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), said), ("c1_1".to_string(), false), "{labels:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Playback from `frame` (an audio-only preview, so the frame is the one set here). +fn play_at(h: &mut Harness<'_, EffectcraftApp>, frame: i64) { + set_frame(h, frame); + let now = h.ctx.input(|i| i.time); + let pb = &mut h.state_mut().playback; + (pb.playing, pb.start_wall, pb.start_frame) = (true, now, frame); + let plan = effectcraft_engine::preview::PreviewPlan { + start: 0, + end: 47, + first: frame, + step: 1, + fps: 24.0, + looping: true, + cache_first: false, + video: false, + audio: true, + origin: effectcraft_engine::time::Tick::ZERO, + }; + pb.plan = Some(plan); +} + +/// Clicks waiting for their IDs when playback starts are dropped, and the status says to stop +/// playback (not that the frame changed). +#[test] +fn waiting_clicks_dropped_by_playback_say_stop_playback() { + let (mut s, ids) = crypto_session("play-drop", 320, 180, true); + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), Default::default())); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + started(&h, before); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + assert!(!h.state().ui.status.starts_with("Pick not made"), "{:?}", h.state().ui.status); + play_at(&mut h, 9); + h.step(); + assert_eq!(h.state().ui.status, "Pick not made: stop playback to pick"); + h.state_mut().playback.playing = false; + let (_, labels) = let_go(&mut h, &hold); + assert_eq!(selection(&h, &ids), "", "{labels:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Space pressed while the button is held (to pan) cancels the press: nothing is picked at the +/// release. +#[test] +fn space_while_the_button_is_held_cancels_the_click() { + let (mut h, ids, a, _) = ready_at_two_ids("space-mid"); + h.event(Event::PointerMoved(a)); + h.step(); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.event(space(true)); + h.step(); + button(&mut h, a, false, Modifiers::NONE); + h.step(); + h.event(space(false)); + h.run_steps(4); + assert_eq!(selection(&h, &ids), ""); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A Shift-click whose Shift is let go in the same pass is still an add: the modifiers are the +/// press's own, not the pass's end state. +#[test] +fn a_shift_click_in_one_pass_with_shift_let_go_adds() { + let (mut h, ids, a, b) = ready_at_two_ids("shift"); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.run_steps(4); + assert_eq!(selection(&h, &ids), "c1_1"); + h.event(Event::PointerMoved(b)); + h.step(); + let ev = &mut h.input_mut().events; + ev.push(Event::ModifiersChanged(Modifiers::SHIFT)); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::SHIFT }); + ev.push(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + h.run_steps(4); + assert_eq!((selection(&h, &ids), pick_steps(&h)), ("c1_1, c3_3".to_string(), 2)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The same message set again while it still shows gets its own 5 s: pick, undo 2.5 s later, +/// pick the same ID again (Windows round 6, A1). +#[test] +fn the_same_status_set_again_while_shown_gets_its_own_5_s() { + let (mut h, ids, a, _) = ready_at_two_ids("status-again"); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.run_steps(2); + let msg = h.state().ui.status.clone(); + assert_eq!(msg, "Cryptomatte Selection: c1_1"); + h.run_steps(10); + let _ = h.state_mut().session.execute("edit.undo", json!({})); + assert_eq!((selection(&h, &ids), h.state().ui.status.clone()), (String::new(), msg.clone())); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.run_steps(12); + assert_eq!((selection(&h, &ids), h.state().ui.status.clone()), ("c1_1".to_string(), msg), "3 s after the second pick"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A press, a PageDown and the release in one slow pass: the click is the frame on screen at the +/// press, so it is dropped and says so, not made on the next frame (Windows round 6, R-i pd8). +#[test] +fn a_frame_step_between_press_and_release_in_one_pass_drops_the_click() { + let (mut h, ids, a, _) = ready_at_two_ids("key-one-pass"); + let key = |pressed| Event::Key { key: egui::Key::PageDown, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + ev.push(key(true)); + ev.push(key(false)); + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + assert!(h.state().session.time().seconds() > 0.0, "the PageDown stepped"); + assert!(h.state().ui.status.starts_with("Pick not made"), "{:?}", h.state().ui.status); + for _ in 0..40 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!((selection(&h, &ids), pick_steps(&h)), (String::new(), 0)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The window loses the focus while the button is held (the release may go to another window): +/// the press is dropped, so a later release alone picks nothing. +#[test] +fn losing_the_focus_drops_a_held_press() { + let (mut h, ids, a, _) = ready_at_two_ids("focus-lost"); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + h.event(Event::WindowFocused(false)); + h.step(); + h.event(Event::WindowFocused(true)); + h.step(); + button(&mut h, a, false, Modifiers::NONE); + h.run_steps(4); + assert_eq!((selection(&h, &ids), pick_steps(&h)), (String::new(), 0)); + let _ = std::fs::remove_dir_all(&ids.dir); +} diff --git a/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs new file mode 100644 index 000000000..32635084a --- /dev/null +++ b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs @@ -0,0 +1,1328 @@ +//! Global undo / redo with the Cryptomatte eyedropper (#473): every pick is one undo step that +//! never merges with a neighbouring edit, and Ctrl+Z / Ctrl+Shift+Z (the app's bindings; Ctrl+Y +//! is New Solid, as in After Effects) walk a model of the expected states over long sequences of +//! picks mixed with ordinary edits, at Full and Third, in two window sizes. After every step the +//! Selection, the other properties, the Edit menu's Undo / Redo entries, the History panel, the +//! viewer's chip and highlight, and the eyedropper's armed state are checked. Undo while the IDs +//! are being read and with clicks waiting, across Open / Revert / New, and after Esc. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, Resolution}; +use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::{self, PanelKind, SplitSize}; +use effectcraft_ui_egui::panels::viewer::comp_to_screen; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +const READING: &str = "Reading Cryptomatte IDs…"; +const HINT: &str = "Click: replace · Shift: add · Alt or Ctrl: remove"; +const CRYPTO: &str = "ec.3d.cryptomatte"; +/// Settings ▸ General ▸ Undo Levels in these tests (the most allowed). +const LEVELS: u32 = 99; +/// The app's Cmd (Ctrl+Z / Ctrl+Shift+Z) as winit reports it: Ctrl off macOS (`command` is Ctrl +/// there), Cmd on macOS. +const MAC: bool = cfg!(target_os = "macos"); +const CTRL: Modifiers = Modifiers { alt: false, ctrl: !MAC, shift: false, mac_cmd: MAC, command: true }; +const CTRL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: !MAC, shift: true, mac_cmd: MAC, command: true }; + +// --------------------------------------------------------------------------------------------- +// Fixture (as in ui_crypto_pick.rs): an EXR whose CryptoObject has an ID per 37 × 29 px cell. + +fn cell(x: usize, y: usize) -> String { + format!("c{}_{}", x / 37, y / 29) +} + +fn crypto_exr(dir: &std::path::Path, name: &str, w: usize, h: usize) -> String { + use exr::prelude::*; + let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(&cell(i % w, i / w)))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let one = || vec![1.0f32; w * h]; + let channels = AnyChannels::sort( + vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(), + ); + let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let names: serde_json::Map = (0..w.div_ceil(37)) + .flat_map(|x| (0..h.div_ceil(29)).map(move |y| cell(x * 37, y * 29))) + .map(|n| (n.clone(), json!(format!("{:08x}", crypto_hash(&n))))) + .collect(); + for (key, v) in [("name", "CryptoObject".to_string()), ("manifest", Value::Object(names).to_string())] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{key}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + let path = dir.join(name); + Image::from_layer(layer).write().to_file(&path).unwrap(); + path.to_string_lossy().to_string() +} + +struct Fix { + comp: u64, + /// The footage layer (Cryptomatte EXR) and the precomp layer of a 3D comp, each with a + /// Cryptomatte effect, and a plain solid. + plate: u64, + pre: u64, + other: u64, + inner: u64, + dir: std::path::PathBuf, +} + +/// A `w` × `h` comp: the plate (optionally behind a heavy blur, so its probe takes a while), +/// with a precomp of two 3D solids (`PA` full size, `PB` a 60 px strip at the left) on top, and a +/// solid. Each Cryptomatte layer has the effect. The history starts empty. +fn session(tag: &str, w: usize, h: usize, blur: bool, precomp: bool) -> (Session, Fix) { + let dir = std::env::temp_dir().join(format!("ec-ui-crypick-undo-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = crypto_exr(&dir, "plate.exr", w, h); + let mut s = effectcraft_host::session(); + let f = populate(&mut s, &path, w, h, blur, precomp, dir); + (s, f) +} + +/// [`session`]'s project, built in `s`'s current project from the EXR at `path`. +fn populate(s: &mut Session, path: &str, w: usize, h: usize, blur: bool, precomp: bool, dir: std::path::PathBuf) -> Fix { + let r = s.execute("file.import", json!({"paths": [path]})).unwrap(); + let footage = r["items"][0].as_u64().unwrap(); + let comp = s.execute("comp.new", json!({"name": "Crypto", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap(); + let comp = comp.get("comp").and_then(Value::as_u64).unwrap_or_else(|| s.active_comp_id().unwrap().0); + let plate = s.execute("layer.addItem", json!({"item": footage})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(plate)]; + if blur { + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + s.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": 40.0})).unwrap(); + } + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let (mut pre, mut inner) = (0, 0); + if precomp { + let a = s.execute("layer.newSolid", json!({"name": "PA", "width": w, "height": h})).unwrap()["layer"].clone(); + let b = s.execute("layer.newSolid", json!({"name": "PB", "width": 60, "height": h})).unwrap()["layer"].clone(); + s.execute_checked("prop.set", json!({"layer": b, "path": "transform/position", "value": [30.0, h as f64 / 2.0, -10.0]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap(); + inner = s.project.items.iter().find(|(_, i)| i.name == "Inner").unwrap().0.0; + pre = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Inner").unwrap().id.0; + s.execute_checked("effect.apply", json!({"layers": [pre], "effect": "Cryptomatte"})).unwrap(); + } + let other = s.execute("layer.newSolid", json!({"name": "Other", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(plate)]; + s.history = Default::default(); + s.prefs.general.undo_levels = LEVELS; + Fix { comp, plate, pre, other, inner, dir } +} + +/// Composition viewer and Effect Controls side by side, the Timeline and the History panel below. +fn harness(s: Session, size: egui::Vec2) -> Harness<'static, EffectcraftApp> { + let mut app = EffectcraftApp::new(s); + app.ui.viewer.show_layer_controls = false; + app.ui.dock = dock::docked(dock::vsplit( + SplitSize::FixedSecond(200.0), + dock::hsplit(SplitSize::FixedSecond(420.0), dock::tabs(&[PanelKind::Composition], 0), dock::tabs(&[PanelKind::EffectControls], 0)), + dock::hsplit(SplitSize::FixedSecond(300.0), dock::tabs(&[PanelKind::Timeline], 0), dock::tabs(&[PanelKind::History], 0)), + )); + let mut h = Harness::builder().with_size(size).with_pixels_per_point(1.0).with_step_dt(0.25).build_eframe(|_| app); + settle(&mut h); + h +} + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..1500 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().is_ok_and(|v| *v > 0.0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(4); +} + +fn chip(h: &Harness<'_, EffectcraftApp>) -> Option { + h.state().auto.find("viewer.cryptoPick").map(|e| e.label.clone()) +} + +fn highlight(h: &Harness<'_, EffectcraftApp>) -> bool { + h.state().auto.find("viewer.cryptoPick.highlight").is_some() +} + +fn wait_probe(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..3000 { + h.step(); + if chip(h).as_deref() != Some(READING) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the probe never finished"); +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) -> String { + h.event(Event::PointerMoved(pos)); + h.step(); + wait_probe(h); + h.step(); + chip(h).unwrap_or_default() +} + +/// Press and release at `pos` with `modifiers`, without waiting for the probe. +fn press(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2, modifiers: Modifiers) { + h.event(Event::PointerMoved(pos)); + h.event(Event::ModifiersChanged(modifiers)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2, modifiers: Modifiers) { + press(h, pos, modifiers); + wait_probe(h); + h.run_steps(2); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5) +} + +/// The Selection property uid of the layer's (first) Cryptomatte effect. +fn sel_uid(s: &Session, comp: u64, layer: u64) -> Option { + let l = s.project.comp(effectcraft_engine::project::ItemId(comp))?.layer(LayerId(layer))?; + l.effects()?.groups().find(|g| g.match_id == CRYPTO)?.get("selection").map(|p| p.uid) +} + +/// Select `layer` (Effect Controls shows it) and arm its Cryptomatte eyedropper. +fn arm(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, layer: u64) -> u64 { + h.state_mut().session.state.selected_layers = vec![LayerId(layer)]; + h.run_steps(3); + let uid = sel_uid(&h.state().session, f.comp, layer).expect("a Cryptomatte effect"); + let eye = at(h, &format!("effectControls.prop.{uid}.eyedropper")); + click(h, eye, Modifiers::NONE); + assert!(h.state().ui.fx_pick.as_ref().is_some_and(|p| p.kind == "crypto" && p.prop == uid && p.layer == layer), "armed on {layer}"); + uid +} + +/// Type `text` into the Selection field of `layer` (shown in Effect Controls), committed with Enter. +fn type_selection(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, layer: u64, text: &str, commit: bool) { + h.state_mut().session.state.selected_layers = vec![LayerId(layer)]; + h.run_steps(3); + let uid = sel_uid(&h.state().session, f.comp, layer).unwrap(); + let field = at(h, &format!("effectControls.prop.{uid}.value")); + click(h, field, Modifiers::NONE); + h.key_press_modifiers(CTRL, Key::A); + h.step(); + h.event(Event::Text(text.into())); + h.step(); + if commit { + key(h, Key::Enter, Modifiers::NONE); + } +} + +// --------------------------------------------------------------------------------------------- +// Observation and model. + +#[derive(Clone, Debug, PartialEq)] +struct LayerObs { + id: u64, + name: String, + opacity: Value, + opacity_keys: Vec<(f64, Value)>, + position: Value, + /// (match id, Selection uid and value for Cryptomatte) per effect. + effects: Vec<(String, Option<(u64, String)>)>, +} + +impl LayerObs { + fn crypto(&self) -> Option<&(u64, String)> { + self.effects.iter().find_map(|e| e.1.as_ref()) + } + fn crypto_mut(&mut self) -> &mut (u64, String) { + self.effects.iter_mut().find_map(|e| e.1.as_mut()).expect("a Cryptomatte effect") + } +} + +type Obs = Vec; + +fn observe(s: &Session, comp: u64) -> Obs { + let c = s.project.comp(effectcraft_engine::project::ItemId(comp)).unwrap(); + c.layers + .iter() + .map(|l| { + let tr = l.transform().unwrap(); + let op = tr.get("opacity").unwrap(); + let effects = l + .effects() + .map(|fx| { + fx.groups() + .map(|g| { + let sel = (g.match_id == CRYPTO).then(|| { + let p = g.get("selection").unwrap(); + (p.uid, p.value.to_json().as_str().unwrap_or_default().to_string()) + }); + (g.match_id.clone(), sel) + }) + .collect() + }) + .unwrap_or_default(); + LayerObs { + id: l.id.0, + name: l.name.clone(), + opacity: op.value.to_json(), + opacity_keys: op.keys.iter().map(|k| (k.time.seconds(), k.value.to_json())).collect(), + position: tr.get("position").unwrap().value.to_json(), + effects, + } + }) + .collect() +} + +fn sel_of(o: &Obs, id: u64) -> String { + o.iter().find(|l| l.id == id).and_then(|l| l.crypto()).map(|c| c.1.clone()).unwrap_or_default() +} + +fn layer_mut(o: &mut Obs, id: u64) -> &mut LayerObs { + o.iter_mut().find(|l| l.id == id).unwrap_or_else(|| panic!("no layer {id}")) +} + +/// The Selection after a pick of token `t` (as `edit_selection` writes it: `, ` separated). +fn model_pick(sel: &str, t: &str, mode: &str) -> String { + let mut v: Vec = sel.split(", ").filter(|s| !s.is_empty()).map(str::to_string).collect(); + match mode { + "replace" => v = vec![t.to_string()], + "add" => { + if !v.iter().any(|x| x == t) { + v.push(t.to_string()) + } + } + _ => v.retain(|x| x != t), + } + v.join(", ") +} + +fn mode_of(m: Modifiers) -> &'static str { + if m.alt || m.ctrl || m.command { + "remove" + } else if m.shift { + "add" + } else { + "replace" + } +} + +struct Model { + comp: u64, + /// The working line: (label of the step that made it, expected state); `cur` is current. + line: Vec<(String, Obs)>, + cur: usize, + time: f64, + /// (layer, Selection uid) the eyedropper is armed on. + armed: Option<(u64, u64)>, + branches: usize, + /// States per branch (the history bounds them by the undo levels). + branch_states: Vec, + /// Every distinct undo label seen, with the step that made it. + labels: Vec<(String, String)>, + /// Undo / redo steps after which the probe had to be built again, of those checked. + rebuilds: (usize, usize), + checks: usize, +} + +impl Model { + fn new(h: &Harness<'_, EffectcraftApp>, comp: u64) -> Model { + let s = &h.state().session; + Model { + comp, + line: vec![("Original".into(), observe(s, comp))], + cur: 0, + time: s.time_of(effectcraft_engine::project::ItemId(comp)).seconds(), + armed: None, + branches: 0, + branch_states: vec![], + labels: vec![], + rebuilds: (0, 0), + checks: 0, + } + } + fn state(&self) -> &Obs { + &self.line[self.cur].1 + } + /// A step that records: the redo line becomes a branch. + fn record(&mut self, label: &str, o: Obs, what: &str) { + if self.cur + 1 < self.line.len() { + self.branches += 1; + self.branch_states.push(self.line.len() - 1 - self.cur); + // The oldest branches go beyond the undo levels (no branch here grows from another). + while self.branch_states.iter().sum::() > LEVELS as usize { + self.branch_states.remove(0); + self.branches -= 1; + } + } + self.line.truncate(self.cur + 1); + self.line.push((label.to_string(), o)); + self.cur += 1; + assert!(self.cur <= LEVELS as usize, "the model doesn't trim the working line"); + if !self.labels.iter().any(|(l, _)| l == label) { + self.labels.push((label.to_string(), what.to_string())); + } + } + /// Undo or redo moved to another state: the pick ends when its effect is gone there. + fn moved(&mut self) { + if let Some((l, uid)) = self.armed + && !self.state().iter().any(|x| x.id == l && x.crypto().is_some_and(|c| c.0 == uid)) + { + self.armed = None; + } + } +} + +/// The comp point of layer point `p` (no scale, rotation or anchor change in these tests). +fn comp_point(o: &Obs, layer: u64, p: [f64; 2], size: [f64; 2]) -> [f32; 2] { + let l = o.iter().find(|l| l.id == layer).unwrap(); + let pos = &l.position; + let (dx, dy) = (pos[0].as_f64().unwrap() - size[0] / 2.0, pos[1].as_f64().unwrap() - size[1] / 2.0); + [(p[0] + dx) as f32, (p[1] + dy) as f32] +} + +/// Where the checks hover on each Cryptomatte layer (layer point) and the ID they must name. +fn probe_point(f: &Fix, layer: u64) -> ([f64; 2], &'static str) { + if layer == f.plate { ([55.5, 43.5], "c1_1") } else { ([20.0, 90.0], "PB") } +} + +/// Every check after a step: state, Edit menu, History panel, time, armed state, chip, highlight. +fn verify(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, m: &mut Model, size: [f64; 2], what: &str) { + m.checks += 1; + // (Two passes: the panels draw what the last edit left, and their rows are current.) + h.run_steps(2); + let got = observe(&h.state().session, m.comp); + assert_eq!(got, *m.state(), "{what}: project state"); + // Edit ▸ Undo / Redo: labels and availability. + let items = effectcraft_ui_egui::menus::menu_items(h.state()); + let item = |id: &str| items.iter().find(|i| i.id == id).map(|i| (i.label.clone(), i.enabled)).unwrap(); + let want_undo = if m.cur > 0 { (format!("Undo {}", m.line[m.cur].0), true) } else { ("Can't Undo".to_string(), false) }; + let want_redo = if m.cur + 1 < m.line.len() { (format!("Redo {}", m.line[m.cur + 1].0), true) } else { ("Can't Redo".to_string(), false) }; + assert_eq!((item("edit.undo"), item("edit.redo")), (want_undo, want_redo), "{what}: Edit menu"); + let s = &h.state().session; + assert_eq!((s.history.undo.len(), s.history.redo.len()), (m.cur, m.line.len() - 1 - m.cur), "{what}: history depth"); + // The History panel: the working line's labels, the current state highlighted and in view. + let tree = s.history_tree(); + let line: Vec<&str> = tree.iter().filter(|n| n.line).map(|n| n.label.as_str()).collect(); + let want: Vec<&str> = m.line.iter().map(|l| l.0.as_str()).collect(); + assert_eq!(line, want, "{what}: History panel line"); + let cur = tree.iter().find(|n| n.current).unwrap(); + assert_eq!(cur.label, m.line[m.cur].0, "{what}: History current"); + assert_eq!(s.history.branches.len(), m.branches, "{what}: History branches"); + let row = h.state().auto.find(&format!("history.state.{}", cur.index)).map(|e| e.label.clone()); + assert_eq!(row.as_deref(), Some(m.line[m.cur].0.as_str()), "{what}: History panel row of the current state"); + // The time is not part of the history. + let t = s.time_of(effectcraft_engine::project::ItemId(m.comp)).seconds(); + assert!((t - m.time).abs() < 1e-9, "{what}: time {t} != {}", m.time); + // The eyedropper: armed where the model says, the chip names what is under the pointer + // (selected or not as the Selection says), and the hovered ID is highlighted. + let pick = h.state().ui.fx_pick.as_ref().map(|p| (p.layer, p.prop)); + assert_eq!(pick, m.armed, "{what}: armed"); + match m.armed { + Some((layer, _)) => { + let (lp, name) = probe_point(f, layer); + let p = comp_point(m.state(), layer, lp, size); + let sp = comp_to_screen(&h.ctx, p).unwrap(); + let sel = m.state().iter().find(|l| l.id == layer).unwrap().crypto().unwrap().1.clone(); + let selected = sel.split(", ").any(|t| t == name); + let want = if selected { format!("{name} (selected)") } else { name.to_string() }; + assert_eq!(hover(h, sp), want, "{what}: chip"); + assert!(highlight(h), "{what}: the hovered ID is highlighted"); + } + None => { + h.step(); + assert_eq!(chip(h), None, "{what}: no chip when disarmed"); + assert!(!highlight(h), "{what}: no highlight when disarmed"); + } + } +} + +/// Undo (`redo` false) or redo with the keyboard, then check against the model. Counts whether +/// the probe was built again for the step. +fn step_history(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, m: &mut Model, size: [f64; 2], redo: bool, what: &str) { + let can = if redo { m.cur + 1 < m.line.len() } else { m.cur > 0 }; + let builds = h.state().session.crypto_probe_stats().1; + // A step that changed only Selections (a pick or a typed Selection). + let only_sel = can && { + let other = if redo { m.cur + 1 } else { m.cur - 1 }; + let strip = |o: &Obs| { + o.iter() + .map(|l| LayerObs { effects: l.effects.iter().map(|e| (e.0.clone(), e.1.as_ref().map(|c| (c.0, String::new())))).collect(), ..l.clone() }) + .collect::>() + }; + strip(&m.line[other].1) == strip(m.state()) + }; + if redo { + key(h, Key::Z, CTRL_SHIFT); + } else { + key(h, Key::Z, CTRL); + } + if can { + if redo { + m.cur += 1; + } else { + m.cur -= 1; + } + } + m.moved(); + let armed = m.armed.is_some(); + verify(h, f, m, size, what); + if armed && only_sel { + m.rebuilds.1 += 1; + if h.state().session.crypto_probe_stats().1 > builds { + m.rebuilds.0 += 1; + } + } +} + +/// One forward step of the long sequence. +enum Step { + /// Click at a layer point of the armed layer: the token it names and whether it is an edit. + Pick([f64; 2], Modifiers, &'static str), + /// Click where the armed layer has no ID: no step. + PickEmpty, + Arm(u64), + Esc, + Opacity(u64, f64, Option<&'static str>), + Position(u64, [f64; 2]), + Type(u64, &'static str), + OpacityKey(u64, f64, f64), + Time(f64), + RemoveCrypto(u64), + ApplyCrypto(u64), + DeleteLayer(u64), + AddPrecompLayer, + /// The plate's blur (before the Cryptomatte effect): drops the probe, slow to rebuild. + Blur(u64, f64), +} + +fn run_step(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, m: &mut Model, size: [f64; 2], step: &Step, n: usize) { + let mut o = m.state().clone(); + let before = h.state().session.history.undo.len(); + let (what, records): (String, bool) = match step { + Step::Pick(lp, mods, token) => { + let (layer, _) = m.armed.expect("armed"); + let p = comp_point(&o, layer, *lp, size); + click(h, comp_to_screen(&h.ctx, p).unwrap(), *mods); + let c = layer_mut(&mut o, layer).crypto_mut(); + let next = model_pick(&c.1, token, mode_of(*mods)); + let changed = next != c.1; + c.1 = next; + (format!("#{n} pick {token} {}", mode_of(*mods)), changed) + } + Step::PickEmpty => { + let (layer, _) = m.armed.expect("armed"); + // Just off the layer's left or right edge, inside the viewer. + let area = h.state().auto.find("viewer.fxPick").unwrap().rect; + let inside = |p: Pos2| p.x > area[0] + 2.0 && p.x < area[0] + area[2] - 2.0 && p.y > area[1] + 40.0 && p.y < area[1] + area[3] - 2.0; + let sp = [[-6.0, 120.0], [size[0] + 6.0, 120.0], [100.0, size[1] + 6.0]] + .into_iter() + .map(|lp| comp_to_screen(&h.ctx, comp_point(&o, layer, lp, size)).unwrap()) + .find(|p| inside(*p)) + .expect("an empty point in the viewer"); + assert_eq!(hover(h, sp), HINT); + click(h, sp, Modifiers::NONE); + (format!("#{n} click off the IDs"), false) + } + Step::Arm(layer) => { + let uid = arm(h, f, *layer); + m.armed = Some((*layer, uid)); + (format!("#{n} arm {layer}"), false) + } + Step::Esc => { + key(h, Key::Escape, Modifiers::NONE); + m.armed = None; + (format!("#{n} Esc"), false) + } + Step::Opacity(layer, v, merge) => { + let mut q = json!({"comp": f.comp, "layer": layer, "path": "transform/opacity", "value": v}); + if let Some(k) = merge { + q["merge"] = json!(k); + } + h.state_mut().session.execute_checked("prop.set", q).unwrap(); + layer_mut(&mut o, *layer).opacity = json!(v); + (format!("#{n} opacity {layer} {v} merge {merge:?}"), true) + } + Step::Position(layer, v) => { + let l = layer_mut(&mut o, *layer); + let mut p = l.position.clone(); + (p[0], p[1]) = (json!(v[0]), json!(v[1])); + h.state_mut().session.execute_checked("prop.set", json!({"comp": f.comp, "layer": layer, "path": "transform/position", "value": p})).unwrap(); + l.position = p; + (format!("#{n} position {layer} {v:?}"), true) + } + Step::Type(layer, text) => { + type_selection(h, f, *layer, text, true); + layer_mut(&mut o, *layer).crypto_mut().1 = text.to_string(); + // Typing selects the layer; the pick (if any) stays where it was. + (format!("#{n} type {text:?}"), true) + } + Step::OpacityKey(layer, t, v) => { + h.state_mut() + .session + .execute_checked("prop.addKey", json!({"comp": f.comp, "layer": layer, "path": "transform/opacity", "time": t, "value": v})) + .unwrap(); + layer_mut(&mut o, *layer).opacity_keys.push((*t, json!(v))); + (format!("#{n} opacity key {layer} {t}s {v}"), true) + } + Step::Time(t) => { + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(*t)); + m.time = *t; + settle(h); + (format!("#{n} time {t}"), false) + } + Step::RemoveCrypto(layer) => { + let uid = o.iter().find(|l| l.id == *layer).unwrap().effects.iter().position(|e| e.0 == CRYPTO).unwrap(); + h.state_mut().session.execute_checked("effect.remove", json!({"comp": f.comp, "layer": layer, "effect": uid + 1})).unwrap(); + layer_mut(&mut o, *layer).effects.retain(|e| e.0 != CRYPTO); + if m.armed.is_some_and(|a| a.0 == *layer) { + m.armed = None; + } + (format!("#{n} remove Cryptomatte from {layer}"), true) + } + Step::ApplyCrypto(layer) => { + h.state_mut().session.execute_checked("effect.apply", json!({"comp": f.comp, "layers": [layer], "effect": "Cryptomatte"})).unwrap(); + let uid = sel_uid(&h.state().session, f.comp, *layer).unwrap(); + layer_mut(&mut o, *layer).effects.push((CRYPTO.into(), Some((uid, String::new())))); + (format!("#{n} apply Cryptomatte to {layer}"), true) + } + Step::DeleteLayer(layer) => { + h.state_mut().session.execute_checked("edit.clear", json!({"comp": f.comp, "layers": [layer]})).unwrap(); + o.retain(|l| l.id != *layer); + (format!("#{n} delete layer {layer}"), true) + } + Step::Blur(layer, v) => { + h.state_mut().session.execute_checked("prop.set", json!({"comp": f.comp, "layer": layer, "path": "effects/#1/blurriness", "value": v})).unwrap(); + (format!("#{n} blur {v}"), true) + } + Step::AddPrecompLayer => { + let r = h.state_mut().session.execute_checked("layer.addItem", json!({"comp": f.comp, "item": f.inner})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + let now = observe(&h.state().session, f.comp); + let new = now.iter().find(|l| l.id == id).unwrap().clone(); + assert!(new.name.starts_with("Inner") && new.opacity == json!(100.0) && new.effects.is_empty(), "a fresh precomp layer: {new:?}"); + let at = now.iter().position(|l| l.id == id).unwrap(); + o.insert(at, new); + (format!("#{n} add precomp layer {id}"), true) + } + }; + h.run_steps(2); + let after = h.state().session.history.undo.len(); + if records { + assert_eq!(after, before + 1, "{what}: exactly one undo step"); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + m.record(&label, o, &what); + } else { + assert_eq!(after, before, "{what}: no undo step"); + assert_eq!(m.state(), &o, "{what}: no change"); + } + verify(h, f, m, size, &what); +} + +fn long_sequence(tag: &str, size: egui::Vec2, res: Resolution) { + let (w, hh) = (320usize, 180usize); + let dims = [w as f64, hh as f64]; + let (mut s, f) = session(tag, w, hh, false, true); + let cid = effectcraft_engine::project::ItemId(f.comp); + s.set_resolution(cid, res).unwrap(); + let mut h = harness(s, size); + let mut m = Model::new(&h, f.comp); + verify(&mut h, &f, &mut m, dims, "start"); + let ctr = |cx: usize, cy: usize| [cx as f64 * 37.0 + 18.5, cy as f64 * 29.0 + 14.5]; + let (pl, pre, other) = (f.plate, f.pre, f.other); + use Step::*; + let steps = vec![ + Arm(pl), + Pick(ctr(1, 1), Modifiers::NONE, "c1_1"), + Opacity(pl, 80.0, Some("scrub-a")), + Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), + Opacity(pl, 70.0, Some("scrub-a")), + Position(pl, [170.0, 95.0]), + Pick(ctr(1, 1), Modifiers::ALT, "c1_1"), + Opacity(other, 50.0, None), + Pick(ctr(3, 3), Modifiers::COMMAND, "c3_3"), + PickEmpty, + Pick(ctr(5, 2), Modifiers::NONE, "c5_2"), + Pick(ctr(5, 2), Modifiers::SHIFT, "c5_2"), + Pick(ctr(0, 0), Modifiers::ALT, "c0_0"), + Type(pl, "c2_2, c5_2"), + Pick(ctr(4, 4), Modifiers::SHIFT, "c4_4"), + Time(1.0), + OpacityKey(pl, 1.0, 60.0), + Pick(ctr(6, 1), Modifiers::SHIFT, "c6_1"), + Time(0.5), + RemoveCrypto(pl), + Opacity(other, 40.0, None), + ApplyCrypto(pl), + Arm(pl), + Pick(ctr(7, 3), Modifiers::NONE, "c7_3"), + Arm(pre), + Pick([20.0, 90.0], Modifiers::NONE, "PB"), + Pick([200.0, 90.0], Modifiers::SHIFT, "PA"), + Opacity(pre, 30.0, None), + Pick([20.0, 90.0], Modifiers::ALT, "PB"), + DeleteLayer(other), + Pick([20.0, 90.0], Modifiers::NONE, "PB"), + AddPrecompLayer, + Arm(pl), + Pick(ctr(8, 4), Modifiers::SHIFT, "c8_4"), + ]; + for (n, st) in steps.iter().enumerate() { + run_step(&mut h, &f, &mut m, dims, st, n); + } + let total = m.cur; + assert!(total >= 20, "{total} undo steps"); + // Undo all the way with Ctrl+Z, and once more (nothing left). + for k in 0..=total { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo {k}")); + } + assert_eq!(m.cur, 0); + // Redo all the way with Ctrl+Shift+Z, and once more. + for k in 0..=total { + step_history(&mut h, &f, &mut m, dims, true, &format!("redo {k}")); + } + assert_eq!(m.cur, total); + // Ctrl+Y is New Solid (After Effects' binding), not Redo: it opens the Solid Settings dialog + // and changes nothing; Esc closes the dialog. + for _ in 0..3 { + step_history(&mut h, &f, &mut m, dims, false, "undo before Ctrl+Y"); + } + let armed = m.armed; + key(&mut h, Key::Y, CTRL); + assert!(h.state().dialog.is_some(), "Ctrl+Y opens the New Solid dialog"); + assert_eq!(h.state().session.history.redo.len(), 3, "Ctrl+Y doesn't redo"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + if armed.is_some() && h.state().ui.fx_pick.is_none() { + eprintln!("FINDING {tag}: Esc that closed the New Solid dialog also disarmed the eyedropper"); + m.armed = None; + } + verify(&mut h, &f, &mut m, dims, "after Ctrl+Y and Esc"); + // Undo partway, then a new pick: no redo left, the undone steps kept as a branch. + for k in 0..4 { + step_history(&mut h, &f, &mut m, dims, false, &format!("partway {k}")); + } + if m.armed.is_none() { + let l = if m.state().iter().find(|l| l.id == pl).and_then(|l| l.crypto()).is_some() { pl } else { pre }; + run_step(&mut h, &f, &mut m, dims, &Arm(l), 100); + } + let (layer, _) = m.armed.unwrap(); + let (lp, name) = probe_point(&f, layer); + let sel = m.state().iter().find(|l| l.id == layer).unwrap().crypto().unwrap().1.clone(); + let mods = if sel.split(", ").any(|t| t == name) { Modifiers::ALT } else { Modifiers::SHIFT }; + let branches = m.branches; + run_step(&mut h, &f, &mut m, dims, &Pick(lp, mods, name), 101); + assert_eq!((m.branches, m.cur + 1 == m.line.len()), (branches + 1, true), "the redo line became a branch"); + // Random undo / redo (fixed seed), with an occasional new pick or re-arm. + let mut seed: u64 = 0x5eed_c0ff_ee12_3457; + let mut rnd = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for i in 0..400 { + let r = rnd() % 20; + if r < 9 { + step_history(&mut h, &f, &mut m, dims, false, &format!("random {i} undo")); + } else if r < 18 { + step_history(&mut h, &f, &mut m, dims, true, &format!("random {i} redo")); + } else { + match m.armed { + None => { + let l = [pl, pre].into_iter().find(|l| m.state().iter().find(|x| x.id == *l).is_some_and(|x| x.crypto().is_some())); + if let Some(l) = l { + run_step(&mut h, &f, &mut m, dims, &Arm(l), 200 + i); + } + } + Some((layer, _)) => { + let (lp, name) = probe_point(&f, layer); + let sel = m.state().iter().find(|l| l.id == layer).unwrap().crypto().unwrap().1.clone(); + let mods = if sel.split(", ").any(|t| t == name) { Modifiers::ALT } else { Modifiers::SHIFT }; + run_step(&mut h, &f, &mut m, dims, &Pick(lp, mods, name), 200 + i); + } + } + } + } + eprintln!( + "{tag}: {} checks; {} history states on the line, {} branches; probe rebuilt after {} of {} armed undo/redo steps that changed only a Selection", + m.checks, + m.line.len(), + m.branches, + m.rebuilds.0, + m.rebuilds.1 + ); + for (l, w) in &m.labels { + eprintln!("{tag}: LABEL {l:?} (first from {w})"); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// Scenario 2 at Full and Third, in a large and a small window. +#[test] +fn long_sequence_full_large_window() { + long_sequence("full-l", vec2(1400.0, 860.0), Resolution::Full); +} + +#[test] +fn long_sequence_third_large_window() { + long_sequence("third-l", vec2(1400.0, 860.0), Resolution::Third); +} + +#[test] +fn long_sequence_full_small_window() { + long_sequence("full-s", vec2(1024.0, 700.0), Resolution::Full); +} + +#[test] +fn long_sequence_third_small_window() { + long_sequence("third-s", vec2(1024.0, 700.0), Resolution::Third); +} + +// --------------------------------------------------------------------------------------------- +// Scenario 1: each pick kind is one step; clicks off the IDs and picks that change nothing are +// none; clicks queued while reading are one step each, undone in order. + +#[test] +fn each_pick_is_one_step_and_clicks_that_change_nothing_are_none() { + let (s, f) = session("kinds", 320, 180, false, false); + let dims = [320.0, 180.0]; + let mut h = harness(s, vec2(1400.0, 860.0)); + let mut m = Model::new(&h, f.comp); + let ctr = |cx: usize, cy: usize| [cx as f64 * 37.0 + 18.5, cy as f64 * 29.0 + 14.5]; + let pl = f.plate; + let ctrl_only = Modifiers { ctrl: true, ..Modifiers::NONE }; + use Step::*; + let steps = [ + Arm(pl), + Pick(ctr(1, 1), Modifiers::NONE, "c1_1"), // replace + Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), // add + Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), // add, already selected: no step + Pick(ctr(1, 1), Modifiers::ALT, "c1_1"), // remove + Pick(ctr(1, 1), Modifiers::ALT, "c1_1"), // remove, not selected: no step + Pick(ctr(4, 2), Modifiers::SHIFT, "c4_2"), // add + Pick(ctr(4, 2), Modifiers::COMMAND, "c4_2"), // Ctrl (as `command`) removes + Pick(ctr(5, 5), Modifiers::SHIFT, "c5_5"), // add + Pick(ctr(5, 5), ctrl_only, "c5_5"), // Ctrl alone removes too + Pick(ctr(3, 3), Modifiers::NONE, "c3_3"), // replace with what it already is: no step + PickEmpty, // off the layer: no step + Pick(ctr(2, 1), Modifiers::NONE, "c2_1"), // replace + ]; + for (n, st) in steps.iter().enumerate() { + run_step(&mut h, &f, &mut m, dims, st, n); + } + assert_eq!(m.cur, 8, "eight picks changed the Selection"); + for k in 0..9 { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo {k}")); + } + for k in 0..9 { + step_history(&mut h, &f, &mut m, dims, true, &format!("redo {k}")); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// Clicks made while the IDs are read (a 4K plate behind a blur) are one undo step each, in the +/// order clicked; Ctrl+Z takes them back one at a time and Ctrl+Shift+Z makes them again. +#[test] +fn clicks_queued_while_reading_are_one_step_each_and_undo_in_order() { + let (s, f) = session("queue", 3840, 2160, true, false); + let dims = [3840.0, 2160.0]; + let mut h = harness(s, vec2(1280.0, 860.0)); + let mut m = Model::new(&h, f.comp); + let pl = f.plate; + run_step(&mut h, &f, &mut m, dims, &Step::Arm(pl), 0); + run_step(&mut h, &f, &mut m, dims, &Step::Blur(pl, 41.0), 1); + let (a, b) = ([1017.5, 594.5], [1868.5, 1174.5]); + let sp = |h: &Harness<'_, EffectcraftApp>, m: &Model, p: [f64; 2]| comp_to_screen(&h.ctx, comp_point(m.state(), pl, p, dims)).unwrap(); + // The verify above built the probe; drop it again. + run_step(&mut h, &f, &mut m, dims, &Step::Blur(pl, 42.0), 2); + let pa = sp(&h, &m, a); + h.state_mut().session.execute_checked("prop.set", json!({"comp": f.comp, "layer": pl, "path": "effects/#1/blurriness", "value": 43.0})).unwrap(); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + let o = m.state().clone(); + m.record(&label, o, "blur 43"); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + let before = h.state().session.history.undo.len(); + let clicks = [(a, Modifiers::NONE, "c27_20"), (b, Modifiers::SHIFT, "c50_40"), (a, Modifiers::ALT, "c27_20"), (a, Modifiers::SHIFT, "c27_20")]; + for (p, mods, _) in clicks { + let q = sp(&h, &m, p); + press(&mut h, q, mods); + } + assert_eq!(chip(&h).as_deref(), Some(READING), "all four made while reading"); + assert_eq!(h.state().session.history.undo.len(), before, "none made yet"); + wait_probe(&mut h); + h.run_steps(3); + assert_eq!(h.state().session.history.undo.len(), before + 4, "one step per click"); + // The model: each click on the state the previous one left. + for (k, (_, mods, t)) in clicks.iter().enumerate() { + let mut o = m.state().clone(); + let c = layer_mut(&mut o, pl).crypto_mut(); + c.1 = model_pick(&c.1, t, mode_of(*mods)); + let label = h.state().session.history.undo[before + k].0.clone(); + m.record(&label, o, &format!("queued click {k}")); + } + verify(&mut h, &f, &mut m, dims, "after the queued clicks"); + for k in 0..5 { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo {k}")); + } + for k in 0..5 { + step_history(&mut h, &f, &mut m, dims, true, &format!("redo {k}")); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +// --------------------------------------------------------------------------------------------- +// Scenario 3: undo / redo while armed, while reading, and with clicks waiting. + +/// Ctrl+Z or Ctrl+Shift+Z without waiting for anything; the model follows. +fn undo_now(h: &mut Harness<'_, EffectcraftApp>, m: &mut Model, redo: bool) { + if redo { + key(h, Key::Z, CTRL_SHIFT); + m.cur += 1; + } else { + key(h, Key::Z, CTRL); + m.cur -= 1; + } + m.moved(); +} + +fn blur_value(h: &Harness<'_, EffectcraftApp>, f: &Fix) -> Value { + let s = &h.state().session; + let l = s.project.comp(effectcraft_engine::project::ItemId(f.comp)).unwrap().layer(LayerId(f.plate)).unwrap(); + l.effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().value.to_json() +} + +#[test] +fn undo_and_redo_while_reading_and_with_clicks_waiting() { + let (s, f) = session("reading", 3840, 2160, true, false); + let dims = [3840.0, 2160.0]; + let mut h = harness(s, vec2(1280.0, 860.0)); + let mut m = Model::new(&h, f.comp); + let pl = f.plate; + let (a, b) = ([1017.5, 594.5], [1868.5, 1174.5]); + let sp = |h: &Harness<'_, EffectcraftApp>, m: &Model, p: [f64; 2]| comp_to_screen(&h.ctx, comp_point(m.state(), pl, p, dims)).unwrap(); + use Step::*; + for (n, st) in [Arm(pl), Pick(a, Modifiers::NONE, "c27_20"), Pick(b, Modifiers::SHIFT, "c50_40")].iter().enumerate() { + run_step(&mut h, &f, &mut m, dims, st, n); + } + let blur = |h: &mut Harness<'_, EffectcraftApp>, m: &mut Model, v: f64| { + h.state_mut().session.execute_checked("prop.set", json!({"comp": f.comp, "layer": pl, "path": "effects/#1/blurriness", "value": v})).unwrap(); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + let o = m.state().clone(); + m.record(&label, o, &format!("blur {v}")); + }; + + // (a) Undo a pick while armed: the probe goes with the edit, the chip says it is reading, + // then names the right IDs with the right "(selected)" for the state undone to. + let pb = sp(&h, &m, b); + assert_eq!(hover(&mut h, pb), "c50_40 (selected)"); + let builds = h.state().session.crypto_probe_stats().1; + undo_now(&mut h, &mut m, false); + let right_after = chip(&h); + eprintln!("chip right after undoing a pick: {right_after:?}"); + assert_eq!(hover(&mut h, pb), "c50_40", "after undo: not selected any more"); + let pa = sp(&h, &m, a); + assert_eq!(hover(&mut h, pa), "c27_20 (selected)"); + assert!(highlight(&h)); + let rebuilt = h.state().session.crypto_probe_stats().1 - builds; + eprintln!("probes built for undoing one pick: {rebuilt}"); + verify(&mut h, &f, &mut m, dims, "(a) undo a pick while armed"); + + // (b) Ctrl+Z while the IDs are being read (a build in flight for the state undone). + blur(&mut h, &mut m, 41.0); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + undo_now(&mut h, &mut m, false); + assert_eq!(blur_value(&h, &f), json!(40.0)); + verify(&mut h, &f, &mut m, dims, "(b) undo while reading"); + assert_eq!(hover(&mut h, pb), "c50_40", "the probe answers for the state undone to"); + // ... and Ctrl+Shift+Z while reading. + h.state_mut().session.drop_crypto_probe(); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + undo_now(&mut h, &mut m, true); + assert_eq!(blur_value(&h, &f), json!(41.0)); + verify(&mut h, &f, &mut m, dims, "(b) redo while reading"); + + // (c) A click waiting for the probe, then Ctrl+Z: the undo goes first and the waiting click + // is not made on the state undone to (it was aimed at the state before the undo). + blur(&mut h, &mut m, 42.0); + h.event(Event::PointerMoved(pb)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + press(&mut h, pb, Modifiers::SHIFT); + assert_eq!(chip(&h).as_deref(), Some(READING), "the click waits"); + h.state_mut().ui.status.clear(); + undo_now(&mut h, &mut m, false); + // (Asked at once: a status message goes after 5 s, which is 20 steps here.) + assert!(h.state().ui.status.starts_with("Pick not made"), "the dropped click says so: {:?}", h.state().ui.status); + wait_probe(&mut h); + h.run_steps(3); + verify(&mut h, &f, &mut m, dims, "(c) waiting click, then Ctrl+Z: the click is not made"); + + // (d) Ctrl+Z, then a click while the probe is rebuilt: the click is made on the state undone + // to, as one step, and the redo line becomes a branch. + undo_now(&mut h, &mut m, false); // the blur 41 + undo_now(&mut h, &mut m, false); // the pick of c27_20: the Selection is now empty + assert_eq!(sel_of(m.state(), pl), "", "{:?}", m.state()); + let pb = sp(&h, &m, b); + h.event(Event::PointerMoved(pb)); + h.run_steps(2); + // (The blur's render is in the layer cache: the rebuild can be done already.) + press(&mut h, pb, Modifiers::SHIFT); + wait_probe(&mut h); + h.run_steps(3); + let mut o = m.state().clone(); + layer_mut(&mut o, pl).crypto_mut().1 = "c50_40".into(); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + m.record(&label, o, "(d) click after undo"); + verify(&mut h, &f, &mut m, dims, "(d) Ctrl+Z, then a click while reading"); + + // (e) Ctrl+Z, a click waiting, then Ctrl+Shift+Z: the click is not made. + blur(&mut h, &mut m, 43.0); + verify(&mut h, &f, &mut m, dims, "(e) blur 43"); + // (Undo goes back to a render the layer cache keeps: forget it, so the probe takes a while.) + h.state().session.layer_cache.clear(); + undo_now(&mut h, &mut m, false); + let pa = sp(&h, &m, a); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + press(&mut h, pa, Modifiers::SHIFT); + undo_now(&mut h, &mut m, true); + wait_probe(&mut h); + h.run_steps(3); + verify(&mut h, &f, &mut m, dims, "(e) waiting click, then Ctrl+Shift+Z: the click is not made"); + + // (f) A click waiting, Ctrl+Z, another click: only the second is made, on the undone state. + blur(&mut h, &mut m, 44.0); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + press(&mut h, pa, Modifiers::ALT); + undo_now(&mut h, &mut m, false); + press(&mut h, pa, Modifiers::SHIFT); + wait_probe(&mut h); + h.run_steps(3); + let mut o = m.state().clone(); + let c = layer_mut(&mut o, pl).crypto_mut(); + c.1 = model_pick(&c.1, "c27_20", "add"); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + m.record(&label, o, "(f) second click"); + verify(&mut h, &f, &mut m, dims, "(f) click, Ctrl+Z, click: only the second is made"); + for k in 0..m.cur { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo all {k}")); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +// --------------------------------------------------------------------------------------------- +// Scenario 4: Esc mid-sequence; undo across Revert, Open and New. + +#[test] +fn esc_mid_sequence_and_undo_across_revert_open_and_new() { + let (s, f) = session("reload", 320, 180, false, false); + let dims = [320.0, 180.0]; + let mut h = harness(s, vec2(1400.0, 860.0)); + let mut m = Model::new(&h, f.comp); + let ctr = |cx: usize, cy: usize| [cx as f64 * 37.0 + 18.5, cy as f64 * 29.0 + 14.5]; + let pl = f.plate; + use Step::*; + let steps = [Arm(pl), Pick(ctr(1, 1), Modifiers::NONE, "c1_1"), Opacity(f.other, 50.0, None), Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), Esc]; + for (n, st) in steps.iter().enumerate() { + run_step(&mut h, &f, &mut m, dims, st, n); + } + // Disarmed: undo and redo still walk the picks, and don't arm it again. + for k in 0..3 { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo after Esc {k}")); + } + for k in 0..3 { + step_history(&mut h, &f, &mut m, dims, true, &format!("redo after Esc {k}")); + } + // Esc while armed with a pick just made: one Esc, no step, no undo. + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 10); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(2, 2), Modifiers::SHIFT, "c2_2"), 11); + run_step(&mut h, &f, &mut m, dims, &Esc, 12); + // Saved, then one more pick, then Revert: the saved state, no history, still a sane pick. + let path = f.dir.join("saved.ecproj"); + h.state_mut().session.execute("file.save", json!({"path": path.to_string_lossy()})).unwrap(); + let saved = m.state().clone(); + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 13); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(4, 4), Modifiers::SHIFT, "c4_4"), 14); + let pick = h.state().ui.fx_pick.clone(); + h.state_mut().session.execute("file.revert", json!({})).unwrap(); + h.run_steps(3); + let armed_after_revert = h.state().ui.fx_pick.clone(); + eprintln!("armed before Revert {pick:?}, after {armed_after_revert:?}; chip {:?}", chip(&h)); + m.line = vec![("Original".into(), saved.clone())]; + (m.cur, m.branches) = (0, 0); + m.branch_states.clear(); + m.armed = armed_after_revert.as_ref().map(|p| (p.layer, p.prop)); + verify(&mut h, &f, &mut m, dims, "after Revert"); + step_history(&mut h, &f, &mut m, dims, false, "Ctrl+Z after Revert: nothing"); + if m.armed.is_none() { + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 15); + } + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(5, 1), Modifiers::SHIFT, "c5_1"), 16); + step_history(&mut h, &f, &mut m, dims, false, "undo the pick after Revert"); + step_history(&mut h, &f, &mut m, dims, true, "redo the pick after Revert"); + // New Project: the pick ends, nothing to undo. + h.state_mut().session.execute("file.newProject", json!({})).unwrap(); + h.run_steps(3); + eprintln!("after New Project: armed {:?}, chip {:?}, status {:?}", h.state().ui.fx_pick, chip(&h), h.state().ui.status); + assert!(chip(&h).is_none(), "no viewer, no chip"); + let items = effectcraft_ui_egui::menus::menu_items(h.state()); + let undo = items.iter().find(|i| i.id == "edit.undo").unwrap(); + assert_eq!((undo.label.as_str(), undo.enabled), ("Can't Undo", false)); + // Open the saved project: its state, no history; arm and pick as usual. + h.state_mut().session.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + settle(&mut h); + m.line = vec![("Original".into(), saved)]; + (m.cur, m.branches, m.armed) = (0, 0, None); + // (Still armed from before New, on the same ids: see `opening_a_project_ends_the_pick`.) + if let Some(p) = h.state().ui.fx_pick.as_ref() { + eprintln!("after Open: still armed {p:?}, chip {:?}", chip(&h)); + m.armed = Some((p.layer, p.prop)); + } + m.branch_states.clear(); + m.time = h.state().session.time_of(effectcraft_engine::project::ItemId(f.comp)).seconds(); + verify(&mut h, &f, &mut m, dims, "after Open"); + if m.armed.is_none() { + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 20); + } + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(6, 2), Modifiers::NONE, "c6_2"), 21); + step_history(&mut h, &f, &mut m, dims, false, "undo after Open"); + step_history(&mut h, &f, &mut m, dims, false, "undo past Open: nothing"); + step_history(&mut h, &f, &mut m, dims, true, "redo after Open"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +// --------------------------------------------------------------------------------------------- +// Scenario 5: grouping and labels. + +#[test] +fn picks_never_merge_with_neighbouring_edits() { + let (s, f) = session("group", 320, 180, false, false); + let dims = [320.0, 180.0]; + let mut h = harness(s, vec2(1024.0, 700.0)); + let mut m = Model::new(&h, f.comp); + let ctr = |cx: usize, cy: usize| [cx as f64 * 37.0 + 18.5, cy as f64 * 29.0 + 14.5]; + let pl = f.plate; + use Step::*; + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 0); + // A scrub of the plate's opacity (one merged step), a pick, the same scrub going on: three. + run_step(&mut h, &f, &mut m, dims, &Opacity(pl, 90.0, Some("scrub-1")), 1); + let n = h.state().session.history.undo.len(); + h.state_mut() + .session + .execute_checked("prop.set", json!({"comp": f.comp, "layer": pl, "path": "transform/opacity", "value": 85.0, "merge": "scrub-1"})) + .unwrap(); + assert_eq!(h.state().session.history.undo.len(), n, "a scrub merges"); + layer_mut(&mut m.line.last_mut().unwrap().1, pl).opacity = json!(85.0); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(1, 1), Modifiers::NONE, "c1_1"), 2); + run_step(&mut h, &f, &mut m, dims, &Opacity(pl, 80.0, Some("scrub-1")), 3); + // A pick next to a typed Selection. + run_step(&mut h, &f, &mut m, dims, &Type(pl, "c2_2"), 4); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), 5); + run_step(&mut h, &f, &mut m, dims, &Type(pl, "c2_2, c3_3, c4_4"), 6); + // Two picks in a row. + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(4, 4), Modifiers::ALT, "c4_4"), 7); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(5, 5), Modifiers::SHIFT, "c5_5"), 8); + for k in 0..m.cur { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo {k}")); + } + for (l, w) in &m.labels { + eprintln!("LABEL {l:?} (first from {w})"); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// FINDING 4. Typing in the Selection field (not committed yet) and then clicking an ID in the +/// viewer: the pick is made first, on the Selection before the typing, and then the field loses +/// the focus and commits the typed text over it. The pick is lost, yet it left an undo step (to a +/// state never shown). Expected: the typed text, then the pick on top, one step each. +#[test] +fn a_click_while_the_selection_field_is_being_edited() { + let (s, f) = session("typing", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + let pl = f.plate; + arm(&mut h, &f, pl); + let a = comp_to_screen(&h.ctx, [37.0 + 18.5, 29.0 + 14.5]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + let n = h.state().session.history.undo.len(); + type_selection(&mut h, &f, pl, "c2_2", false); + assert!(h.ctx.memory(|m| m.focused().is_some()), "the field is being edited"); + // Ctrl+Z in the field is the field's own: no global undo. + key(&mut h, Key::Z, CTRL); + assert_eq!(h.state().session.history.undo.len(), n, "Ctrl+Z while typing doesn't undo the project"); + type_selection(&mut h, &f, pl, "c2_2", false); + click(&mut h, a, Modifiers::SHIFT); + h.run_steps(3); + let sel = sel_of(&observe(&h.state().session, f.comp), pl); + let steps: Vec = h.state().session.history.undo[n..].iter().map(|u| u.0.clone()).collect(); + // The state between the two steps (what one Ctrl+Z goes back to). + let mid = h.state().session.history.undo.last().map(|u| { + let c = u.1.comp(effectcraft_engine::project::ItemId(f.comp)).unwrap().layer(LayerId(pl)).unwrap(); + c.effects().unwrap().groups().next().unwrap().get("selection").unwrap().value.to_json() + }); + eprintln!("the state one Ctrl+Z goes back to: Selection {mid:?}"); + eprintln!("typed c2_2 (uncommitted) + Shift-click c1_1: Selection {sel:?}, steps {steps:?}"); + assert_eq!((sel.as_str(), steps.len()), ("c2_2, c1_1", 2), "the typed text, then the pick on top, one step each"); + key(&mut h, Key::Z, CTRL); + assert_eq!(sel_of(&observe(&h.state().session, f.comp), pl), "c2_2", "undo takes back the pick"); + key(&mut h, Key::Z, CTRL); + assert_eq!(sel_of(&observe(&h.state().session, f.comp), pl), "", "then the typed text"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// On an animated Selection a pick sets (or adds) the key at the current time: one step, and +/// undo takes the key back. +#[test] +fn a_pick_on_an_animated_selection_is_one_step() { + let (s, f) = session("anim", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + let pl = f.plate; + let uid = arm(&mut h, &f, pl); + let keys = |h: &Harness<'_, EffectcraftApp>| { + let s = &h.state().session; + let l = s.project.comp(effectcraft_engine::project::ItemId(f.comp)).unwrap().layer(LayerId(pl)).unwrap(); + l.props.find(uid).unwrap().keys.iter().map(|k| (k.time.seconds(), k.value.to_json())).collect::>() + }; + h.state_mut().session.execute_checked("prop.toggleAnimation", json!({"comp": f.comp, "layer": pl, "prop": uid, "value": true})).unwrap(); + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0)); + settle(&mut h); + let n = h.state().session.history.undo.len(); + let before = keys(&h); + let a = comp_to_screen(&h.ctx, [37.0 + 18.5, 29.0 + 14.5]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + click(&mut h, a, Modifiers::NONE); + assert_eq!(h.state().session.history.undo.len(), n + 1); + assert_eq!(keys(&h), [before.clone(), vec![(1.0, json!("c1_1"))]].concat(), "a key at 1 s"); + key(&mut h, Key::Z, CTRL); + assert_eq!(keys(&h), before, "undo takes the key back"); + assert_eq!(hover(&mut h, a), "c1_1"); + key(&mut h, Key::Z, CTRL_SHIFT); + assert_eq!(hover(&mut h, a), "c1_1 (selected)"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +// --------------------------------------------------------------------------------------------- +// Findings: minimal failing tests (fixes proposed in research/crypick-undo-fixes.patch). + +/// FINDING 1. Undoing (or redoing) a pick changes only the Selection, which the probe doesn't +/// read, yet the probe is dropped and built again: on a 3D precomp that is ~0.6 s of "Reading +/// Cryptomatte IDs…" after every Ctrl+Z of a pick (the pick itself keeps it). +#[test] +fn undoing_or_redoing_a_pick_keeps_the_probe() { + let (mut s, f) = session("keep", 320, 180, false, false); + let q = json!({"comp": f.comp, "layer": f.plate, "effect": 1, "x": 55.5, "y": 43.5, "probeOnly": true}); + s.crypto_probe_job(&q).unwrap().expect("nothing built yet")(); + let pick = json!({"comp": f.comp, "layer": f.plate, "effect": 1, "x": 55.5, "y": 43.5}); + s.execute_checked("effect.pickCryptomatte", pick).unwrap(); + assert!(s.crypto_probe_job(&q).unwrap().is_none(), "the pick keeps the probe"); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.crypto_probe_job(&q).unwrap().is_none(), "so does its undo"); + assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], false); + s.execute_checked("edit.redo", json!({})).unwrap(); + assert!(s.crypto_probe_job(&q).unwrap().is_none(), "and its redo"); + assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true); + // Any other edit still drops it. + s.execute_checked("prop.set", json!({"comp": f.comp, "layer": f.plate, "path": "transform/opacity", "value": 50})).unwrap(); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.crypto_probe_job(&q).unwrap().is_some(), "an undone opacity change is a new probe"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// FINDING 2. The eyedropper stays armed through New Project, Open and Revert: the pick is kept +/// by layer id and property uid only, and a new project reuses them. Rebuilt the same way, the +/// new project's Cryptomatte is picked into by a plain viewer click the user never armed. +#[test] +fn a_new_or_opened_project_ends_the_pick() { + let (s, f) = session("stale", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + let path = f.dir.join("plate.exr").to_string_lossy().to_string(); + let saved = f.dir.join("saved.ecproj").to_string_lossy().to_string(); + h.state_mut().session.execute("file.save", json!({"path": saved})).unwrap(); + // Open (the same file, as Revert does). + arm(&mut h, &f, f.plate); + h.state_mut().session.execute("file.open", json!({"path": saved})).unwrap(); + settle(&mut h); + assert!(h.state().ui.fx_pick.is_none(), "Open ends the pick: {:?}", h.state().ui.fx_pick); + // New Project, then the same setup built in it. + h.state_mut().ui.fx_pick = None; + h.run_steps(2); + arm(&mut h, &f, f.plate); + h.state_mut().session.execute("file.newProject", json!({})).unwrap(); + h.run_steps(3); + let g = populate(&mut h.state_mut().session, &path, 320, 180, false, false, f.dir.clone()); + settle(&mut h); + let stale = h.state().ui.fx_pick.clone(); + let a = comp_to_screen(&h.ctx, [55.5, 43.5]).unwrap(); + click(&mut h, a, Modifiers::NONE); + let sel = sel_of(&observe(&h.state().session, g.comp), g.plate); + assert!(stale.is_none() && sel.is_empty(), "New ends the pick: armed {stale:?}; a plain click picked {sel:?} into the new project"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// FINDING 3 (wording). A pick's undo step is "Change Property", the same as an opacity change +/// or a typed Selection: Edit ▸ Undo and the History panel can't tell them apart. The command +/// is "Pick Cryptomatte"; its step could say so. +#[test] +fn a_pick_is_labelled_as_a_pick() { + let (s, f) = session("label", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + arm(&mut h, &f, f.plate); + let a = comp_to_screen(&h.ctx, [55.5, 43.5]).unwrap(); + click(&mut h, a, Modifiers::NONE); + let items = effectcraft_ui_egui::menus::menu_items(h.state()); + let undo = items.iter().find(|i| i.id == "edit.undo").unwrap(); + assert_eq!(undo.label, "Undo Pick Cryptomatte"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// FINDING 5 (from review). A middle-button drag pans the viewer, armed or not: egui hands the +/// press to the eyedropper's overlay (it sensed drags the pass before), so armed it didn't pan. +#[test] +fn a_middle_drag_pans_while_armed() { + let (s, f) = session("pan", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + let a = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + let drag = |h: &mut Harness<'_, EffectcraftApp>| { + let before = h.state().ui.viewer.pan; + h.event(Event::PointerMoved(a)); + h.step(); + h.event(Event::PointerButton { pos: a, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=5 { + h.event(Event::PointerMoved(a + vec2(10.0 * k as f32, 0.0))); + h.step(); + } + h.event(Event::PointerButton { pos: a + vec2(50.0, 0.0), button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let after = h.state().ui.viewer.pan; + h.state_mut().ui.viewer.pan = before; + h.run_steps(2); + after[0] - before[0] + }; + let unarmed = drag(&mut h); + let n = h.state().session.history.undo.len(); + arm(&mut h, &f, f.plate); + let armed = drag(&mut h); + assert_eq!(h.state().session.history.undo.len(), n, "no pick"); + assert!(h.state().ui.fx_pick.is_some(), "still armed"); + assert!((unarmed - 50.0).abs() < 0.01 && (armed - 50.0).abs() < 0.01, "a 50 px middle drag pans 50 px: unarmed {unarmed}, armed {armed}"); + let _ = std::fs::remove_dir_all(&f.dir); +}