diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index b798493b..0832163d 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -417,8 +417,10 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, let applies = |l: &Layer| match name { "audio" => l.props.sub("audio").is_some(), "lock" | "locked" => true, - // Nulls have a Video switch too (it hides their outline in the viewer). - "video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id), + // Every layer has a Video switch (it hides a null's outline in the viewer and turns a + // camera or light off), as the Timeline's eye cell shows; track mattes keep theirs off + // by design. + "video" | "eye" => !mattes.contains(&l.id), _ => l.source.is_av() && !mattes.contains(&l.id), }; let mut alone = true; diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index cdc1dae4..0a044937 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -114,10 +114,10 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result { fn hide_other_video(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; s.edit("Hide Other Video", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() { - if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) { - l.switches.video = false; - } + // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's + // eye cell shows): the selection stays shown. + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) { + l.switches.video = false; } Ok(()) })?; diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index fd19300d..5131bf3f 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -111,6 +111,29 @@ fn set_switch_others() { assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err()); } +/// The Video switch has a cell on every layer's row, cameras and lights included, so Alt-clicking +/// it (and Hide Other Video) takes them along with the rest. +#[test] +fn video_others_takes_cameras_and_lights() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap(); + let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, cam, light].map(|l| f(&layer(s, l))); + // The camera and the light are hidden: Alt-click on the solid's eye brings them back, since + // they stand apart with it, and the next Alt-click hides them with every other layer. + s.execute("layer.setSwitch", json!({"layers": [cam, light], "switch": "video", "value": false})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true; 3]); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true, false, false]); + // Hide Other Video takes them too. + s.execute("layer.showAllVideo", json!({})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true; 3]); + s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true, false, false]); +} + /// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles, /// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is /// hidden (parenting ignores the eye). diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 74fc84b2..0d5a3941 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -8,7 +8,7 @@ use effectcraft_engine::color::BlendMode; use effectcraft_engine::keyframe::{Interp, Value}; -use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; +use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, Project, PropGroup, Property}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -1314,6 +1314,39 @@ fn switch_gesture(ui: &egui::Ui, resp: &egui::Response, row: Rect, switch: &'sta (a.min(b) < row.max.y && a.max(b) >= row.min.y).then_some((d.value, d.merge)) } +/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode): +/// the selection when the row is selected, else that layer alone, as in After Effects. +fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec { + if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] } +} + +/// Whether the Timeline shows switch `name`'s cell on `layer`'s row (A/V Features and Switches +/// columns): a layer that has no such cell isn't one a click gives the switch to either. +fn switch_applies(layer: &Layer, name: &str, project: &Project) -> bool { + match name { + "video" | "lock" | "shy" => true, + "audio" => layer.props.sub("audio").is_some(), + "solo" | "quality" | "motionBlur" | "adjustment" => layer.source.is_av(), + "collapse" => collapse_applies(layer, project), + "fx" => layer.effects().is_some_and(|f| !f.children.is_empty()), + "frameBlend" => matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }), + "threeD" => layer.source.is_av() || matches!(layer.source, LayerSource::Null), + _ => true, + } +} + +/// The layers a click on the `name` switch of `layer`'s row acts on: [`edited_layers`] without +/// the layers the switch doesn't apply to. +fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u64, name: &str) -> Vec { + edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect() +} + +/// The layers a Modes column edit on `layer`'s row (its blending mode, Preserve Transparency) +/// acts on: [`edited_layers`] that have video. +fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { + edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect() +} + /// A layer's switch (A/V Features and Switches columns) in `row`, with the /// [`switch_gesture`]. #[allow(clippy::too_many_arguments)] @@ -1328,20 +1361,29 @@ fn layer_switch( layer: u64, name: &'static str, others: bool, + comp: &Comp, + project: &Project, + selected: &[LayerId], actions: &mut Vec<(String, serde_json::Value)>, ) { let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag()); // Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked), - // and come back on the next Alt-click. + // and come back on the next Alt-click. With several layers selected, the whole selection is + // set apart. if others && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed() && i.modifiers.alt) { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "others": true}))); + let layers = edited_layers(LayerId(layer), selected); + actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "others": true}))); return; } + // A press on a selected layer's switch gives the switch to every selected layer, as in After + // Effects; the targets are picked when the press happens, never while the row is drawn. if let Some((value, merge)) = switch_gesture(ui, &resp, row, name, on) { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge}))); + let layers = switch_targets(comp, project, selected, layer, name); + actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": value, "merge": merge}))); } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { // Keyboard or accessibility activation (a pointer press was handled above). - actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name}))); + let layers = switch_targets(comp, project, selected, layer, name); + actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": !on}))); } } @@ -2000,10 +2042,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // as in After Effects: it hides a null's outline in the viewer and turns a camera or // light off. let av_items: [(Icon, bool, &str, bool); 4] = [ - (Icon::Eye, sw.video, "video", true), - (Icon::Speaker, sw.audio, "audio", layer.props.sub("audio").is_some()), - (Icon::Solo, sw.solo, "solo", layer.source.is_av()), - (Icon::Lock, sw.locked, "lock", true), + (Icon::Eye, sw.video, "video", switch_applies(layer, "video", &app.session.project)), + (Icon::Speaker, sw.audio, "audio", switch_applies(layer, "audio", &app.session.project)), + (Icon::Solo, sw.solo, "solo", switch_applies(layer, "solo", &app.session.project)), + (Icon::Lock, sw.locked, "lock", switch_applies(layer, "lock", &app.session.project)), ]; for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() { let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(15.0, 15.0)); @@ -2011,7 +2053,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, true, &mut actions); + layer_switch( + ui, + &t, + r, + br, + icon, + on, + egui::Id::new(("av", layer.id.0, i)), + layer.id.0, + name, + true, + &comp, + &app.session.project, + &selected, + &mut actions, + ); app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name); } // Label swatch. @@ -2026,7 +2083,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { widgets::open_popup(ui, pop); } if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) { - actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()}))); + actions.push(("edit.label".into(), json!({"layers": edited_layers(layer.id, &selected), "label": lab.name()}))); } } if vis.num { @@ -2122,19 +2179,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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), - (Icon::Collapse, sw.collapse, "collapse", collapse_applies(layer, &app.session.project)), - (Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", layer.source.is_av()), - (Icon::Fx, sw.effects, "fx", layer.effects().is_some_and(|f| !f.children.is_empty())), + (Icon::Shy, sw.shy, "shy", switch_applies(layer, "shy", &app.session.project)), + (Icon::Collapse, sw.collapse, "collapse", switch_applies(layer, "collapse", &app.session.project)), + ( + Icon::Quality, + sw.quality == effectcraft_engine::project::Quality::Best, + "quality", + switch_applies(layer, "quality", &app.session.project), + ), + (Icon::Fx, sw.effects, "fx", switch_applies(layer, "fx", &app.session.project)), ( Icon::FrameBlend, sw.frame_blend != effectcraft_engine::project::FrameBlend::Off, "frameBlend", - matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }), + switch_applies(layer, "frameBlend", &app.session.project), ), - (Icon::MotionBlur, sw.motion_blur, "motionBlur", layer.source.is_av()), - (Icon::Adjustment, sw.adjustment, "adjustment", layer.source.is_av()), - (Icon::Cube, layer.is_3d(), "threeD", layer.source.is_av() || matches!(layer.source, LayerSource::Null)), + (Icon::MotionBlur, sw.motion_blur, "motionBlur", switch_applies(layer, "motionBlur", &app.session.project)), + (Icon::Adjustment, sw.adjustment, "adjustment", switch_applies(layer, "adjustment", &app.session.project)), + (Icon::Cube, layer.is_3d(), "threeD", switch_applies(layer, "threeD", &app.session.project)), ]; for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() { if !applicable || !vis.switches { @@ -2142,7 +2204,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, false, &mut actions); + layer_switch( + ui, + &t, + r, + br, + icon, + on, + egui::Id::new(("sw", layer.id.0, i)), + layer.id.0, + name, + false, + &comp, + &app.session.project, + &selected, + &mut actions, + ); app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name); } // Modes. @@ -2159,11 +2236,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] }) .collect(); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) { - actions.push(("layer.setBlendMode".into(), json!({"layers": [layer.id.0], "mode": opts[i]}))); + actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "mode": opts[i]}))); } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); + app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency"); if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": "preserveTransparency"}))); + // The click gives the selection the state the clicked row toggles to, + // whatever the first selected layer's value (as the switches do). + actions.push(( + "layer.setSwitch".into(), + json!({ + "layers": mode_targets(&comp, layer.id, &selected), + "switch": "preserveTransparency", + "value": !layer.preserve_transparency + }), + )); } // Track matte: layer picker + kind. let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0)); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index a6903854..7dc15334 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -7,6 +7,7 @@ mod ui_agent_audit; mod ui_angles; mod ui_audio_scrub; mod ui_azerty_shortcuts; +mod ui_batch_switches; mod ui_blank_context_menus; mod ui_camera_tracker; mod ui_character_eyedropper; diff --git a/crates/ui-egui/tests/it/ui_batch_switches.rs b/crates/ui-egui/tests/it/ui_batch_switches.rs new file mode 100644 index 00000000..407ac194 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_batch_switches.rs @@ -0,0 +1,244 @@ +//! A Timeline row control clicked on one of several selected layers applies to the whole +//! selection (#1849), as in After Effects: the A/V Features and Switches columns, the label +//! swatch and the Modes column. The engine commands already took several layers; the row +//! controls used to pass the clicked layer alone. + +use effectcraft_engine::Session; +use effectcraft_engine::color::{BlendMode, Label}; +use effectcraft_engine::project::{Layer, LayerId, Switches}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom. +fn harness() -> (Harness<'static, EffectcraftApp>, Vec) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids: Vec = ["D", "C", "B", "A"] + .iter() + .map(|name| s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap()) + .collect(); + ids.reverse(); + 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, ids) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// Press on `from`, move to `to` in `steps`, release there. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=steps { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A click on the Timeline control `id`. +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = rect(h, id).center(); + drag(h, p, p, 1); +} + +/// A click at `p` (a menu entry, found by its label). +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + drag(h, p, p, 1); +} + +fn alt_click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = rect(h, id).center(); + let modifiers = Modifiers::ALT; + h.event(Event::PointerMoved(p)); + h.event(Event::ModifiersChanged(modifiers)); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// Select `ids` in the Timeline, as a Shift range or a Cmd toggle would. +fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) { + h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap(); + h.run_steps(2); +} + +/// The state of switch `f` on each layer of `ids`, top to bottom. +fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&Layer) -> bool) -> Vec { + let comp = h.state().session.active_comp().unwrap(); + ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect() +} + +fn switches(h: &Harness<'_, EffectcraftApp>, id: u64) -> Switches { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.clone() +} + +fn locked(l: &Layer) -> bool { + l.switches.locked +} + +fn video(l: &Layer) -> bool { + l.switches.video +} + +fn three_d(l: &Layer) -> bool { + l.switches.three_d +} + +fn solo(l: &Layer) -> bool { + l.switches.solo +} + +fn shy(l: &Layer) -> bool { + l.switches.shy +} + +fn motion_blur(l: &Layer) -> bool { + l.switches.motion_blur +} + +fn preserved(l: &Layer) -> bool { + l.preserve_transparency +} + +/// #1849: a click on the Lock, 3D, Solo, Shy or Motion Blur switch of a selected layer gives the +/// switch to every selected layer, in one undo step. +#[test] +fn switches_on_a_selected_row_set_the_whole_selection() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + + let steps = h.state().session.history.undo.len(); + click(&mut h, &format!("timeline.layer.{}.lock", ids[0])); + assert_eq!(states(&h, &ids, locked), [true, false, true, false]); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(states(&h, &ids, locked), [false; 4]); + + click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[0])); + assert_eq!(states(&h, &ids, three_d), [true, false, true, false]); + // The switch of the other selected layer acts the same way. + click(&mut h, &format!("timeline.layer.{}.solo", ids[2])); + assert_eq!(states(&h, &ids, solo), [true, false, true, false]); + click(&mut h, &format!("timeline.layer.{}.switch.shy", ids[2])); + assert_eq!(states(&h, &ids, shy), [true, false, true, false]); + click(&mut h, &format!("timeline.layer.{}.switch.motionBlur", ids[0])); + assert_eq!(states(&h, &ids, motion_blur), [true, false, true, false]); + // A second click turns both off again, whatever their own state. + click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[2])); + assert_eq!(states(&h, &ids, three_d), [false; 4]); +} + +/// A switch on a row that isn't selected still changes that layer alone, and leaves the +/// selection. +#[test] +fn a_switch_on_an_unselected_row_sets_that_layer_only() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + click(&mut h, &format!("timeline.layer.{}.lock", ids[1])); + assert_eq!(states(&h, &ids, locked), [false, true, false, false]); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(ids[0]), LayerId(ids[2])]); +} + +/// The targets skip the selected layers a switch doesn't apply to: 3D reaches a null but not a +/// camera (3D by kind), the Effect switch not a layer without effects. +#[test] +fn switch_targets_skip_the_layers_the_switch_does_not_apply_to() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let null = s.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap()["layer"].as_u64().unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"effect": "ec.blur.gaussian", "layers": [solid]})).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); + select(&mut h, &[solid, null, cam]); + + click(&mut h, &format!("timeline.layer.{solid}.switch.threeD")); + assert!(switches(&h, solid).three_d && switches(&h, null).three_d, "3D reaches the null"); + assert!(!switches(&h, cam).three_d, "a camera is 3D by kind, its own switch stays off"); + + click(&mut h, &format!("timeline.layer.{solid}.switch.fx")); + assert!(!switches(&h, solid).effects, "the Effect switch reaches the layer that has an effect"); + assert!(switches(&h, null).effects && switches(&h, cam).effects, "no effects, no Effect switch"); +} + +/// Alt-click on an A/V feature sets the selected layers apart (the others off), and the next +/// Alt-click brings the others back — with several layers, the whole selection stands apart. +#[test] +fn alt_click_sets_the_selected_layers_apart() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0])); + assert_eq!(states(&h, &ids, video), [true, false, true, false]); + alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0])); + assert_eq!(states(&h, &ids, video), [true; 4]); +} + +/// The label swatch menu of a selected layer sets the label of every selected layer. +#[test] +fn the_label_menu_sets_the_selections_label() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + let green = h.state().session.prefs.label_name(Label::Green); + click(&mut h, &format!("timeline.layer.{}.label", ids[0])); + let entry = h.query_by_label(&green).expect("the label menu is open").rect(); + click_at(&mut h, entry.center()); + let label = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().label; + assert_eq!([label(ids[0]), label(ids[1]), label(ids[2]), label(ids[3])], [Label::Green, Label::Red, Label::Green, Label::Red]); +} + +/// The Modes column: the Blending Mode menu and the Preserve Transparency checkbox of a selected +/// layer set both selected layers, one undo step each. +#[test] +fn the_modes_column_sets_the_whole_selection() { + let (mut h, ids) = harness(); + h.state_mut().ui.timeline.show_modes = true; + h.run_steps(3); + select(&mut h, &[ids[0], ids[2]]); + + click(&mut h, &format!("timeline.layer.{}.mode", ids[0])); + let entry = h.query_by_label("Multiply").expect("the blend mode menu is open").rect(); + click_at(&mut h, entry.center()); + let mode = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().blend_mode; + assert_eq!([mode(ids[0]), mode(ids[1]), mode(ids[2]), mode(ids[3])], [BlendMode::Multiply, BlendMode::Normal, BlendMode::Multiply, BlendMode::Normal]); + + click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[0])); + assert_eq!(states(&h, &ids, preserved), [true, false, true, false]); + + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(states(&h, &ids, preserved), [false; 4], "one undo step put both back"); +} + +/// Preserve Transparency toggles from the row it is clicked on, not from the first selected layer: +/// A on and C off, clicking C's box turns both on. +#[test] +fn preserve_transparency_toggles_from_the_clicked_row() { + let (mut h, ids) = harness(); + h.state_mut().ui.timeline.show_modes = true; + h.run_steps(3); + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[0]], "switch": "preserveTransparency", "value": true})).unwrap(); + h.run_steps(2); + select(&mut h, &[ids[0], ids[2]]); + click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2])); + assert_eq!(states(&h, &ids, preserved), [true, false, true, false]); + // The next click turns both off again. + click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2])); + assert_eq!(states(&h, &ids, preserved), [false; 4]); +} diff --git a/docs/agents.md b/docs/agents.md index b2589a7a..04f09a2e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -595,6 +595,12 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). properties, FF missing effects, EE Expressions, LL Waveform, UU everything changed from its default. Shift+key adds or removes a set; the same key again hides it; Ctrl/Cmd+` twirls the selected layers open or closed. With no layer selected they apply to every layer. +* A row control clicked on one of several selected layers applies to every selected layer, as in + After Effects: the A/V switches (video, audio, solo, lock), the Switches column (shy, collapse, + quality, fx, frame blending, motion blur, adjustment, 3D), the label swatch and the Modes column + (blending mode, Preserve Transparency), one undo step, with the layers the switch doesn't apply + to left out. A control on an unselected row changes that layer alone; Alt-clicking an A/V feature + still sets the selected layers apart from the rest. * `keys.toggleTransform {"layers"?: [...], "prop": "position"}` (Alt+Shift+A/P/S/R/T) adds or removes a keyframe at the current time on that Transform property of each layer, in one undo step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ccce70e0..f2956918 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. |---|---|---:|---| | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed | -| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there) | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849) | | Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed |