mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Guide layers: hidden in nested comps, and in the Timeline context menu, as in After Effects
Guide layers render only in their own comp's viewer: a nested renderer (precomp, collapsed precomp in 2D, Classic 3D and Advanced 3D, comps read by effects, aux passes, the GPU walk's resident precomps) now runs with guides off (Renderer::nested_opts), so a precomp's guide layers no longer show in the parent comp's viewer or in a "Current Settings" render. The Timeline layer and Composition viewer right-click menus (shared layer_menu) gain a Guide Layer item (a plain toggle, no check mark) acting on the selection, and guide layers show a guide mark before their name in the Timeline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
549e9d8251
commit
f62af4ca73
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<u64> = 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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user