mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 10:20:47 -06:00
#2859: keep timeline marquee anchored while scrolling
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user