Timeline: dragging over layer switches sets them all (#227)

Each layer switch toggled on its own click only, so hiding or locking
many layers took a click per layer. As in After Effects, pressing a
switch in the A/V Features or Switches column now sets it at once, and
dragging up or down gives the same switch of every layer the pointer
passes the state the first one got, whatever their own state was
(rows passed in a quick drag included). The whole drag is one undo
step: layer.setSwitch takes a `merge` key like other gesture edits.
A single click still changes only that layer, so clicking the eye of
one selected layer doesn't change the others (Layer > Switches still
sets all selected layers).

Test: dragging_over_layer_switches_sets_them_all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 12:36:12 +07:00
parent 6b95b7a182
commit c4c28682eb
5 changed files with 164 additions and 18 deletions

View File

@ -404,7 +404,8 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
let v = b_p(p, "value");
let label = format!("Layer Switch ({name})");
let r = s.edit(&label, None, |proj, _| {
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
let r = s.edit(&label, merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut last = false;
// Toggle relative to the first layer so a multi-selection ends up consistent.
@ -1344,7 +1345,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Switch",
[],
None,
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?}",
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}",
has_layers,
set_switch
),

View File

@ -946,7 +946,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid);
bp.rect_filled(r, 0.0, if selected { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) });
let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0));
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), None).clicked() {
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() {
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.effect.{}.fx", g.uid), fxr, &g.name);

View File

@ -862,6 +862,56 @@ struct KeyDrag {
fn key_drag_id() -> egui::Id {
egui::Id::new("tl-key-drag")
}
/// A layer switch pressed and dragged over other layers: the switch, the state the press gave
/// it, and the undo step the drag folds into.
#[derive(Clone, Debug)]
struct SwitchDrag {
switch: &'static str,
value: bool,
merge: String,
}
fn switch_drag_id() -> egui::Id {
egui::Id::new("tl-switch-drag")
}
/// A layer's switch (A/V Features and Switches columns) in `row`. Pressing it sets it, and
/// dragging on gives the same switch of every layer the pointer passes the same new state, in
/// one undo step (After Effects).
#[allow(clippy::too_many_arguments)]
fn layer_switch(
ui: &mut egui::Ui,
t: &Tokens,
row: Rect,
br: Rect,
icon: Icon,
on: bool,
id: egui::Id,
layer: u64,
name: &'static str,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag());
let set = |actions: &mut Vec<(String, serde_json::Value)>, value: bool, merge: &str| {
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge})))
};
if resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
let merge = format!("tl-switch-{}", ui.input(|i| i.time));
set(actions, !on, &merge);
ui.data_mut(|d| d.insert_temp(switch_drag_id(), SwitchDrag { switch: name, value: !on, merge }));
} else if let Some(d) = ui.data(|d| d.get_temp::<SwitchDrag>(switch_drag_id())).filter(|d| d.switch == name && d.value != on)
// The heights the pointer moved over since the last frame, so a quick drag skips no row.
&& let Some((a, b)) = ui.input(|i| i.pointer.interact_pos().map(|p| (p.y - i.pointer.delta().y, p.y)))
&& a.min(b) < row.max.y
&& a.max(b) >= row.min.y
{
set(actions, d.value, &d.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})));
}
}
/// Set on the frame the reorder drag is released.
/// Where layers dropped at height `py` land among the visible layer rows: above the first row
/// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that
@ -923,6 +973,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg));
let mut actions: Vec<(String, serde_json::Value)> = Vec::new();
if !ui.input(|i| i.pointer.primary_down()) {
ctx.data_mut(|d| d.remove::<SwitchDrag>(switch_drag_id()));
}
// ---- header (left): time display, search, switches.
let tc = crate::panels::timecode(&app.session, &comp, time);
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
@ -1339,11 +1392,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
continue;
}
lp.rect_filled(br.shrink(0.5), 1.0, SWITCH_BOX);
let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("av", layer.id.0, i)), None);
layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, &mut actions);
app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
if resp.clicked() {
actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": name})));
}
}
// Label swatch.
if vis.label {
@ -1484,11 +1534,8 @@ 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, SWITCH_BOX);
let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("sw", layer.id.0, i)), None);
layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, &mut actions);
app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
if resp.clicked() {
actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": name})));
}
}
// Modes.
if app.ui.timeline.show_modes && layer.source.is_av() {
@ -1715,7 +1762,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
if let Some(en) = fx {
let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::new(("gfx", uid)), None).clicked() {
if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::new(("gfx", uid)), Sense::click()).clicked() {
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
}
app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
@ -1724,7 +1771,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Layer style eye switch in the A/V column, like AE.
let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0));
lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), None).clicked() {
if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), Sense::click()).clicked() {
actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
}
app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);

