#2859: keep timeline marquee anchored while scrolling

This commit is contained in:
arnav-hiwarkar
2026-11-10 09:23:04 +05:30
parent 93b69a8cf9
commit 51ef332c05
4 changed files with 442 additions and 86 deletions
+11 -81
View File
@@ -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<u64>, Vec<effectcraft_engine::KeyRef>) {
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::<Rect>(id))
{
ctx.data_mut(|d| d.remove::<Rect>(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<serde_json::Value> = 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;
@@ -0,0 +1,168 @@
//! 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<Comp>,
rows: Arc<Vec<Row>>,
graph_on: bool,
layout: (Rect, f32, f32),
time: f64,
y: f32,
outline_x: Option<f32>,
dragging: bool,
}
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::<Marquee>(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::<Marquee>(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),
time: g.tl.tm.t(p.x.max(g.tl.graph_x0)),
y: p.y - g.tl.rows_rect.min.y + app.ui.timeline.scroll_y,
outline_x: (p.x < g.tl.graph_x0).then_some(p.x),
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::<Marquee>(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 Marquee {
fn selection(&self, g: &Rw, scroll: f32, pointer: Pos2) -> (Rect, SelectionBox) {
let tm = g.tl.tm;
let anchor = pos2(self.outline_x.unwrap_or_else(|| tm.x(self.time)), g.tl.rows_rect.min.y + self.y - scroll);
let paint = Rect::from_two_pos(anchor, pointer);
let graph = self.outline_x.is_none() || pointer.x >= g.tl.graph_x0;
let keys = if graph && !g.graph_on {
Rect::from_two_pos(pos2(tm.x(self.time), anchor.y), pos2(pointer.x.max(g.tl.graph_x0), pointer.y))
} else {
Rect::NOTHING
};
(paint, SelectionBox { keys, outline: self.outline_x.is_some() || pointer.x < g.tl.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<u64>, Vec<effectcraft_engine::KeyRef>) {
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))
}
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::<Marquee>(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.selection(g, app.ui.timeline.scroll_y, pointer);
if dropped {
ctx.data_mut(|d| d.remove::<Marquee>(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);
}
}
+4 -3
View File
@@ -959,10 +959,11 @@ 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 go = |br: Rect| {
let keys = if br.max.x < 200.0 { Rect::NOTHING } else { Rect::from_min_max(pos2(br.min.x.max(200.0), br.min.y), br.max) };
marquee::targets(comp, &rows, &tm, 0.0, 20.0, br, &marquee::SelectionBox { keys, outline: br.min.x < 200.0 })
};
let keyed = |ks: &[effectcraft_engine::KeyRef]| ks.iter().map(|k| k.layer.0).collect::<Vec<_>>();
// 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)));
+259 -2
View File
@@ -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,256 @@ 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");
}
fn rows_harness() -> (Harness<'static, EffectcraftApp>, Vec<u64>) {
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)
}
#[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<f64> {
h.state().session.state.selected_keys.iter().map(|k| k.time.seconds()).collect()
}
#[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)]);
}
#[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);
}
#[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");
}
}
}
#[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");
}
}
#[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");
}
#[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");
}
#[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}");
}
}