diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 3e78d490..787566de 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -382,6 +382,9 @@ fn set_switch(s: &mut Session, p: &Value) -> Result { 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 { 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, label: &str) -> Result { + 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 = + 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 { let sw = &l.switches; Some(match name { @@ -1538,7 +1573,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c7252acb..6eeede40 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index cbd0b58b..50a341ee 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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. diff --git a/crates/ui-egui/tests/ui_timeline_switches.rs b/crates/ui-egui/tests/ui_timeline_switches.rs index 20f861d4..ea0798d9 100644 --- a/crates/ui-egui/tests/ui_timeline_switches.rs +++ b/crates/ui-egui/tests/ui_timeline_switches.rs @@ -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.