mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
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:
1 parent
7051fc1f58
commit
43a47a5d6a
3 files changed
+158
-57
No files matched your search
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user