Timeline: a click on empty track area deselects layers (as in After Effects)

(cherry picked from commit 9d6352faea454ebcd1a575056c4d5f0bafcc80ea)

(cherry picked from commit d6110a23e9c459eb4c677b439d811b7438b1a999)
(cherry picked from commit f46595d119d253c9298eb045ad521420a155b422)

(cherry picked from commit fff43e6f6edc00bf60aa031643f3356b9e7ccfad)
This commit is contained in:
Elias
2026-10-12 19:22:16 +01:00
committed by Elias
parent 223b21b06a
commit def8b6f3b4
2 changed files with 61 additions and 10 deletions
+17 -10
View File
@@ -2086,8 +2086,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
continue;
}
let gresp = ui
.interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::drag())
.interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::click_and_drag())
.on_hover_text(crate::i18n::tr("Drag to slip the source"));
// Outside the bar it is empty track area: a plain click deselects.
if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip");
if gresp.hovered() || gresp.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
@@ -2825,14 +2829,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if graph_on {
super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
}
// Empty-area click in the graph: deselect keys (below the last row, the layers too, as in
// After Effects); drag: box-select keys.
if empty.clicked() {
actions.push(("keys.select".into(), json!({"keys": []})));
let below = empty.interact_pointer_pos().is_some_and(|p| hit_rows.iter().all(|(r, _)| p.y > r.max.y));
if below && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
// A plain click on empty track area (beside a layer's bar, on a property row's empty span or
// below the last row) deselects the layers and their keys and properties, as in After
// Effects; Shift / Cmd keep the selection. Drag: box-select keys.
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())
@@ -2938,7 +2939,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift), "selectProperties": true})));
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})));
}
}
// CTI.
@@ -88,6 +88,50 @@ fn timeline_empty_outline_marquee_selects_layers() {
drag(&mut h, pos2(area.min.x + 210.0, last.max.y + 25.0), pos2(area.min.x + 215.0, top.min.y + 2.0), Modifiers::NONE);
assert_eq!(h.state().session.state.selected_layers, ids);
}
/// A plain click on empty track area (beside a layer's bar or below the last layer) deselects
/// the layers, as in After Effects; Shift / Cmd keep the selection, a click on a bar selects
/// its layer and a key marquee that catches nothing leaves nothing selected.
#[test]
fn timeline_click_on_empty_track_area_deselects_layers() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
let select = |h: &mut Harness<'_, EffectcraftApp>, layers: &[LayerId]| {
let layers: Vec<u64> = layers.iter().map(|l| l.0).collect();
h.state_mut().session.execute("layer.select", json!({"layers": layers})).unwrap();
h.run_steps(2);
};
h.state_mut().session.execute("layer.timing", json!({"layers": [ids[1].0], "in": 1.5, "out": 2.5})).unwrap();
select(&mut h, &ids[..2]);
let bar = rect(&h, &format!("timeline.layer.{}.bar", ids[1].0));
let before = pos2(bar.min.x - 30.0, bar.center().y);
let after = pos2(bar.max.x + 30.0, bar.center().y);
click(&mut h, before, Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "a click beside the bar deselects");
click(&mut h, bar.center(), Modifiers::NONE);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a click on the bar selects its layer");
click(&mut h, after, Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Shift-click on empty keeps the selection");
click(&mut h, after, Modifiers::COMMAND);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Cmd-click on empty keeps the selection");
click(&mut h, after, Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty());
// Below the last layer, in the track area and in the outline.
let last = rect(&h, &format!("timeline.layer.{}.bar", ids[3].0));
select(&mut h, &ids);
click(&mut h, pos2(last.center().x, last.max.y + 30.0), Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "a click below the last layer deselects");
let outline = rect(&h, "timeline.layerMarquee");
select(&mut h, &ids);
click(&mut h, pos2(outline.min.x + 210.0, last.max.y + 30.0), Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "so does one below the layers in the outline");
// A key marquee over empty track area that catches nothing.
select(&mut h, &ids[1..2]);
drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "an empty marquee leaves nothing selected");
select(&mut h, &ids[1..2]);
drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a Shift marquee keeps the selection");
}
#[test]
fn project_clear_deletes_items_and_undo_restores_them() {
let mut h = harness();