//! User-reported selection, Project deletion, and render-queue drop regressions. use effectcraft_engine::{Session, project::LayerId}; use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable as _; use serde_json::json; fn harness() -> Harness<'static, EffectcraftApp> { let mut s = Session::default(); s.execute("comp.new", json!({"name":"Study", "width":640,"height":360,"duration":4})).unwrap(); for n in 0..4 { s.execute("layer.newSolid", json!({"name":format!("Tile {n}"),"width":80,"height":80,"color":"#406080"})).unwrap(); } s.execute("edit.deselectAll", json!({})).unwrap(); let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); h } fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) } fn pointer(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, down: bool, m: Modifiers) { h.input_mut().events.push(Event::ModifiersChanged(m)); h.input_mut().events.push(Event::PointerMoved(p)); h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: m }); h.step(); } fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, m: Modifiers) { pointer(h, p, true, m); pointer(h, p, false, m); h.run_steps(2); } fn drag(h: &mut Harness<'_, EffectcraftApp>, a: Pos2, b: Pos2, m: Modifiers) { pointer(h, a, true, m); for n in 1..=8 { h.input_mut().events.push(Event::PointerMoved(a + (b - a) * (n as f32 / 8.0))); h.step(); } pointer(h, b, false, m); h.run_steps(2); } #[test] fn timeline_shift_click_selects_range_and_ctrl_toggles() { let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); let point = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { let r = rect(h, &format!("timeline.layer.{}.row", id.0)); pos2(r.min.x + 210.0, r.center().y) }; let p = point(&h, ids[0]); click(&mut h, p, Modifiers::NONE); let p = point(&h, ids[2]); click(&mut h, p, Modifiers::SHIFT); assert_eq!(h.state().session.state.selected_layers, ids[..3]); let p = point(&h, ids[1]); click(&mut h, p, Modifiers::COMMAND); assert_eq!(h.state().session.state.selected_layers, vec![ids[0], ids[2]]); } /// #284: Shift-clicking layer bars in the time graph selects the range from the last layer /// clicked, as Shift-clicking their names does. #[test] fn timeline_shift_click_on_layer_bars_selects_the_range() { let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center(); let p = bar(&h, ids[3]); click(&mut h, p, Modifiers::NONE); assert_eq!(h.state().session.state.selected_layers, vec![ids[3]]); let p = bar(&h, ids[1]); click(&mut h, p, Modifiers::SHIFT); assert_eq!(h.state().session.state.selected_layers, ids[1..]); // From a name to a bar too. let r = rect(&h, &format!("timeline.layer.{}.row", ids[0].0)); click(&mut h, pos2(r.min.x + 210.0, r.center().y), Modifiers::NONE); let p = bar(&h, ids[2]); click(&mut h, p, Modifiers::SHIFT); assert_eq!(h.state().session.state.selected_layers, ids[..3]); } #[test] fn timeline_empty_outline_marquee_selects_layers() { let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); let area = rect(&h, "timeline.layerMarquee"); let top = rect(&h, &format!("timeline.layer.{}.row", ids[0].0)); let last = rect(&h, &format!("timeline.layer.{}.row", ids[3].0)); drag(&mut h, pos2(area.min.x + 210.0, last.max.y + 25.0), pos2(area.min.x + 215.0, top.min.y + 2.0), Modifiers::NONE); assert_eq!(h.state().session.state.selected_layers, ids); } /// A plain click on empty track area (beside a layer's bar or below the last layer) deselects /// the layers, as in After Effects; Shift / Cmd keep the selection, a click on a bar selects /// its layer and a key marquee that catches nothing leaves nothing selected. #[test] fn timeline_click_on_empty_track_area_deselects_layers() { let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); let select = |h: &mut Harness<'_, EffectcraftApp>, layers: &[LayerId]| { let layers: Vec = layers.iter().map(|l| l.0).collect(); h.state_mut().session.execute("layer.select", json!({"layers": layers})).unwrap(); h.run_steps(2); }; h.state_mut().session.execute("layer.timing", json!({"layers": [ids[1].0], "in": 1.5, "out": 2.5})).unwrap(); select(&mut h, &ids[..2]); let bar = rect(&h, &format!("timeline.layer.{}.bar", ids[1].0)); let before = pos2(bar.min.x - 30.0, bar.center().y); let after = pos2(bar.max.x + 30.0, bar.center().y); click(&mut h, before, Modifiers::NONE); assert!(h.state().session.state.selected_layers.is_empty(), "a click beside the bar deselects"); click(&mut h, bar.center(), Modifiers::NONE); assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a click on the bar selects its layer"); click(&mut h, after, Modifiers::SHIFT); assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Shift-click on empty keeps the selection"); click(&mut h, after, Modifiers::COMMAND); assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Cmd-click on empty keeps the selection"); click(&mut h, after, Modifiers::NONE); assert!(h.state().session.state.selected_layers.is_empty()); // Below the last layer, in the track area and in the outline. let last = rect(&h, &format!("timeline.layer.{}.bar", ids[3].0)); select(&mut h, &ids); click(&mut h, pos2(last.center().x, last.max.y + 30.0), Modifiers::NONE); assert!(h.state().session.state.selected_layers.is_empty(), "a click below the last layer deselects"); let outline = rect(&h, "timeline.layerMarquee"); select(&mut h, &ids); click(&mut h, pos2(outline.min.x + 210.0, last.max.y + 30.0), Modifiers::NONE); assert!(h.state().session.state.selected_layers.is_empty(), "so does one below the layers in the outline"); // A key marquee over empty track area that catches nothing. select(&mut h, &ids[1..2]); drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::NONE); assert!(h.state().session.state.selected_layers.is_empty(), "an empty marquee leaves nothing selected"); select(&mut h, &ids[1..2]); drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::SHIFT); assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a Shift marquee keeps the selection"); } #[test] fn project_clear_deletes_items_and_undo_restores_them() { let mut h = harness(); let id = h.state().session.active_comp_id().unwrap(); h.state_mut().session.state.project_selection = vec![id]; h.state_mut().ui.focused = PanelKind::Project; let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})).unwrap(); assert!(h.state().session.project.comp(id).is_none()); h.state_mut().session.execute("edit.undo", json!({})).unwrap(); assert!(h.state().session.project.comp(id).is_some()); } #[test] fn project_comp_drop_queues_the_dragged_comp() { let mut h = harness(); let id = h.state().session.active_comp_id().unwrap(); let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"renderQueue"})).unwrap(); h.run_steps(3); let a = rect(&h, &format!("project.item.{}.name", id.0)).center(); let b = rect(&h, "renderQueue.drop").center(); drag(&mut h, a, b, Modifiers::NONE); assert_eq!(h.state().session.project.render_queue.len(), 1); assert_eq!(h.state().session.project.render_queue[0].comp, id); } #[test] fn viewer_marquee_crosses_a_layer_interior_and_shift_preserves_selection() { let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); for (n, id) in ids.iter().enumerate() { h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"transform/position","value":[120.0+n as f64*120.0,180.0]})).unwrap(); } h.run_steps(3); let comp = rect(&h, "viewer.comp"); let screen = |x: f32, y: f32| comp.min + vec2(x, y) * (comp.width() / 640.0); drag(&mut h, screen(60.0, 170.0), screen(180.0, 190.0), Modifiers::NONE); assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]); drag(&mut h, screen(200.0, 100.0), screen(300.0, 230.0), Modifiers::SHIFT); assert!(h.state().session.state.selected_layers.contains(&ids[0])); assert!(h.state().session.state.selected_layers.contains(&ids[1])); drag(&mut h, screen(200.0, 100.0), screen(300.0, 230.0), Modifiers::SHIFT); assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]); } #[test] fn render_queue_delete_preserves_selected_timeline_layers() { let mut h = harness(); let id = h.state().session.active_comp().unwrap().layers[0].id; h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); h.state_mut().session.execute("renderQueue.add", json!({})).unwrap(); let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"renderQueue"})).unwrap(); h.run_steps(3); let row = rect(&h, "renderQueue.item.1.row"); click(&mut h, pos2(row.max.x - 15.0, row.center().y), Modifiers::NONE); assert_eq!(h.state().ui.focused, PanelKind::RenderQueue); h.input_mut().events.push(Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert!(h.state().session.project.render_queue.is_empty()); assert!(h.state().session.active_comp().unwrap().layer(id).is_some()); assert_eq!(h.state().session.state.selected_layers, vec![id]); } #[test] fn project_shift_selects_visible_range_and_ctrl_removes_item() { let mut h = harness(); let mut ids = vec![]; for name in ["A Range", "B Range", "C Range"] { ids.push(h.state_mut().session.execute("comp.new", json!({"name":name,"width":64,"height":64,"duration":1})).unwrap()["comp"].as_u64().unwrap()); } h.run_steps(3); let p = rect(&h, &format!("project.item.{}.name", ids[0])).center(); click(&mut h, p, Modifiers::NONE); let p = rect(&h, &format!("project.item.{}.name", ids[2])).center(); click(&mut h, p, Modifiers::SHIFT); assert_eq!(h.state().session.state.project_selection.iter().map(|i| i.0).collect::>(), ids); let p = rect(&h, &format!("project.item.{}.name", ids[1])).center(); click(&mut h, p, Modifiers::COMMAND); assert_eq!(h.state().session.state.project_selection.iter().map(|i| i.0).collect::>(), vec![ids[0], ids[2]]); } #[test] fn effect_scrubbing_uses_adaptive_resolution_and_settles_on_release() { let mut h = harness(); let id = h.state().session.active_comp().unwrap().layers[0].id; h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); h.state_mut().session.execute("effect.apply", json!({"effect":"Gaussian Blur"})).unwrap(); h.state_mut().session.execute("view.fastPreviewMode", json!({"mode":"adaptive"})).unwrap(); let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"effectControls"})).unwrap(); h.run_steps(3); let prop = h.state().session.active_comp().unwrap().layer(id).unwrap().effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().uid; let a = rect(&h, &format!("effectControls.prop.{prop}.value")).center(); pointer(&mut h, a, true, Modifiers::NONE); h.input_mut().events.push(Event::PointerMoved(a + vec2(30.0, 0.0))); h.run_steps(2); assert!(h.state().ui.viewer.property_interacting); assert!(h.state().viewer_scale(1.0, 1.0) < 1.0); h.state_mut().session.execute("view.res.full", json!({})).unwrap(); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); pointer(&mut h, a + vec2(30.0, 0.0), false, Modifiers::NONE); h.run_steps(2); assert!(!h.state().ui.viewer.property_interacting); h.state_mut().session.execute("view.res.auto", json!({})).unwrap(); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); } #[test] fn viewer_right_click_targets_hit_and_duplicate_preserves_group() { let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); for (n, id) in ids.iter().enumerate() { h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"transform/position","value":[120.0+n as f64*120.0,180.0]})).unwrap(); } h.state_mut().session.execute("layer.select", json!({"layers":[ids[2].0]})).unwrap(); h.run_steps(3); let comp = rect(&h, "viewer.comp"); let p = comp.min + vec2(120.0, 180.0) * (comp.width() / 640.0); let right_click = |h: &mut Harness<'_, EffectcraftApp>| { for pressed in [true, false] { h.input_mut().events.push(Event::PointerMoved(p)); h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); h.step(); } h.run_steps(3); }; right_click(&mut h); assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]); if let Ok(out) = std::env::var("CONTEXT_MENU_SNAPSHOT") { let comp = h.state().session.active_comp_id().unwrap(); let key = effectcraft_ui_egui::frames::FrameKey { frame: 0, ..h.state().shown_series(comp) }; for _ in 0..600 { h.step(); if h.state().frames.is_cached(&key) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } assert!(h.state().frames.is_cached(&key)); h.run_steps(3); h.render().expect("offscreen context menu render").save(out).unwrap(); } h.get_by_label("Duplicate").click(); h.run_steps(3); assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 5); h.state_mut().session.execute("edit.undo", json!({})).unwrap(); h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0,ids[1].0]})).unwrap(); h.run_steps(3); right_click(&mut h); assert_eq!(h.state().session.state.selected_layers.len(), 2); h.get_by_label("Duplicate").click(); h.run_steps(3); assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 6); } #[test] fn viewer_context_select_reaches_an_obscured_layer() { let mut h = harness(); let target = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Tile 1").unwrap().id; let p = rect(&h, "viewer.comp").center(); for pressed in [true, false] { h.input_mut().events.push(Event::PointerMoved(p)); h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); h.step(); } h.run_steps(3); h.get_by_label("Select ⏵").click(); h.run_steps(3); h.get_by_label("Tile 1").click(); h.run_steps(3); assert_eq!(h.state().session.state.selected_layers, vec![target]); } /// Original four-tile UI fixture; no Adobe assets or reference screenshots. #[test] fn modal_blocks_clicks_on_underlying_timeline_layers() { let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0]})).unwrap(); let row = rect(&h, &format!("timeline.layer.{}.row", ids[2].0)); h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About); h.run_steps(3); click(&mut h, pos2(row.min.x + 210.0, row.center().y), Modifiers::NONE); assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]); assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::About)); } #[test] fn layer_style_escape_rolls_back_live_preview() { let mut h = harness(); let id = h.state().session.active_comp().unwrap().layers[0].id; h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap(); h.run_steps(3); h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":25})).unwrap(); h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(3); assert!(h.state().dialog.is_none()); let styles = h.state_mut().session.execute("layer.style.list", json!({})).unwrap(); assert!(styles["styles"].as_array().unwrap().is_empty()); } #[test] fn style_cancel_restores_checkpoint_with_bounded_history_and_reopened_scrub() { let mut h = harness(); let id = h.state().session.active_comp().unwrap().layers[0].id; h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); h.state_mut().session.execute("layer.style.dropShadow", json!({})).unwrap(); h.state_mut().session.prefs.general.undo_levels = 1; let ctx = h.ctx.clone(); let open = |h: &mut Harness<'_, EffectcraftApp>| { effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.options", json!({"style":"dropShadow"})).unwrap(); }; open(&mut h); let before = h.state().session.project.clone(); let history = h.state().session.history.undo.len(); h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":25,"merge":"same-style-control"})).unwrap(); effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false); assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before)); assert_eq!(h.state().session.history.undo.len(), history); open(&mut h); h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":50,"merge":"same-style-control"})).unwrap(); effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true); let accepted = h.state().session.project.clone(); open(&mut h); h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":75,"merge":"same-style-control"})).unwrap(); effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false); assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &accepted)); } #[test] fn project_edit_commands_do_not_target_timeline_layers() { let mut h = harness(); let comp = h.state().session.active_comp_id().unwrap(); let id = h.state().session.active_comp().unwrap().layers[0].id; h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); h.state_mut().session.execute("project.select", json!({"items":[comp.0]})).unwrap(); h.state_mut().ui.focused = PanelKind::Project; let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.duplicate", json!({})).unwrap(); assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 4); assert_ne!(h.state().session.state.project_selection, vec![comp]); assert!(effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.cut", json!({})).is_err()); assert!(h.state().session.active_comp().unwrap().layer(id).is_some()); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.deselectAll", json!({})).unwrap(); assert!(h.state().session.state.project_selection.is_empty()); assert_eq!(h.state().session.state.selected_layers, vec![id]); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.selectAll", json!({})).unwrap(); assert!(h.state().session.state.project_selection.contains(&comp)); assert_eq!(h.state().session.state.selected_layers, vec![id]); } #[test] fn compact_settings_keeps_confirmation_buttons_clickable() { let mut h = Harness::builder().with_size(vec2(1024.0, 768.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::Settings); h.run_steps(4); h.get_by_label(" OK ").click(); h.run_steps(3); assert!(h.state().dialog.is_none()); } #[test] fn filtered_project_select_all_does_not_delete_hidden_assets() { let mut h = harness(); let comp = h.state().session.active_comp_id().unwrap(); let before = h.state().session.project.items.len(); h.state_mut().ui.focused = PanelKind::Project; h.state_mut().ui.project_search = "Study".into(); let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.selectAll", json!({})).unwrap(); assert_eq!(h.state().session.state.project_selection, vec![comp]); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})).unwrap(); assert_eq!(h.state().session.project.items.len(), before - 1); } /// Original four-tile UI fixture; no Adobe assets or reference screenshots. #[test] #[ignore] fn workflow_snapshot() { let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); for (n, id) in ids.iter().enumerate() { h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"transform/position","value":[120.0+n as f64*120.0,180.0]})).unwrap(); } h.state_mut().session.execute("layer.select", json!({"layers":ids[..3].iter().map(|i|i.0).collect::>()})).unwrap(); h.run_steps(3); let out = std::env::var("WORKFLOW_SNAPSHOT").expect("set WORKFLOW_SNAPSHOT to the output PNG"); let comp = h.state().session.active_comp_id().unwrap(); let key = effectcraft_ui_egui::frames::FrameKey { frame: 0, ..h.state().shown_series(comp) }; for _ in 0..600 { h.step(); if h.state().frames.is_cached(&key) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } assert!(h.state().frames.is_cached(&key), "current composition rendered before capture"); h.run_steps(3); h.render().expect("offscreen UI render").save(out).unwrap(); } /// Filled rects painted this frame (flattening nested shape lists). fn filled_rects(h: &Harness<'_, EffectcraftApp>) -> Vec<(Rect, egui::Color32)> { fn walk(s: &egui::Shape, out: &mut Vec<(Rect, egui::Color32)>) { match s { egui::Shape::Rect(r) => out.push((r.rect, r.fill)), egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), _ => {} } } let mut out = vec![]; for c in &h.output().shapes { walk(&c.shape, &mut out); } out } /// Issues #91 / #97: clicking an effect's name in the Timeline selects it, and its row then /// highlights like a selected property row (it used to stay unhighlighted). #[test] fn timeline_selected_effect_row_highlights() { let mut h = harness(); let id = h.state().session.active_comp().unwrap().layers[0].id; let fx = h.state_mut().session.execute("effect.apply", json!({"layers": [id.0], "effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap(); let fx_group = h.state().session.active_comp().unwrap().layer(id).unwrap().props.sub("effects").unwrap().uid; h.state_mut().ui.timeline.open_layers.insert(id.0); h.state_mut().ui.timeline.open_groups.insert(fx_group); // `effect.apply` selects the new effect; start from no property selection. h.state_mut().session.state.selected_props.clear(); h.run_steps(3); let row_selected = h.state().tokens.row_selected; let name = rect(&h, &format!("timeline.group.{fx}.name")); let highlighted = |h: &Harness<'_, EffectcraftApp>| filled_rects(h).iter().any(|(r, c)| *c == row_selected && r.contains(name.center())); assert!(!highlighted(&h), "not highlighted before it is selected"); click(&mut h, name.center(), Modifiers::NONE); assert!(h.state().session.state.selected_props.contains(&(id, fx)), "the click selects the effect"); assert!(highlighted(&h), "the selected effect's row is highlighted"); } /// Right-clicking a layer in the Timeline keeps the selection when the layer is part of it, /// so the context menu's commands act on every selected layer; right-clicking an unselected /// layer selects only it (After Effects). Reported on Discord: Pre-compose and Delete from the /// menu acted on the right-clicked layer only (#878). #[test] fn timeline_context_menu_acts_on_the_whole_selection() { let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { for pressed in [true, false] { h.input_mut().events.push(Event::PointerMoved(p)); h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); h.step(); } h.run_steps(3); }; let row = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { let r = rect(h, &format!("timeline.layer.{}.row", id.0)); pos2(r.min.x + 210.0, r.center().y) }; let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center(); let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); // Outline row: Delete removes both selected layers. h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); h.run_steps(3); let p = row(&h, ids[1]); right_click(&mut h, p); assert_eq!(h.state().session.state.selected_layers, vec![ids[0], ids[1]]); h.get_by_label("Delete").click(); h.run_steps(3); let left: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); assert_eq!(left, vec![ids[2], ids[3]]); h.state_mut().session.execute("edit.undo", json!({})).unwrap(); // Layer bar: same, with Duplicate. h.state_mut().session.execute("layer.select", json!({"layers":[ids[2].0, ids[3].0]})).unwrap(); h.run_steps(3); let p = bar(&h, ids[3]); right_click(&mut h, p); h.get_by_label("Duplicate").click(); h.run_steps(3); assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 6); h.state_mut().session.execute("edit.undo", json!({})).unwrap(); // An unselected row: right-clicking selects only it. h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); h.run_steps(3); let p = row(&h, ids[3]); right_click(&mut h, p); assert_eq!(h.state().session.state.selected_layers, vec![ids[3]]); } /// The Project panel's Move to Project Root acts on every selected item when the right-clicked /// item is part of the selection, as Delete and Label do. #[test] fn project_context_move_to_root_acts_on_the_whole_selection() { let mut h = harness(); let solids: Vec = { let p = &h.state().session.project; p.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).map(|i| i.id.0).collect() }; assert!(solids.len() >= 2); let folder = h.state_mut().session.execute("project.newFolder", json!({"name":"Bin"})).unwrap()["item"].as_u64().unwrap(); h.state_mut().session.execute("project.move", json!({"items": solids, "folder": folder})).unwrap(); h.state_mut().ui.project_open_folders.insert(folder); h.state_mut().session.execute("project.select", json!({"items": [solids[0], solids[1]]})).unwrap(); h.run_steps(3); let p = rect(&h, &format!("project.item.{}.name", solids[1])).center(); for pressed in [true, false] { h.input_mut().events.push(Event::PointerMoved(p)); h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); h.step(); } h.run_steps(3); h.get_by_label("Move to Project Root").click(); h.run_steps(3); let project = &h.state().session.project; for id in &solids[..2] { assert_eq!(project.item(effectcraft_engine::project::ItemId(*id)).unwrap().parent, None); } } /// Timeline right-click "Pre-compose…" groups every selected layer into one composition, /// like Layer > Pre-compose does, from both the outline row and the layer bar /// (#985, #1003). #[test] fn timeline_context_menu_precompose_groups_the_whole_selection() { let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { for pressed in [true, false] { h.input_mut().events.push(Event::PointerMoved(p)); h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); h.step(); } h.run_steps(3); }; let row = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { let r = rect(h, &format!("timeline.layer.{}.row", id.0)); pos2(r.min.x + 210.0, r.center().y) }; let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center(); for via_bar in [false, true] { let mut h = harness(); let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); let mut want: Vec = h.state().session.active_comp().unwrap().layers.iter().filter(|l| l.id == ids[0] || l.id == ids[1]).map(|l| l.name.clone()).collect(); want.sort(); h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); h.run_steps(3); let p = if via_bar { bar(&h, ids[1]) } else { row(&h, ids[1]) }; right_click(&mut h, p); h.get_by_label("Pre-compose…").click(); h.run_steps(3); assert_eq!(h.state().dialog, Some(Dialog::Precompose), "via_bar={via_bar}: Pre-compose opens its dialog"); let ok = rect(&h, "dialog.precompose.ok").center(); click(&mut h, ok, Modifiers::NONE); h.run_steps(3); // The dialog opens the new composition holding both nested layers. let mut nested: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect(); nested.sort(); assert_eq!(nested, want, "via_bar={via_bar}: both selected layers are nested"); } } /// A text layer's timeline context menu offers Create Shapes from Text and Create Masks from /// Text (After Effects), which convert the layer; a non-text layer does not show them (#872). #[test] fn timeline_context_menu_creates_shapes_and_masks_from_text() { use effectcraft_engine::project::LayerSource; let mut s = Session::default(); s.execute("comp.new", json!({"name":"Type","width":320,"height":180,"duration":4})).unwrap(); s.execute("layer.newText", json!({"text":"Abc"})).unwrap(); s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap(); s.execute("edit.deselectAll", json!({})).unwrap(); let text = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Text)).unwrap().id.0; let plate = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Plate").unwrap().id.0; let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); let row = |h: &Harness<'_, EffectcraftApp>, id: u64| { let r = rect(h, &format!("timeline.layer.{id}.row")); pos2(r.min.x + 210.0, r.center().y) }; let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { for pressed in [true, false] { h.input_mut().events.push(Event::PointerMoved(p)); h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); h.step(); } h.run_steps(3); }; // The text layer offers both conversions; Create Shapes from Text works. h.state_mut().session.execute("layer.select", json!({"layers": [text]})).unwrap(); h.run_steps(3); let at = row(&h, text); right_click(&mut h, at); h.get_by_label("Create Shapes from Text").click(); h.run_steps(3); let shapes = h.state().session.active_comp().unwrap().layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); assert_eq!(shapes, 1, "a shape layer was created from the text"); // A non-text layer does not show the text conversions. h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); h.run_steps(3); let at = row(&h, plate); right_click(&mut h, at); assert!(h.query_by_label("Create Shapes from Text").is_none(), "no text conversion for a solid"); assert!(h.query_by_label("Create Masks from Text").is_none(), "no text conversion for a solid"); }