From bf7aff11f89f1461515bdbc7ab37fd5d56169ca9 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 09:54:16 +0200 Subject: [PATCH] Timeline: layer switches act on every selected layer (#1849) A switch click on one of several selected layers passed that layer alone to `layer.setSwitch`, so Lock, 3D, Video (and the rest of the A/V Features and Switches columns) had to be set one layer at a time. The click now gives the switch to the whole selection, with the layers it doesn't apply to left out (3D on a camera, Effect on a layer without effects, Audio on a layer without audio, ...). The targets are picked when the press happens, so drawing a row costs nothing more than before. A row that isn't selected still changes that layer alone, pressing and dragging over rows is unchanged, and Alt-clicking an A/V feature sets the selected layers apart. --- crates/ui-egui/src/panels/timeline.rs | 109 ++++++++++--- crates/ui-egui/tests/ui_batch_switches.rs | 179 ++++++++++++++++++++++ 2 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/tests/ui_batch_switches.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d..d6b79744 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,33 @@ 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() +} + /// A layer's switch (A/V Features and Switches columns) in `row`, with the /// [`switch_gesture`]. #[allow(clippy::too_many_arguments)] @@ -1328,20 +1355,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 +2036,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 +2047,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. @@ -2122,19 +2173,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 +2198,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. diff --git a/crates/ui-egui/tests/ui_batch_switches.rs b/crates/ui-egui/tests/ui_batch_switches.rs new file mode 100644 index 00000000..8534c77f --- /dev/null +++ b/crates/ui-egui/tests/ui_batch_switches.rs @@ -0,0 +1,179 @@ +//! A Timeline switch clicked on one of several selected layers applies to the whole selection +//! (#1849), as in After Effects. The engine commands already took several layers; the row +//! controls used to pass the clicked layer alone. + +use effectcraft_engine::Session; +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 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); +} + +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 +} + +/// #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]); +}