diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d7017e7fd..be0ef05d5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -22,7 +22,9 @@ use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; -#[derive(Clone, Debug)] +mod marquee; + +#[derive(Clone, Debug, PartialEq)] enum RowKind { Layer, Group { @@ -328,7 +330,7 @@ fn focus_expression(ctx: &egui::Context, prop: u64, select: [usize; 2]) { ctx.data_mut(|d| d.insert_temp(expr_focus_id(), ExprFocus { prop, select, pass })); } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] struct Row { layer: LayerId, depth: usize, @@ -1969,8 +1971,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { g.max_scroll = (total_h - g.rows_rect.height() + g.rh).max(0.0); g.rows = rows; reveal_scroll(app, g); - wheel_zoom_scroll(app, ui, g); - pick_whip_edge_scroll(app, g); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); let ectx = EvalCtx { @@ -1986,6 +1986,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; let mut rw = begin_rows(app, ui, tl); let g = &mut rw; + marquee::prepare(app, ui, g); + wheel_zoom_scroll(app, ui, &g.tl); + pick_whip_edge_scroll(app, &g.tl); + marquee::scroll(app, g); draw_rows(app, ui, g, &ectx); ui.set_clip_rect(g.full_clip); background_menus(app, ui, g); @@ -1997,7 +2001,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { group_reorder_drag(ui, g); quick_offset_follow(g); key_drag_follow(app, g); - box_select(app, ui, g); + marquee::finish(app, ui, g); current_time_indicator(app, g); let footer = footer_bar(app, ui, g); footer_zoom(app, ui, g, footer); @@ -4460,7 +4464,7 @@ fn background_menus(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) { } } -/// Click on the empty outline (its drag is the Timeline's one box, [`box_select`]). +/// Click on the empty outline (its drag is the Timeline's one box, [`marquee`]). fn outline_marquee(ui: &mut egui::Ui, g: &mut Rw) { let actions = &mut g.tl.actions; let outline_empty = g.outline_empty.clone(); @@ -4578,7 +4582,7 @@ fn alt_scale_and_graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, // below the last row) deselects the layers and their keys and properties, as in After } -/// Click on empty track area (its drag is the Timeline's one box, [`box_select`]). +/// Click on empty track area (its drag is the Timeline's one box, [`marquee`]). fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) { let actions = &mut g.tl.actions; let empty = g.empty.clone(); @@ -4797,80 +4801,6 @@ fn keys_in_box(comp: &Comp, rows: &[Row], tm: &TMap, top: f32, rh: f32, br: Rect keys } -/// What a box over the Timeline selects: the layer rows it crosses (locked layers stay out) and -/// the keys whose markers it touches. A box that reaches the outline (started on either side) -/// selects the rows it crosses; one kept inside the time graph selects keys only, as before. -/// Keys exist only in the time graph, so the box is cut at its left edge (`graph_x0`; a key -/// scrolled out under the outline isn't touched). -/// `graph_on`: the Graph Editor shows no key markers here. -#[allow(clippy::too_many_arguments)] -fn box_targets( - comp: &Comp, - rows: &[Row], - hit_rows: &[(Rect, Row)], - tm: &TMap, - top: f32, - rh: f32, - graph_x0: f32, - graph_on: bool, - br: Rect, -) -> (Vec, Vec) { - let layers = hit_rows - .iter() - .filter(|(r, row)| br.min.x < graph_x0 && matches!(row.kind, RowKind::Layer) && r.intersects(br)) - .filter(|(_, row)| comp.layer(row.layer).is_some_and(|l| !l.switches.locked)) - .map(|(_, row)| row.layer.0) - .collect(); - let in_graph = Rect::from_min_max(pos2(br.min.x.max(graph_x0), br.min.y), pos2(br.max.x, br.max.y)); - let keys = if graph_on || in_graph.width() < 0.0 { vec![] } else { keys_in_box(comp, rows, tm, top, rh, in_graph) }; - (layers, keys) -} - -/// The Timeline's one selection box, from the empty outline or the empty time graph: it spans both -/// columns as it is dragged (the pointer may cross the boundary either way) and on release selects -/// the layer rows it crosses and the keys inside it, as in After Effects. -fn box_select(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) { - let t = g.tl.t; - let tm = g.tl.tm; - let rows_rect = g.tl.rows_rect; - let rh = g.tl.rh; - let graph_x0 = g.tl.graph_x0; - let graph_on = g.graph_on; - let ctx = g.tl.ctx.clone(); - let comp = g.tl.comp.clone(); - let rows = g.tl.rows.clone(); - let origin = [&g.outline_empty, &g.empty].into_iter().find(|r| r.dragged() || r.drag_stopped()).cloned(); - let Some(origin) = origin else { return }; - let box_of = |o: &egui::Response| { - let start = ctx.input(|i| i.pointer.press_origin()); - start.zip(o.interact_pointer_pos().or_else(|| ctx.input(|i| i.pointer.latest_pos()))).map(|(a, b)| Rect::from_two_pos(a, b).intersect(rows_rect)) - }; - let id = egui::Id::unique("tl-box"); - if origin.dragged() - && let Some(br) = box_of(&origin) - { - let bp = g.tl.p.with_clip_rect(rows_rect); - bp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12)); - bp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); - ctx.data_mut(|d| d.insert_temp(id, br)); - } - if origin.drag_stopped() - && let Some(br) = ctx.data(|d| d.get_temp::(id)) - { - ctx.data_mut(|d| d.remove::(id)); - let (layers, touched) = box_targets(&comp, &rows, &g.hit_rows, &tm, rows_rect.min.y - app.ui.timeline.scroll_y, rh, graph_x0, graph_on, br); - let mods = ui.input(|i| i.modifiers); - let add = mods.shift || mods.command; - let actions = &mut g.tl.actions; - // A box that catches nothing leaves nothing selected (layers included), unless it adds. - actions.push(("layer.select".into(), json!({"layers": layers, "add": add}))); - if !touched.is_empty() { - let keys: Vec = touched.iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect(); - actions.push(("keys.select".into(), json!({"keys": keys, "add": true, "selectProperties": true}))); - } - } -} - fn current_time_indicator(app: &mut EffectcraftApp, g: &Rw) { let t = g.tl.t; let time = g.tl.time; diff --git a/crates/ui-egui/src/panels/timeline/marquee.rs b/crates/ui-egui/src/panels/timeline/marquee.rs new file mode 100644 index 000000000..d4eb56c76 --- /dev/null +++ b/crates/ui-egui/src/panels/timeline/marquee.rs @@ -0,0 +1,192 @@ +//! A marquee owns content coordinates; clipping belongs only to its painter. + +use std::sync::Arc; + +use effectcraft_engine::project::Comp; +use egui::{Id, Pos2, Rect, Stroke, StrokeKind, pos2}; +use serde_json::json; + +use super::{Row, RowKind, Rw, TMap, edge_scroll_speed, keys_in_box, row_height}; +use crate::EffectcraftApp; + +#[derive(Clone)] +struct Marquee { + owner: Id, + comp: Arc, + rows: Arc>, + graph_on: bool, + layout: (Rect, f32, f32), + anchor: Anchor, + dragging: bool, +} + +/// The pressed corner in content coordinates: a time (or an outline x) and a scroll-free y. +#[derive(Clone, Copy)] +pub(super) struct Anchor { + time: f64, + y: f32, + outline_x: Option, +} + +/// The timeline layout a marquee is projected through. +#[derive(Clone, Copy)] +pub(super) struct View { + pub tm: TMap, + /// Screen y of the first row at scroll 0. + pub top: f32, + pub graph_x0: f32, + pub graph_on: bool, +} + +impl View { + fn of(g: &Rw) -> Self { + Self { tm: g.tl.tm, top: g.tl.rows_rect.min.y, graph_x0: g.tl.graph_x0, graph_on: g.graph_on } + } +} + +fn id() -> Id { + Id::unique("tl-marquee") +} + +/// Capture on press, before wheel handling, even if the drag threshold is not crossed yet. +pub(super) fn prepare(app: &EffectcraftApp, ui: &egui::Ui, g: &Rw) { + let ctx = &g.tl.ctx; + let (pressed, down, released, escape, focused) = + ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released(), i.key_pressed(egui::Key::Escape), i.focused)); + if let Some(mut drag) = ctx.data(|d| d.get_temp::(id())) { + let owned = ctx.dragged_id() == Some(drag.owner); + let dropped = released && ctx.drag_stopped_id() == Some(drag.owner); + let valid = ui.is_enabled() + && focused + && !escape + && Arc::ptr_eq(&drag.comp, &g.tl.comp) + && *drag.rows == *g.tl.rows + && drag.graph_on == g.graph_on + && drag.layout == (g.tl.rect, g.tl.rh, g.tl.graph_x0) + && (dropped || (down && (owned || (!drag.dragging && ctx.dragged_id().is_none())))); + drag.dragging |= owned; + ctx.data_mut(|d| { + if valid { + d.insert_temp(id(), drag); + } else { + d.remove::(id()); + } + }); + } + if pressed && ui.is_enabled() && focused && !escape { + let owner = [&g.outline_empty, &g.empty].into_iter().find(|r| r.is_pointer_button_down_on()); + if let Some(owner) = owner + && let Some(p) = ctx.input(|i| i.pointer.press_origin()) + { + ctx.data_mut(|d| { + d.insert_temp( + id(), + Marquee { + owner: owner.id, + comp: g.tl.comp.clone(), + rows: g.tl.rows.clone(), + graph_on: g.graph_on, + layout: (g.tl.rect, g.tl.rh, g.tl.graph_x0), + anchor: Anchor::press(&View::of(g), app.ui.timeline.scroll_y, p), + dragging: false, + }, + ); + }); + } + } +} + +/// Only the marquee owner scrolls, with the same bounded edge speed as a pick whip. +pub(super) fn scroll(app: &mut EffectcraftApp, g: &Rw) { + let ctx = &g.tl.ctx; + if let Some(drag) = ctx.data(|d| d.get_temp::(id())) + && ctx.dragged_id() == Some(drag.owner) + && ctx.input(|i| i.pointer.primary_down()) + && let Some(p) = ctx.input(|i| i.pointer.latest_pos()) + { + let speed = edge_scroll_speed(g.tl.rows_rect, p.y, g.tl.rh); + let old = app.ui.timeline.scroll_y; + app.ui.timeline.scroll_y = (old + speed * ctx.input(|i| i.stable_dt).min(0.1)).clamp(0.0, g.tl.max_scroll); + if app.ui.timeline.scroll_y != old { + ctx.request_repaint(); + } + } +} + +pub(super) struct SelectionBox { + pub keys: Rect, + pub outline: bool, +} + +impl Anchor { + /// Anchor a press at screen point `p` with the rows scrolled by `scroll`. + pub(super) fn press(v: &View, scroll: f32, p: Pos2) -> Self { + Self { time: v.tm.t(p.x.max(v.graph_x0)), y: p.y - v.top + scroll, outline_x: (p.x < v.graph_x0).then_some(p.x) } + } + + /// Reproject the content anchor without clipping the logical selection bounds. + pub(super) fn selection(&self, v: &View, scroll: f32, pointer: Pos2) -> (Rect, SelectionBox) { + let tm = v.tm; + let anchor = pos2(self.outline_x.unwrap_or_else(|| tm.x(self.time)), v.top + self.y - scroll); + let paint = Rect::from_two_pos(anchor, pointer); + let graph = self.outline_x.is_none() || pointer.x >= v.graph_x0; + let keys = + if graph && !v.graph_on { Rect::from_two_pos(pos2(tm.x(self.time), anchor.y), pos2(pointer.x.max(v.graph_x0), pointer.y)) } else { Rect::NOTHING }; + (paint, SelectionBox { keys, outline: self.outline_x.is_some() || pointer.x < v.graph_x0 }) + } +} + +/// Query all logical rows; the renderer's visible hit rows are not selection data. +pub(super) fn targets( + comp: &Comp, + rows: &[Row], + tm: &TMap, + top: f32, + rh: f32, + paint: Rect, + selection: &SelectionBox, +) -> (Vec, Vec) { + let mut layers = vec![]; + let mut y = top; + for row in rows { + let bottom = y + row_height(row, rh); + if selection.outline + && matches!(row.kind, RowKind::Layer) + && y <= paint.max.y + && bottom >= paint.min.y + && comp.layer(row.layer).is_some_and(|l| !l.switches.locked) + { + layers.push(row.layer.0); + } + y = bottom; + } + (layers, keys_in_box(comp, rows, tm, top, rh, selection.keys)) +} + +/// Commit an owned primary release; otherwise paint without changing selection. +pub(super) fn finish(app: &EffectcraftApp, ui: &egui::Ui, g: &mut Rw) { + let ctx = &g.tl.ctx; + let Some(drag) = ctx.data(|d| d.get_temp::(id())) else { return }; + let released = ctx.input(|i| i.pointer.primary_released()); + let dropped = released && ctx.drag_stopped_id() == Some(drag.owner); + if ctx.dragged_id() != Some(drag.owner) && !dropped { + return; + } + let Some(pointer) = ctx.input(|i| i.pointer.latest_pos()) else { return }; + let (paint, selection) = drag.anchor.selection(&View::of(g), app.ui.timeline.scroll_y, pointer); + if dropped { + ctx.data_mut(|d| d.remove::(id())); + let (layers, keys) = targets(&g.tl.comp, &g.tl.rows, &g.tl.tm, g.tl.rows_rect.min.y - app.ui.timeline.scroll_y, g.tl.rh, paint, &selection); + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + g.tl.actions.push(("layer.select".into(), json!({"layers": layers, "add": add}))); + if !keys.is_empty() { + let keys: Vec<_> = keys.iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect(); + g.tl.actions.push(("keys.select".into(), json!({"keys": keys, "add": true, "selectProperties": true}))); + } + } else { + let clip = if selection.outline { g.tl.rows_rect } else { g.right_clip }; + let painter = g.tl.p.with_clip_rect(clip); + painter.rect_filled(paint, 0.0, g.tl.t.accent.gamma_multiply(0.12)); + painter.rect_stroke(paint, 0.0, Stroke::new(1.0, g.tl.t.accent), StrokeKind::Inside); + } +} diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 983d80ae6..e42923e4a 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -959,25 +959,33 @@ fn a_box_over_both_columns_selects_the_rows_it_crosses_and_the_keys_inside() { rows.push(Row { layer: LayerId(*id), depth: 1, kind: RowKind::Prop { uid: uid(*id) } }); } // Rows are 20 pt and 400 pt wide: the outline is left of x 200, a key at 0 s sits at x 205. - let hit_rows: Vec<(Rect, Row)> = - rows.iter().enumerate().map(|(i, r)| (Rect::from_min_size(pos2(0.0, 20.0 * i as f32), vec2(400.0, 20.0)), r.clone())).collect(); let tm = TMap { x0: 205.0, start: 0.0, pps: 100.0 }; - let go = |br: Rect| box_targets(comp, &rows, &hit_rows, &tm, 0.0, 20.0, 200.0, false, br); + let view = marquee::View { tm, top: 0.0, graph_x0: 200.0, graph_on: false }; + // Press at `from` with the rows scrolled by `s0`, release at `to` scrolled by `s1`. + let drag = |from: Pos2, s0: f32, to: Pos2, s1: f32| { + let (paint, sel) = marquee::Anchor::press(&view, s0, from).selection(&view, s1, to); + marquee::targets(comp, &rows, &tm, -s1, 20.0, paint, &sel) + }; + let go = |from: Pos2, to: Pos2| drag(from, 0.0, to, 0.0); let keyed = |ks: &[effectcraft_engine::KeyRef]| ks.iter().map(|k| k.layer.0).collect::>(); // From the outline across the boundary: layer A's row (y 0..20) and B's row, and their keys. - let (ls, ks) = go(Rect::from_min_max(pos2(10.0, 5.0), pos2(260.0, 65.0))); + let (ls, ks) = go(pos2(10.0, 5.0), pos2(260.0, 65.0)); assert_eq!(ls, [layers[0], layers[1]]); assert_eq!(keyed(&ks), [layers[0], layers[1]]); - // The same box dragged the other way is the same rectangle: the same rows, the same keys. - let (ls, ks) = go(Rect::from_two_pos(pos2(260.0, 65.0), pos2(10.0, 5.0))); + // The same box dragged the other way: the same rows, the same keys. + let (ls, ks) = go(pos2(260.0, 65.0), pos2(10.0, 5.0)); assert_eq!((ls, keyed(&ks)), (vec![layers[0], layers[1]], vec![layers[0], layers[1]])); // Only over the outline: rows, no keys; only over the graph: keys, and the rows crossed. - let (ls, ks) = go(Rect::from_min_max(pos2(10.0, 5.0), pos2(150.0, 65.0))); + let (ls, ks) = go(pos2(10.0, 5.0), pos2(150.0, 65.0)); assert_eq!((ls.len(), ks.len()), (2, 0), "a key under the outline isn't touched"); // Kept inside the time graph: keys only, no rows (a marquee over empty track area). - let (ls, ks) = go(Rect::from_min_max(pos2(204.0, 5.0), pos2(260.0, 65.0))); + let (ls, ks) = go(pos2(204.0, 5.0), pos2(260.0, 65.0)); assert_eq!((ls.len(), keyed(&ks)), (0, vec![layers[0], layers[1]])); // A locked layer is not selected as a row (the engine drops its keys in keys.select). - let (ls, _) = go(Rect::from_min_max(pos2(10.0, 5.0), pos2(260.0, 119.0))); + let (ls, _) = go(pos2(10.0, 5.0), pos2(260.0, 119.0)); + assert_eq!(ls, [layers[0], layers[1]]); + // Scrolled 20 pt mid-drag: the pressed corner stays on layer A's row (#2859), so A is still + // selected; a screen-fixed anchor would start the box below A and select only B. + let (ls, _) = drag(pos2(10.0, 5.0), 0.0, pos2(260.0, 45.0), 20.0); assert_eq!(ls, [layers[0], layers[1]]); } diff --git a/crates/ui-egui/tests/it/ui_timeline_marquee.rs b/crates/ui-egui/tests/it/ui_timeline_marquee.rs index bf6dab74e..9d72f08aa 100644 --- a/crates/ui-egui/tests/it/ui_timeline_marquee.rs +++ b/crates/ui-egui/tests/it/ui_timeline_marquee.rs @@ -13,9 +13,9 @@ use serde_json::json; const REACH: f32 = 5.5; /// A comp with one solid, its Opacity keys at 1 s and 2 s (linear) revealed. -fn harness() -> (Harness<'static, EffectcraftApp>, u64) { +fn harness_for(duration: f64) -> (Harness<'static, EffectcraftApp>, u64) { let mut s = Session::default(); - s.execute("comp.new", json!({"name": "Box", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Box", "width": 320, "height": 180, "frameRate": 30, "duration": duration})).unwrap(); let id = s.execute("layer.newSolid", json!({"name": "L", "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap(); for (t, v) in [(1.0, 0.0), (2.0, 100.0)] { s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap(); @@ -29,6 +29,10 @@ fn harness() -> (Harness<'static, EffectcraftApp>, u64) { (h, id) } +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + harness_for(4.0) +} + /// The center of the key at `secs`: the middle of its hit rect, which is centered on the glyph. fn key_center(h: &Harness<'_, EffectcraftApp>, id: u64, secs: f64) -> Pos2 { let comp = h.state().session.active_comp().unwrap(); @@ -122,3 +126,265 @@ fn a_box_dragged_across_the_outline_and_the_graph_selects_rows_and_keys() { assert_eq!(keys, [1.0, 1.0]); assert_eq!(selected(&h), (both.clone(), both), "graph → outline"); } + +/// Thirty expanded layers force row virtualization and leave room for wheel and edge scrolling. +fn rows_harness() -> (Harness<'static, EffectcraftApp>, Vec) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Rows", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids = vec![]; + for i in 0..30 { + let id = s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": 1.0, "value": 50})).unwrap(); + ids.push(id); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap(); + h.run_steps(4); + (h, ids) +} + +/// The originally enclosed key stays in the box when wheel scrolling takes its row offscreen. +#[test] +fn scrolling_rows_during_a_marquee_keeps_offscreen_keys_in_the_box() { + let (mut h, ids) = rows_harness(); + let top = *ids.last().unwrap(); + let c = key_center(&h, top, 1.0); + let from = c - vec2(20.0, 5.0); + let to = c + vec2(20.0, 30.0); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerMoved(to)); + h.run_steps(2); + h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -400.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + h.run_steps(20); + assert!(h.state().ui.timeline.scroll_y > 300.0, "the real wheel handler scrolled rows"); + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let selected: Vec<_> = h.state().session.state.selected_keys.iter().map(|k| k.layer.0).collect(); + assert!(selected.contains(&top), "the key on layer {top} scrolled offscreen but remains inside the content-anchored marquee; actual layers {selected:?}"); +} + +fn press(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerMoved(to)); + h.run_steps(2); +} + +fn release(h: &mut Harness<'_, EffectcraftApp>, to: Pos2) { + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn wheel(h: &mut Harness<'_, EffectcraftApp>, delta: Vec2) { + h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + h.run_steps(20); +} + +fn times(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.state.selected_keys.iter().map(|k| k.time.seconds()).collect() +} + +/// Panning time retains hidden keys while both painting and row selection respect the graph boundary. +#[test] +fn horizontal_scroll_keeps_hidden_keys_without_selecting_outline_layers() { + let (mut h, id) = harness_for(20.0); + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(3); + let c = key_center(&h, id, 1.0); + let to = c + vec2(20.0, 5.0); + press(&mut h, c - vec2(20.0, 5.0), to); + wheel(&mut h, vec2(-400.0, 0.0)); + assert!(h.state().ui.timeline.start > 1.5, "time panned: {}", h.state().ui.timeline.start); + let graph_left = h.state().auto.find("timeline.ruler").unwrap().rect[0]; + let box_shapes: Vec<_> = + h.output().shapes.iter().filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect.max == to && (r.rect.height() - 10.0).abs() < 0.01)).collect(); + assert!(!box_shapes.is_empty(), "the held marquee is drawn"); + assert!(box_shapes.iter().all(|s| s.clip_rect.min.x >= graph_left), "a graph-only box cannot paint over the outline when its anchor scrolls out"); + release(&mut h, to); + assert_eq!(times(&h), [1.0, 2.0]); + // Keys select their owning layer, but panning the anchor under the outline must not select + // unrelated layer rows. The separate cross-column test below covers those rows. + assert_eq!(h.state().session.state.selected_layers, [LayerId(id)]); +} + +/// Returning the viewport shrinks the box again without editing the document or its history. +#[test] +fn shrinking_after_scrolling_removes_keys_again_and_selection_does_not_edit_the_project() { + let (mut h, id) = harness_for(20.0); + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(3); + let before = h.state().session.project.to_json(); + let revision = h.state().session.revision; + let undo = h.state().session.history.undo.len(); + let c = key_center(&h, id, 1.0); + let to = c + vec2(20.0, 5.0); + press(&mut h, c - vec2(20.0, 5.0), to); + wheel(&mut h, vec2(-400.0, 0.0)); + wheel(&mut h, vec2(400.0, 0.0)); + assert!(h.state().ui.timeline.start < 0.01); + release(&mut h, to); + assert_eq!(times(&h), [1.0], "shrinking is not a union of every key passed"); + assert_eq!(h.state().session.project.to_json(), before); + assert_eq!(h.state().session.revision, revision); + assert_eq!(h.state().session.history.undo.len(), undo); +} + +/// Invalidated gestures leave the prior selection intact even if the pointer keeps moving. +#[test] +fn cancellation_preserves_selection_and_does_not_restart_until_a_new_press() { + for cancel in ["escape", "ownership", "focus", "layout", "resize", "edit", "comp"] { + let (mut h, id) = harness(); + h.state_mut().session.execute("keys.select", json!({"keys": [{"layer":id,"path":"transform/opacity","time":2.0}]})).unwrap(); + h.run_steps(2); + let c = key_center(&h, id, 1.0); + let to = c + vec2(10.0, 5.0); + press(&mut h, c - vec2(20.0, 5.0), to); + assert_eq!(times(&h), [2.0], "selection commits only on release"); + match cancel { + "escape" => h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }), + "ownership" => h.ctx.set_dragged_id(egui::Id::unique("another-owner")), + "focus" => h.input_mut().focused = false, + "layout" => h.state_mut().ui.timeline.search = "no matching rows".into(), + "resize" => { + h.set_size(vec2(1400.0, 900.0)); + } + "edit" => { + h.state_mut().session.execute("prop.set", json!({"layer":id,"path":"transform/opacity","value":75})).unwrap(); + } + "comp" => { + h.state_mut().session.execute("comp.new", json!({"name":"Other"})).unwrap(); + } + _ => unreachable!(), + } + h.run_steps(2); + let expected = times(&h); + h.event(Event::PointerMoved(to + vec2(1.0, 0.0))); + h.step(); + release(&mut h, to); + assert_eq!(times(&h), expected, "{cancel} cancelled without applying the box"); + if !matches!(cancel, "comp" | "edit") { + assert_eq!(times(&h), [2.0], "{cancel} preserves the prior selection"); + } + } +} + +/// A release-frame pointer move takes effect, modifiers add, and locked keys remain excluded. +#[test] +fn release_uses_the_release_position_and_additive_modifiers_and_excludes_locked_keys() { + for modifiers in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND] { + let (mut h, id) = harness(); + h.state_mut().session.execute("keys.select", json!({"keys": [{"layer":id,"path":"transform/opacity","time":2.0}]})).unwrap(); + h.run_steps(2); + let c = key_center(&h, id, 1.0); + press(&mut h, c - vec2(25.0, 5.0), c - vec2(15.0, 5.0)); + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(c + vec2(10.0, 5.0))); + h.event(Event::PointerButton { pos: c + vec2(10.0, 5.0), button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(2); + let mut actual = times(&h); + actual.sort_by(f64::total_cmp); + assert_eq!(actual, if modifiers == Modifiers::NONE { vec![1.0] } else { vec![1.0, 2.0] }); + h.state_mut().session.execute("layer.setSwitch", json!({"layers":[id],"switch":"lock","value":true})).unwrap(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert!(marquee(&mut h, c - vec2(20.0, 5.0), c + vec2(10.0, 5.0)).is_empty(), "locked keys stay excluded"); + } +} + +/// Cross-column selection uses logical headers, including those no longer rendered after scrolling. +#[test] +fn crossing_into_the_outline_after_scrolling_selects_offscreen_layer_rows() { + let (mut h, ids) = rows_harness(); + let c = key_center(&h, *ids.last().unwrap(), 1.0); + let outline = h.state().auto.find("timeline.layerMarquee").unwrap().rect; + let to = pos2(outline[0] + 40.0, c.y + 30.0); + press(&mut h, c + vec2(20.0, -5.0), to); + wheel(&mut h, vec2(0.0, -400.0)); + assert!(h.state().ui.timeline.scroll_y > 300.0); + release(&mut h, to); + let selected = &h.state().session.state.selected_layers; + assert!(selected.contains(&LayerId(ids[ids.len() - 2])), "the second header is inside the box although it scrolled offscreen"); + assert!(selected.len() > 5, "all intervening rows are selected"); +} + +/// Edge scrolling continues without pointer motion and stops as soon as the gesture is cancelled. +#[test] +fn a_stationary_pointer_at_either_edge_scrolls_only_until_release_or_cancel() { + let (mut h, ids) = rows_harness(); + let c = key_center(&h, *ids.last().unwrap(), 1.0); + let area = h.state().auto.find("timeline.layerMarquee").unwrap().rect; + let bottom = pos2(c.x + 20.0, area[1] + area[3] - 2.0); + press(&mut h, c - vec2(20.0, 5.0), bottom); + let before = h.state().ui.timeline.scroll_y; + h.run_steps(30); + assert!(h.state().ui.timeline.scroll_y > before + 50.0, "a stationary drag requests more frames and scrolls"); + let top = pos2(bottom.x, area[1] + 2.0); + h.event(Event::PointerMoved(top)); + h.step(); + let before = h.state().ui.timeline.scroll_y; + h.run_steps(10); + assert!(h.state().ui.timeline.scroll_y < before, "the top edge scrolls back"); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + let before = h.state().ui.timeline.scroll_y; + h.run_steps(10); + assert_eq!(h.state().ui.timeline.scroll_y, before, "Escape stops edge scrolling"); + release(&mut h, top); + assert!(times(&h).is_empty(), "cancelled box selects nothing"); +} + +/// Batched wheel events cannot move the press anchor or make release query stale row positions. +#[test] +fn wheel_on_press_captures_the_old_anchor_and_wheel_on_release_uses_the_new_rows() { + for scroll_on_press in [true, false] { + let (mut h, ids) = rows_harness(); + let top = *ids.last().unwrap(); + let c = key_center(&h, top, 1.0); + let from = c - vec2(20.0, 5.0); + let to = c + vec2(20.0, 30.0); + if scroll_on_press { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + } else { + press(&mut h, from, to); + } + h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -400.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + if !scroll_on_press { + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + } + h.step(); + if scroll_on_press { + h.event(Event::PointerMoved(to)); + h.run_steps(20); + release(&mut h, to); + } + assert!(h.state().session.state.selected_keys.iter().any(|k| k.layer == LayerId(top)), "scroll_on_press={scroll_on_press}"); + } +}