From 16d43346c4d5e92720360981bf8886c5d23e92f7 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 09:55:24 +0200 Subject: [PATCH] Timeline: the label swatch and the Modes column act on the selection (#1849) The label swatch's menu, the Blending Mode menu and Preserve Transparency passed the clicked layer alone, so a colour or a mode had to be set one layer at a time. They now act on the whole selection, with the Modes column edits going only to the selected layers that have video, as its cells do. The preserve-transparency checkbox also gets an automation id, like the cells around it. --- crates/ui-egui/src/panels/timeline.rs | 13 ++++-- crates/ui-egui/tests/ui_batch_switches.rs | 52 ++++++++++++++++++++++- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d6b79744f..ebe076dc8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1341,6 +1341,12 @@ fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u 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)] @@ -2077,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 { @@ -2230,11 +2236,12 @@ 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"}))); + actions.push(("layer.setSwitch".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "switch": "preserveTransparency"}))); } // 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/ui_batch_switches.rs b/crates/ui-egui/tests/ui_batch_switches.rs index 8534c77f4..acbc91f87 100644 --- a/crates/ui-egui/tests/ui_batch_switches.rs +++ b/crates/ui-egui/tests/ui_batch_switches.rs @@ -1,12 +1,15 @@ -//! 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 +//! 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. @@ -49,6 +52,11 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { 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; @@ -102,6 +110,10 @@ 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] @@ -177,3 +189,39 @@ fn alt_click_sets_the_selected_layers_apart() { 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"); +}