From bfb7507565152ae2c95f135fd5404d2bca66990a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 18:49:51 -0400 Subject: [PATCH] Timeline: a Quick Offset of layer bars ends on release off screen (#1012) The layer bar Quick Offset was followed and ended in its row's code, and rows out of view aren't drawn. Released with its row scrolled away, it stayed active: the next drag of that bar, even a plain one, ran as a Quick Offset from the old origin, into the old undo step. Like the key drag, it now follows the pointer below the rows and ends when the button is released, wherever the bar's row is. Test: a_quick_offset_released_off_screen_ends (release with the row scrolled away; a plain drag after it moves both layers together as its own undo step; fails before). --- crates/ui-egui/src/panels/timeline.rs | 56 +++++++++++++------------ crates/ui-egui/tests/ui_quick_offset.rs | 42 +++++++++++++++++++ 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 802913c4..5b422652 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1141,17 +1141,18 @@ fn key_drag_id() -> egui::Id { egui::Id::new("tl-key-drag") } -/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): where the pointer started, -/// how many layers it staggers and how many frames the last one has moved so far. +/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): the bar it started on, where +/// the pointer started, how many layers it staggers and how many frames the last one has moved. #[derive(Clone, Copy, Debug)] struct QuickBar { + layer: LayerId, origin_x: f32, layers: usize, applied: i64, } -fn quick_bar_id(layer: LayerId) -> egui::Id { - egui::Id::new(("tl-quick-bar", layer.0)) +fn quick_bar_id() -> egui::Id { + egui::Id::new("tl-quick-bar") } /// How many layers a Quick Offset of `layers` staggers (#1012): the unlocked ones, each once, if @@ -2170,7 +2171,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); let drag_key = format!("bar-{}", layer.id.0); // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the - // drag starts. The last selected layer moves by whole frames with the pointer. + // drag starts and followed below the rows until the button is released. let mods = ui.input(|i| i.modifiers); if body.drag_started() && is_sel @@ -2179,29 +2180,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(n) = quick_offset_layers(&comp, selected.iter().copied()) && let Some(p) = ctx.input(|i| i.pointer.press_origin()) { - ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), QuickBar { origin_x: p.x, layers: n, applied: 0 })); - } - let quick = ctx.data(|d| d.get_temp::(quick_bar_id(layer.id))); - if let Some(mut q) = quick { - if body.dragged() - && let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()) - { - let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64; - if frames != q.applied { - q.applied = frames; - ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), q)); - let merge = format!("bar-quick-{}", layer.id.0); - actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true}))); - } - drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers)); - } - if body.drag_stopped() { - ctx.data_mut(|d| d.remove::(quick_bar_id(layer.id))); - ui_actions.push(UiAct::EndMerge); - } + ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 })); } + let quick = ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { - if kind == "move" && quick.is_some() { + if kind == "move" && quick { continue; } if resp.dragged() { @@ -2937,6 +2920,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.remove::>(layer_drag_id())); } } + // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer + // until the button is released, wherever the bar's row has scrolled to. + if let Some(mut q) = ctx.data(|d| d.get_temp::(quick_bar_id())) { + let (down, ptr) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); + match ptr.filter(|_| down) { + Some(ptr) => { + let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64; + if frames != q.applied { + q.applied = frames; + ctx.data_mut(|d| d.insert_temp(quick_bar_id(), q)); + let merge = format!("bar-quick-{}", q.layer.0); + actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true}))); + } + drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers)); + } + None => { + ctx.data_mut(|d| d.remove::(quick_bar_id())); + ui_actions.push(UiAct::EndMerge); + } + } + } // Keyframe drag: the selected keys move by whole frames with the pointer until it is // released (one undo step); Shift snaps the grabbed key to the current time, the work area // and composition markers. diff --git a/crates/ui-egui/tests/ui_quick_offset.rs b/crates/ui-egui/tests/ui_quick_offset.rs index 31161517..0587e85f 100644 --- a/crates/ui-egui/tests/ui_quick_offset.rs +++ b/crates/ui-egui/tests/ui_quick_offset.rs @@ -199,3 +199,45 @@ fn shift_ranges_and_key_boxes_stagger_in_comp_order() { h.state_mut().session.execute("keys.quickOffset", json!({"delta": 2.0 / 30.0})).unwrap(); assert_eq!([key_times(&h, ids[2])[1], key_times(&h, ids[1])[1], key_times(&h, ids[0])[1]], [one, one + F, one + 2 * F]); } + +/// Releasing a Quick Offset drag with its row scrolled out of view ends it: a later plain drag of +/// that bar moves the selection together, as its own undo step. +#[test] +fn a_quick_offset_released_off_screen_ends() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Many", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let ids: Vec = + (0..40).map(|i| s.execute("layer.newSolid", json!({"name": format!("L{i}"), "color": "#e04020"})).unwrap()["layer"].as_u64().unwrap()).collect(); + let (top, next) = (ids[39], ids[38]); + s.execute("layer.select", json!({"layers": [top, next]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let from = center(&h, &format!("timeline.layer.{top}.bar")); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(ctrl_alt())); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: ctrl_alt() }); + h.step(); + for i in 1..=12 { + h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0 * i as f32, 0.0))); + h.step(); + } + // Let go of Ctrl+Alt, scroll the row away, then release the button. + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default())); + h.state_mut().ui.timeline.scroll_y = 10_000.0; + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{top}.bar")).is_none(), "the row is out of view"); + let to = from + vec2(120.0, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::default() }); + h.run_steps(2); + let staggered = (start(&h, next), start(&h, top)); + assert!(staggered.0 > 0 && staggered.1 == 0, "the Quick Offset applied: {staggered:?}"); + h.state_mut().ui.timeline.scroll_y = 0.0; + h.run_steps(3); + let from = center(&h, &format!("timeline.layer.{top}.bar")); + drag(&mut h, from, from + vec2(160.0, 0.0), Modifiers::default()); + let d = start(&h, top); + assert!(d > 0 && start(&h, next) == staggered.0 + d, "a plain drag moves both by the same amount"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!((start(&h, next), start(&h, top)), staggered, "as its own undo step"); +}