Timeline: one selection box spans the layer list and the time graph (#3075)

* Timeline: one selection box spans the layer list and the time graph (#3056)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Timeline box test: say what it tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Timeline box: a box kept in the time graph selects keys only

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Format the timeline box test and box_targets signature

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Timeline box test: drive the real drag across the outline and the time graph

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin KleinandClaude Sonnet 5.5 authored and GitHub committed 2026-11-06 17:36:54 +01:00
1 parent 7051fc1f58
commit 43a47a5d6a
3 files changed
+158 -57

No files matched your search

+66 -57
View File
@@ -1650,7 +1650,6 @@ struct Rw {
left_clip: Rect,
right_clip: Rect,
empty_rect: Rect,
outline_rect: Rect,
empty: egui::Response,
outline_empty: egui::Response,
selected: Vec<LayerId>,
@@ -1858,7 +1857,6 @@ fn begin_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, tl: Tl) -> Rw {
left_clip,
right_clip,
empty_rect,
outline_rect,
empty,
outline_empty,
selected: app.session.state.selected_layers.clone(),
@@ -1932,7 +1930,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);
key_box_select(app, ui, g);
box_select(app, ui, g);
current_time_indicator(app, g);
let footer = footer_bar(app, ui, g);
footer_zoom(app, ui, g, footer);
@@ -4373,39 +4371,13 @@ fn background_menus(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
}
}
/// Click and marquee-select on the empty outline.
/// Click on the empty outline (its drag is the Timeline's one box, [`box_select`]).
fn outline_marquee(ui: &mut egui::Ui, g: &mut Rw) {
let t = g.tl.t;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let actions = &mut g.tl.actions;
let outline_rect = g.outline_rect;
let lp = g.lp.clone();
let outline_empty = g.outline_empty.clone();
let hit_rows = &mut g.hit_rows;
if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
if let (Some(start), Some(end)) = (ctx.input(|i| i.pointer.press_origin()), outline_empty.interact_pointer_pos())
&& outline_empty.dragged()
{
let box_rect = Rect::from_two_pos(start, end).intersect(outline_rect);
lp.rect_filled(box_rect, 0.0, t.accent.gamma_multiply(0.12));
lp.rect_stroke(box_rect, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-layer-box"), box_rect));
}
if outline_empty.drag_stopped()
&& let Some(box_rect) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("tl-layer-box")))
{
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::unique("tl-layer-box")));
let layers: Vec<u64> = hit_rows
.iter()
.filter(|(r, row)| matches!(row.kind, RowKind::Layer) && r.intersects(box_rect))
.filter(|(_, row)| comp.layer(row.layer).is_some_and(|l| !l.switches.locked))
.map(|(_, row)| row.layer.0)
.collect();
actions.push(("layer.select".into(), json!({"layers": layers, "add": ui.input(|i| i.modifiers.shift || i.modifiers.command)})));
}
// Pick whips: a line follows the pointer; releasing over a row links to it (an expression's
}
@@ -4517,24 +4489,13 @@ 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 / marquee on empty track area.
/// Click on empty track area (its drag is the Timeline's one box, [`box_select`]).
fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) {
let t = g.tl.t;
let ctx = g.tl.ctx.clone();
let actions = &mut g.tl.actions;
let gp = g.gp.clone();
let empty = g.empty.clone();
if empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
if let (true, Some(origin), Some(cur)) =
(empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos())
{
let br = Rect::from_two_pos(origin, cur);
gp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
gp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-box"), br));
}
}
/// Layer reorder drag: a line where the layers land.
@@ -4747,28 +4708,76 @@ fn keys_in_box(comp: &Comp, rows: &[Row], tm: &TMap, top: f32, rh: f32, br: Rect
keys
}
/// Releasing the key marquee selects the keys it touches.
fn key_box_select(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
/// 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;
let actions = &mut g.tl.actions;
let empty = g.empty.clone();
if empty.drag_stopped()
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("tl-box")))
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)
{
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::unique("tl-box")));
let touched = keys_in_box(&comp, rows, &tm, rows_rect.min.y - app.ui.timeline.scroll_y, rh, br);
let keys: Vec<serde_json::Value> = touched.iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect();
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);
if keys.is_empty() && !(mods.shift || mods.command) {
// A marquee that catches nothing leaves nothing selected (layers included).
actions.push(("layer.select".into(), json!({"layers": []})));
} else {
actions.push(("keys.select".into(), json!({"keys": keys, "add": mods.shift, "selectProperties": true})));
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})));
}
}
}
@@ -905,3 +905,47 @@ fn mask_reveal_collapsing_a_mask_hides_its_children_under_mixed_filters() {
app.ui.timeline.open_groups.insert(mask);
assert_eq!(labels(&app).iter().filter(|r| r.trim() == "Mask Feather").count(), 1);
}
/// One box spans the outline and the time graph (#3056): rows it crosses are selected on both
/// sides of the boundary, and only keys in the time graph count (the part over the outline is cut).
#[test]
fn a_box_over_both_columns_selects_the_rows_it_crosses_and_the_keys_inside() {
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 4, "frameRate": 30})).unwrap();
let mut layers = vec![];
for name in ["A", "B", "C"] {
let id = s.execute("layer.newSolid", json!({"name": name, "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": 0.0, "value": 50})).unwrap();
layers.push(id);
}
s.execute("layer.setSwitch", json!({"layers": [layers[2]], "switch": "lock", "value": true})).unwrap();
let comp = s.active_comp().unwrap();
let uid = |id: u64| comp.layer(LayerId(id)).unwrap().props.prop("transform/opacity").unwrap().uid;
let mut rows = vec![];
for id in &layers {
rows.push(Row { layer: LayerId(*id), depth: 0, kind: RowKind::Layer });
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 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)));
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)));
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)));
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)));
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)));
assert_eq!(ls, [layers[0], layers[1]]);
}
@@ -74,3 +74,51 @@ fn a_box_selects_a_key_it_grazes_from_any_side_and_not_one_it_stops_short_of() {
assert!(h.state().session.state.selected_layers.is_empty(), "a box that catches nothing deselects (layers included)");
}
}
/// #3056: one box spans the layer outline and the time graph. Dragged from the empty outline
/// into the graph, or from the graph back over the outline, it selects the layers whose rows it
/// crosses and the keys inside it, through the real drag, release and selection commands.
#[test]
fn a_box_dragged_across_the_outline_and_the_graph_selects_rows_and_keys() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Box", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let mut ids = vec![];
for name in ["A", "B"] {
let id = s.execute("layer.newSolid", json!({"name": name, "color": "#e04020", "width": 80, "height": 80})).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.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
h.run_steps(2);
// B was added last, so its rows are on top; below A's rows the outline is empty.
let (top, bottom) = (key_center(&h, ids[1], 1.0), key_center(&h, ids[0], 1.0));
let outline = h.state().auto.find("timeline.layerMarquee").expect("the outline's empty area").rect;
let below = pos2(outline[0] + 60.0, bottom.y + 40.0);
assert!(below.x < top.x && below.y < outline[1] + outline[3], "the start is in the outline, below the rows");
let selected = |h: &Harness<'_, EffectcraftApp>| {
let st = &h.state().session.state;
let mut layers: Vec<u64> = st.selected_layers.iter().map(|l| l.0).collect();
let mut keyed: Vec<u64> = st.selected_keys.iter().map(|k| k.layer.0).collect();
layers.sort_unstable();
keyed.sort_unstable();
(layers, keyed)
};
let mut both = ids.clone();
both.sort_unstable();
// From the outline up and right into the graph, over both keys.
let keys = marquee(&mut h, below, top + vec2(15.0, -3.0));
assert_eq!(keys, [1.0, 1.0]);
assert_eq!(selected(&h), (both.clone(), both.clone()), "outline → graph");
h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
h.run_steps(2);
// And back: from beside the top key in the graph down and left over the outline.
let keys = marquee(&mut h, top + vec2(30.0, 0.0), below);
assert_eq!(keys, [1.0, 1.0]);
assert_eq!(selected(&h), (both.clone(), both), "graph → outline");
}