diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6..45581d7f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -533,7 +533,9 @@ pub struct RenderOpts { /// Output scale relative to comp pixels (1 = Full, 0.5 = Half…). pub scale: f64, pub motion_blur: bool, - /// Render guide layers (viewer only). + /// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested + /// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in + /// After Effects (see [`Renderer::nested_opts`]). pub guides: bool, /// Draft quality: fewer motion-blur samples, bilinear only. pub draft: bool, @@ -737,9 +739,16 @@ impl<'a> Renderer<'a> { sub } + /// The render options one level down (precomps, collapsed precomps, comps read by effects): + /// guide layers render only in their own comp, never when it is nested. + fn nested_opts(&self) -> RenderOpts { + RenderOpts { guides: false, ..self.opts } + } + /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { Renderer { + opts: self.nested_opts(), depth: self.depth + 1, outer: None, opacity_mul: 1.0, @@ -830,7 +839,10 @@ impl<'a> Renderer<'a> { if let Some(slot) = slot { *slot = Some(link); } - Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx)) + Some(( + Renderer { opts: self.nested_opts(), depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, + nctx, + )) } pub(crate) fn opacity_mul(&self) -> f32 { @@ -2388,6 +2400,8 @@ mod tests_footage_size; #[cfg(test)] mod tests_frame_blend; #[cfg(test)] +mod tests_guides; +#[cfg(test)] mod tests_mask_blur; #[cfg(test)] mod tests_nested; diff --git a/crates/render/src/tests_guides.rs b/crates/render/src/tests_guides.rs new file mode 100644 index 00000000..cb985125 --- /dev/null +++ b/crates/render/src/tests_guides.rs @@ -0,0 +1,108 @@ +//! Guide layers: drawn in their own comp's viewer (`RenderOpts::guides`), never when that comp is +//! nested in another (precomp, collapsed precomp, 2D or 3D), and never in final renders. + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), three_d: bool) -> Layer { + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + l.switches.three_d = three_d; + l +} + +#[derive(Clone, Copy, Debug)] +enum Nest { + /// A plain precomp layer (the nested comp rendered on its own, then composited). + Precomp, + /// Collapse Transformations on a 2D precomp layer. + Collapsed, + /// A collapsed 3D precomp in a Classic 3D comp. + Collapsed3d, + /// A collapsed 3D precomp in an Advanced 3D comp. + CollapsedAdvanced, +} + +/// "Inner": a red 100×100 background with a green 40×40 guide layer in the middle; "Outer": the +/// same size, holding only Inner nested as `nest`. +fn scene(nest: Nest) -> (Project, ItemId, ItemId) { + let three_d = matches!(nest, Nest::Collapsed3d | Nest::CollapsedAdvanced); + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut guide = solid(&mut p, &inner, [0.0, 1.0, 0.0], (40, 40), three_d); + guide.switches.guide = true; + let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d); + let mut inner = inner; + inner.layers = vec![guide, bg]; + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + if matches!(nest, Nest::CollapsedAdvanced) { + outer.renderer = effectcraft_project::Renderer::Advanced3D; + } + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + pre.switches.collapse = !matches!(nest, Nest::Precomp); + pre.switches.three_d = three_d; + outer.layers = vec![pre]; + let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + (p, iid, oid) +} + +/// The centre pixel of `comp` rendered with or without guide layers. +fn centre(p: &Project, comp: ItemId, guides: bool) -> [f32; 4] { + Renderer::new(p, &NoFootage, RenderOpts { guides, ..Default::default() }).comp_frame(comp, Tick::ZERO).get(50, 50) +} + +fn is_green(c: [f32; 4]) -> bool { + c[1] > 0.9 && c[0] < 0.1 +} + +fn is_red(c: [f32; 4]) -> bool { + c[0] > 0.9 && c[1] < 0.1 +} + +#[test] +fn guide_layer_renders_in_its_own_comp_with_guides_on() { + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, true); + assert!(is_green(c), "guide layer drawn in its own comp's viewer: {c:?}"); +} + +#[test] +fn final_renders_leave_guide_layers_out_by_default() { + assert!(!RenderOpts::default().guides); + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, false); + assert!(is_red(c), "guide layer left out of final renders: {c:?}"); +} + +#[test] +fn guide_layers_of_a_nested_comp_never_render_in_the_parent() { + for nest in [Nest::Precomp, Nest::Collapsed, Nest::Collapsed3d, Nest::CollapsedAdvanced] { + let (p, inner, outer) = scene(nest); + // The nested comp's own viewer still shows them… + let own = centre(&p, inner, true); + assert!(is_green(own), "{nest:?}: guide layer in its own comp: {own:?}"); + // …but not the parent's viewer (guides on) nor its final render. + for guides in [true, false] { + let c = centre(&p, outer, guides); + assert!(is_red(c), "{nest:?}, guides {guides}: nested guide layer drawn in the parent: {c:?}"); + } + } +} + +#[test] +fn a_guide_precomp_layer_of_the_top_comp_follows_the_guides_option() { + // The precomp layer itself is a guide layer in the top comp: shown in the viewer, not in + // final renders. + let (mut p, _, outer) = scene(Nest::Precomp); + p.comp_mut(outer).unwrap().layers[0].switches.guide = true; + assert!(is_red(centre(&p, outer, true))); + assert!(centre(&p, outer, false)[3] < 0.01, "nothing drawn without guides"); +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 5b5ae956..f1f4c185 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -879,7 +879,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: || l.is_light() || matches!(l.source, LayerSource::Camera) || (any_solo && !l.switches.solo) - || (!r.opts.guides && l.switches.guide) + || (!sub.opts.guides && l.switches.guide) { continue; } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 0d70b32f..ca7998fe 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -503,7 +503,7 @@ fn collapsed_items<'a>( .layers .iter() .rev() - .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide)) + .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (sub.opts.guides || !l.switches.guide)) .collect(); let mut items = Vec::new(); for (k, l) in nested.into_iter().enumerate() { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4af..64a1b6d1 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -262,6 +262,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Grab bag", "いろいろ"), ("Gradient Editor", "グラデーションエディター"), ("Grid", "グリッド"), + ("Guide Layer", "ガイドレイヤー"), ("Guide Layers: {}", "ガイドレイヤー:{}"), ("Guides", "ガイド"), ("Height:", "高さ:"), @@ -806,6 +807,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "列表"), ("Gradient Editor", "渐变编辑器"), ("Grid", "网格"), + ("Guide Layer", "参考线图层"), ("Guide Layers: {}", "参考图层:{}"), ("Guides", "参考线"), ("Height:", "高度:"), @@ -1538,6 +1540,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "清單"), ("Gradient Editor", "漸變編輯器"), ("Grid", "格點"), + ("Guide Layer", "參考圖層"), ("Guide Layers: {}", "參考圖層:{}"), ("Guides", "參考線"), ("Height:", "高度:"), @@ -2273,6 +2276,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Grab bag", "Список"), ("Gradient Editor", "Редактор градієнта"), ("Grid", "Сітка"), + ("Guide Layer", "Допоміжний шар"), ("Guide Layers: {}", "Допоміжні шари: {}"), ("Guides", "Напрямні"), ("Height:", "Висота:"), @@ -2823,6 +2827,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Frame Target:", "Destino do quadro:"), ("Grab bag", "Lista"), ("Grid", "Grade"), + ("Guide Layer", "Camada de guia"), ("Guide Layers: {}", "Camadas de guia: {}"), ("Guides", "Guias"), ("Height:", "Altura:"), @@ -3274,6 +3279,7 @@ const CZECH: &[(&str, &str)] = &[ ("Freeze", "Zmrazit"), ("Grab bag", "Mozaika"), ("Grid", "Mřížka"), + ("Guide Layer", "Pomocná vrstva"), ("Guide Layers: {}", "Pomocné vrstvy: {}"), ("Guides", "Vodítka"), ("Height:", "Výška:"), @@ -4310,6 +4316,7 @@ mod tests { set_current("cs"); assert_eq!(tr("Composition Name:"), "Název kompozice:"); assert_eq!(tr("Cancel"), "Zrušit"); + assert_eq!(tr("Guide Layer"), "Pomocná vrstva", "the layer context menus' item, as Layer ▸ Guide Layer"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Použití zástupných souborů: Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Krok 1 z 3: Colour"); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..d00dbd5e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2042,7 +2042,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl"); icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim); - let name_rect = Rect::from_min_max(pos2(cw.name + 34.0, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); + // A guide layer carries a guide mark before its name (After Effects). + let name_x = if layer.switches.guide { + let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0)); + icons::paint(&lp, mark, Icon::Grid, t.text_dim); + app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer"); + cw.name + 50.0 + } else { + cw.name + 34.0 + }; + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); let rename_id = egui::Id::new("tl-rename"); let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id)); if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) { @@ -2103,7 +2112,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), @@ -2324,7 +2333,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); let drag_key = format!("bar-{}", layer.id.0); // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the // drag starts and followed below the rows until the button is released. @@ -3857,12 +3866,14 @@ impl LayerMenuCaps { /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part /// of the selection keeps the selection and the menu acts on all of it; right-clicking an /// unselected layer selects only that layer. +#[allow(clippy::too_many_arguments)] fn layer_context_menu( resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], caps: LayerMenuCaps, + auto: &mut crate::automation::Registry, actions: &mut Vec<(String, serde_json::Value)>, ) { let in_selection = selected.contains(&layer.id); @@ -3870,12 +3881,24 @@ fn layer_context_menu( actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); } let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; - crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, actions)); + crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions)); } -/// Shared layer actions for Timeline rows and Composition context menus. -pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { +/// The layer switches the layer context menus toggle (After Effects lists them after Time): +/// `layer.setSwitch` name and label. +const MENU_SWITCHES: [(&str, &str); 1] = [("guide", "Guide Layer")]; + +/// Shared layer actions for Timeline rows and Composition context menus. `auto` is the automation +/// registry and the id prefix of the menu's items. +pub(crate) fn layer_menu( + ui: &mut egui::Ui, + layers: &[u64], + caps: LayerMenuCaps, + auto: (&mut crate::automation::Registry, &str), + actions: &mut Vec<(String, serde_json::Value)>, +) { let Some(&id) = layers.first() else { return }; + let (auto, prefix) = auto; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { if ui.button(label).clicked() { actions.push(("layer.select".into(), json!({"layers": layers}))); @@ -3921,6 +3944,18 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, ui.menu_button(crate::i18n::tr("Markers"), |ui| { item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); }); + // A click toggles the switch on the target layers, as Layer ▸ Guide Layer does. + // (Pushed after the other items: `item` holds `actions` until its last use.) + let mut switch_actions: Vec<(String, serde_json::Value)> = Vec::new(); + for (switch, label) in MENU_SWITCHES { + let response = ui.button(crate::i18n::tr(label)); + auto.add(&format!("{prefix}.switch.{switch}"), response.rect, label); + if response.clicked() { + switch_actions.push(("layer.select".into(), json!({"layers": layers}))); + switch_actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": switch}))); + ui.close(); + } + } ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| { widgets::menu_scroll(ui, |ui| { for m in BlendMode::ALL { @@ -3943,6 +3978,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, item(ui, "Duplicate", "edit.duplicate", json!({"layers": layers})); item(ui, "Split Layer", "edit.splitLayer", json!({"layers": layers})); item(ui, "Delete", "edit.clear", json!({"layers": layers})); + actions.extend(switch_actions); } /// The layer context menu's Effects submenu: every effect from the registry grouped by category, diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..324ed3dd 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1274,7 +1274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { caps }, ); - super::timeline::layer_menu(ui, &context_layers, caps, &mut context_actions); + super::timeline::layer_menu(ui, &context_layers, caps, (&mut app.auto, "viewer.layerMenu"), &mut context_actions); } ui.separator(); if ui.button(copy_frame).clicked() { diff --git a/crates/ui-egui/tests/ui_guide_layers.rs b/crates/ui-egui/tests/ui_guide_layers.rs new file mode 100644 index 00000000..37158658 --- /dev/null +++ b/crates/ui-egui/tests/ui_guide_layers.rs @@ -0,0 +1,129 @@ +//! Guide Layer in the layer context menus (Timeline rows and the Composition viewer), a plain item +//! (no check mark) that toggles the switch on the selection, and the guide mark by a guide layer's +//! name. +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use serde_json::json; + +/// A 640×360 comp with two 200×200 solids, "Left" at (160, 180) and "Right" at (480, 180). +fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, LayerId) { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Guides", "width": 640, "height": 360, "duration": 4})).unwrap(); + let mut solid = |name: &str, x: f64| { + let id = session.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + session.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [x, 180.0]})).unwrap(); + LayerId(id) + }; + let left = solid("Left", 160.0); + let right = solid("Right", 480.0); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(3); + (h, left, right) +} + +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 click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn guide(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> bool { + h.state().session.active_comp().unwrap().layer(id).unwrap().switches.guide +} + +fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) { + if let Ok(dir) = std::env::var("GUIDE_LAYER_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + } +} + +/// The open menu's Guide Layer item carries no check mark (a plain toggle). +fn plain(h: &Harness<'_, EffectcraftApp>) -> bool { + !h.get_by_label_contains("Guide Layer").accesskit_node().label().unwrap_or_default().contains('✔') +} + +fn row(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Pos2 { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) +} + +/// The Guide Layer item of the open menu `prefix`. +fn guide_item(h: &Harness<'_, EffectcraftApp>, prefix: &str) -> Rect { + rect(h, &format!("{prefix}.switch.guide")) +} + +#[test] +fn timeline_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_none(), "no guide mark yet"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![left], "right-click selects the row"); + assert!(plain(&h), "no check mark"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left), "Guide Layer turned on"); + assert!(!guide(&h, right), "only the target layer"); + // The guide mark shows by the name. + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_some(), "guide mark by the name"); + // Again: off. Undo brings it back. + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert!(plain(&h), "no check mark on a guide layer either"); + snapshot(&mut h, "timeline-menu"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left), "Guide Layer turned off"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(guide(&h, left)); +} + +#[test] +fn timeline_layer_menu_guide_layer_acts_on_the_selection() { + let (mut h, left, right) = harness(); + h.state_mut().session.execute("layer.select", json!({"layers": [left.0, right.0]})).unwrap(); + h.run_steps(3); + let p = row(&h, right); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left) && guide(&h, right), "both selected layers become guide layers"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left) && !guide(&h, right), "and back together"); +} + +#[test] +fn viewer_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + let comp = rect(&h, "viewer.comp"); + let k = comp.width() / 640.0; + let at = |x: f32| comp.min + vec2(x, 180.0) * k; + click(&mut h, at(480.0), PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![right], "right-click picks the layer under the pointer"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, right) && !guide(&h, left)); + // The Timeline and viewer menus share the item: the viewer menu turns it off again. + click(&mut h, at(480.0), PointerButton::Secondary); + assert!(plain(&h), "no check mark"); + snapshot(&mut h, "viewer-menu"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, right)); +}