diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 5881b329..ca34760e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -404,7 +404,8 @@ 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})"); - 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 { "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 ), diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 27c034e8..bbd6ce47 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index f0613634..341decdd 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::(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::(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); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c737953f..b92e87ae 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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) -> 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 } diff --git a/crates/ui-egui/tests/ui_timeline_switches.rs b/crates/ui-egui/tests/ui_timeline_switches.rs new file mode 100644 index 00000000..501d2096 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_switches.rs @@ -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) { + 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 = ["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 { + 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"); +}