From 2c7eca7ef2f86800c11b8321fd2051ad25f1890b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:18:19 +0700 Subject: [PATCH] Viewer: a press on a selected layer behind others acts on it (#230) A press in the Composition panel always acted on the topmost layer under the pointer, so a selected layer could not be dragged where another layer covered it: the press selected the layer in front. As in After Effects, a selected layer under the pointer now comes first: it stays selected and a drag moves it. With no selected layer there the topmost layer is picked as before, and Shift still toggles the topmost one. The context menu's list of layers under the pointer shares the same hit test. Test: viewer_press_prefers_a_selected_layer_under_the_pointer (ui_viewer). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 29 +++++++++++++++++++---------- crates/ui-egui/tests/ui_viewer.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 95a4dd0b..f3ba1562 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -909,10 +909,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // becomes the target, using the same visible/unlocked hit test as left-click. pick(app, &ectx, map.to_comp(pos), false); let point = map.to_comp(pos); - let hits: Vec<(u64, String)> = selectable_layers(&comp, time) - .filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| point_in_quad(point, &q))) - .map(|l| (l.id.0, l.name.clone())) - .collect(); + let hits: Vec<(u64, String)> = layers_at(&ectx, point).map(|l| (l.id.0, l.name.clone())).collect(); ui.data_mut(|d| d.insert_temp(menu_hits_id, hits)); } let context_layers: Vec = @@ -1754,17 +1751,29 @@ pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { - selectable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))) +/// Selectable layers under a comp point, front to back. +fn layers_at<'a, 'b>(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator + 'b { + selectable_layers(ectx.comp, ectx.time).filter(move |l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))) } -/// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer. +/// Topmost selectable layer under a comp point. +pub(crate) fn layer_at<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { + layers_at(ectx, cpt).next() +} + +/// The layer a press at a comp point acts on, selected (Shift toggles the topmost layer). As in +/// After Effects, a selected layer under the point comes before the layers in front of it, so it +/// can be dragged where they cover it; otherwise the topmost layer (#230). pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { - let hit = layer_at(ectx, cpt).map(|l| l.id)?; if toggle { + let hit = layer_at(ectx, cpt)?.id; let _ = app.session.execute("layer.select", json!({"layers": [hit.0], "toggle": true})); - } else if !app.session.state.selected_layers.contains(&hit) { + return Some(hit); + } + let selected = &app.session.state.selected_layers; + let hits: Vec = layers_at(ectx, cpt).map(|l| l.id).collect(); + let hit = *hits.iter().find(|id| selected.contains(id)).or(hits.first())?; + if !selected.contains(&hit) { let _ = app.session.execute("layer.select", json!({"layers": [hit.0]})); } Some(hit) diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 52033fe3..b2014509 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -134,6 +134,32 @@ fn viewer_click_ignores_inactive_solo_layers() { assert_eq!(h.state().session.state.selected_layers, vec![back]); } +/// A selected layer under the pointer takes the press before the layers in front of it, as in +/// After Effects: it stays selected and a drag moves it. With none selected there, the topmost +/// layer does (#230). +#[test] +fn viewer_press_prefers_a_selected_layer_under_the_pointer() { + let mut h = selection_harness(); + let comp = h.state().session.active_comp().unwrap(); + let (front, back) = (comp.layers[0].id, comp.layers[1].id); + h.state_mut().session.execute("layer.select", json!({"layers": [back.0]})).unwrap(); + h.run_steps(3); + let at = screen(&h, [320.0, 180.0]); + click(&mut h, at); + assert_eq!(h.state().session.state.selected_layers, vec![back], "a click keeps the selected layer behind"); + h.run_steps(40); + let (front0, back0) = (position_and_anchor(&h, front).0, position_and_anchor(&h, back).0); + drag(&mut h, at, at + vec2(60.0, 0.0)); + assert_eq!(h.state().session.state.selected_layers, vec![back]); + assert_eq!(position_and_anchor(&h, front).0, front0, "the layer in front stays"); + assert!(position_and_anchor(&h, back).0[0] > back0[0] + 10.0, "the selected layer behind moves"); + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(40); + let at = screen(&h, [300.0, 180.0]); + click(&mut h, at); + assert_eq!(h.state().session.state.selected_layers, vec![front], "nothing selected: the topmost layer"); +} + #[test] fn bottom_bar_in_after_effects_order() { let h = harness();