Merge pull request #921 from pravdomil/claude/alt-click-layer-toggles-b7a7a3

Timeline: Alt-click an A/V feature switches the other layers
This commit is contained in:
Brandon Thomas committed 2026-10-12 04:20:54 -04:00
commit e2db76e507
4 files changed
+111 -3

No files matched your search

+36 -1
View File
@@ -382,6 +382,9 @@ 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})");
if b_p(p, "others") == Some(true) {
return set_switch_others(s, cid, &ids, &name, v, &label);
}
// `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)?;
@@ -399,6 +402,38 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(r))
}
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named
/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch
/// applies to is the opposite; when they already do, the other layers come back. `value` sets
/// the other layers directly.
fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option<bool>, label: &str) -> Result<Value> {
let r = s.edit(label, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
// The state that sets a layer apart: unlocked for Lock, on for the rest.
let active = !matches!(name, "lock" | "locked");
// Track mattes keep their video off by design.
let mattes: Vec<LayerId> =
if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() };
let applies = |l: &Layer| match name {
"audio" => l.props.sub("audio").is_some(),
"lock" | "locked" => true,
_ => l.source.is_av() && !mattes.contains(&l.id),
};
let mut alone = true;
for l in comp.layers.iter().filter(|l| applies(l)) {
let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
alone &= on == (ids.contains(&l.id) == active);
}
let others = value.unwrap_or(alone == active);
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
let v = if ids.contains(&l.id) { active } else { others };
set_switch_value(l, name, v);
}
Ok(others)
})?;
Ok(json!(r))
}
fn switch_value(l: &Layer, name: &str) -> Option<bool> {
let sw = &l.switches;
Some(match name {
@@ -1538,7 +1573,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?, merge?}",
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}",
has_layers,
set_switch
),
+35
View File
@@ -52,6 +52,41 @@ fn layer_quality_sampling_frame_blending_and_undo() {
assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err());
}
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature): the layer stands alone, the
/// other layers come back the next time.
#[test]
fn set_switch_others() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
let c = solid(&mut s, "#0000ff");
let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, b, c].map(|l| f(&layer(s, l)));
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "value": false})).unwrap();
// B is hidden: it is shown, alone; then the others come back; one undo step each.
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
// Solo.
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "solo", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "solo", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.solo), [false, false, true]);
// Lock: the layer stays unlocked and the others lock, then unlock.
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.locked), [false, true, true]);
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.locked), [false; 3]);
// A track matte keeps its video off.
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": a})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "value": false})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [false, true, true]);
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
}
#[test]
fn layer_switches_menu() {
let mut s = comp();
+9 -2
View File
@@ -1169,9 +1169,16 @@ fn layer_switch(
id: egui::Id,
layer: u64,
name: &'static str,
others: bool,
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.
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})));
return;
}
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})));
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
@@ -1787,7 +1794,7 @@ 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, &mut actions);
layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, true, &mut actions);
app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
}
// Label swatch.
@@ -1913,7 +1920,7 @@ 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, &mut actions);
layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, false, &mut actions);
app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
}
// Modes.
@@ -100,6 +100,37 @@ fn dragging_over_layer_switches_sets_them_all() {
assert_eq!(states(&h, &ids, video), [false, true, false, true], "other switches untouched");
}
/// Alt-click on an A/V feature sets the layer apart (the other layers hidden / locked), and the
/// next Alt-click brings the others back; the Switches column is not affected by Alt.
#[test]
fn alt_click_on_av_feature_switches_the_other_layers() {
let (mut h, ids) = harness();
let alt_click = |h: &mut Harness<'_, EffectcraftApp>, id: String| {
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);
};
let steps = h.state().session.history.undo.len();
alt_click(&mut h, format!("timeline.layer.{}.video", ids[1]));
assert_eq!(states(&h, &ids, video), [false, true, false, false]);
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
alt_click(&mut h, format!("timeline.layer.{}.video", ids[1]));
assert_eq!(states(&h, &ids, video), [true; 4]);
alt_click(&mut h, format!("timeline.layer.{}.lock", ids[2]));
assert_eq!(states(&h, &ids, |l| l.switches.locked), [true, true, false, true]);
alt_click(&mut h, format!("timeline.layer.{}.solo", ids[0]));
assert_eq!(states(&h, &ids, |l| l.switches.solo), [true, false, false, false]);
alt_click(&mut h, format!("timeline.layer.{}.switch.motionBlur", ids[0]));
assert_eq!(states(&h, &ids, motion_blur), [true, false, false, false]);
}
/// #284: pressing a stopwatch and dragging over the stopwatches below gives every property the
/// state the first one got (animated or not), in one undo step; Alt+click still adds an
/// expression and a click toggles one property.