View File

@ -148,15 +148,15 @@ pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> R
resp
}
/// A flat icon toggle inside a rect.
pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id: egui::Id, on_color: Option<Color32>) -> Response {
let resp = ui.interact(rect, id, Sense::click());
/// A flat icon toggle inside a rect (`sense`: click, or click and drag for a switch dragged
/// over several rows).
pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id: egui::Id, sense: Sense) -> Response {
let resp = ui.interact(rect, id, sense);
if resp.hovered() {
ui.painter().rect_filled(rect, 2.0, t.hover);
}
let col = if on { on_color.unwrap_or(t.icon) } else { t.text_faint.gamma_multiply(0.7) };
if on || resp.hovered() {
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if !on { t.text_faint } else { col });
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if on { t.icon } else { t.text_faint });
}
resp
}

View File

@ -0,0 +1,98 @@
//! Timeline layer switches with real pointer input (egui_kittest): pressing a switch and
//! dragging over other layers gives them all the state the first one got, in one undo step, as
//! in After Effects (#227).
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
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);
}
/// Each layer's switch, top to bottom.
fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&effectcraft_engine::project::Layer) -> bool) -> Vec<bool> {
let comp = h.state().session.active_comp().unwrap();
ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect()
}
fn video(l: &effectcraft_engine::project::Layer) -> bool {
l.switches.video
}
fn motion_blur(l: &effectcraft_engine::project::Layer) -> bool {
l.switches.motion_blur
}
#[test]
fn dragging_over_layer_switches_sets_them_all() {
let (mut h, ids) = harness();
let eye = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.layer.{}.video", ids[i])).center();
let steps = h.state().session.history.undo.len();
// From A's eye down to D's: all hidden, one undo step.
let (a, d) = (eye(&h, 0), eye(&h, 3));
drag(&mut h, a, d, 10);
assert_eq!(states(&h, &ids, video), [false; 4]);
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, video), [true; 4]);
// Every layer gets the first one's new state, whatever its own: B hidden, then from C (shown
// → hidden) up to A, quickly (two moves): B stays hidden, A is hidden, D untouched.
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[1]], "switch": "video", "value": false})).unwrap();
h.run_steps(2);
let (c, a) = (eye(&h, 2), eye(&h, 0));
drag(&mut h, c, a, 2);
assert_eq!(states(&h, &ids, video), [false, false, false, true]);
// From B (hidden → shown) down to D: B, C and D shown.
let (b, d) = (eye(&h, 1), eye(&h, 3));
drag(&mut h, b, d + vec2(0.0, 40.0), 6);
assert_eq!(states(&h, &ids, video), [false, true, true, true]);
// A click still toggles only that layer.
let c = eye(&h, 2);
drag(&mut h, c, c, 1);
assert_eq!(states(&h, &ids, video), [false, true, false, true]);
// The Switches column works the same (Motion Blur, from D up to B).
let mb = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.layer.{}.switch.motionBlur", ids[i])).center();
let (d, b) = (mb(&h, 3), mb(&h, 1));
drag(&mut h, d, b, 8);
assert_eq!(states(&h, &ids, motion_blur), [false, true, true, true]);
assert_eq!(states(&h, &ids, video), [false, true, false, true], "other switches untouched");
}