mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
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.
This commit is contained in:
@@ -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<u64> {
|
||||
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<u64> {
|
||||
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.
|
||||
|
||||
@@ -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<u64>) {
|
||||
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<u64> = ["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<bool> {
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user