fix(guided): the press point, not the current pointer, owns the gesture

`contains_pointer` reads where the pointer is NOW; a slow frame can
deliver the press on a menu and the move off it in the same pass, and
the gate would then pass with the saved press origin under the menu.
`layer_id_at(press_origin) == resp.layer_id` asks which layer owns the
press itself: the menu's area (or any modal) wins there and the guide
under it stays untouched. The navigator check stays for its own widget.
This commit is contained in:
mahmatias
2026-10-21 14:13:18 -03:00
parent f2e97d39fe
commit c19265dee0
2 changed files with 40 additions and 3 deletions
+6 -3
View File
@@ -1878,13 +1878,16 @@ fn guided_overlay(
// selected when it lands), on a line it selects the guide (a press on the selected one
// deselects), anywhere else it starts a new guide from the press point. The primary button
// only, never on the navigator — it pans the photo, it draws no guide — and only where the
// loupe itself takes the press: `contains_pointer` is layer-aware (the raw pointer is not),
// so anything floating above the photo — a context menu, a dropdown, a dialog — owns the
// press and the guide under it stays untouched.
// loupe itself takes the press: `contains_pointer` is layer-aware over the current pointer
// (the raw pointer is not), and `layer_id_at` asks the same question at the press point —
// a menu can receive the press and the pointer can leave it within the same frame, so the
// press itself must belong to the loupe's layer. Anything floating above the photo — a
// context menu, a dropdown, a dialog — owns its own press; the guide under it stays put.
let nav = app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r);
if ui.input(|i| i.pointer.primary_pressed())
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
&& resp.contains_pointer()
&& ui.ctx().layer_id_at(q) == Some(resp.layer_id)
&& resp.rect.contains(q)
&& !nav.is_some_and(|n| n.expand(5.0).contains(q))
{
+34
View File
@@ -306,3 +306,37 @@ fn a_press_on_a_menu_over_an_endpoint_moves_nothing() {
"the endpoint under the menu did not move: {a:?}–{b:?}"
);
}
/// The press can leave the menu within the same frame it started (one batch: down on the menu,
/// pointer onto bare photo, release): containment of the *current* pointer would pass then, but
/// the press belongs to the menu — the press point decides, so no guide is drawn from it.
#[test]
fn a_press_that_leaves_the_menu_in_one_frame_draws_nothing() {
let mut h = guided();
let undo = h.app.session.undo.len();
let end = guide_rect(&h, 0).left_top();
// right-click up-left of the endpoint: the menu opens with its top-left at the pointer
let r = h.request("ui.click", json!({"x": end.x - 30.0, "y": end.y - 30.0, "button": "right"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.view.ctx.any_popup_open(), "the context menu is open over the photo");
// one batch: go down 1px inside the menu, then straight off it to bare photo, far below
// both guides, and release there — the current pointer never sees the menu again
let img = h.app.image_rect.expect("the photo on screen");
let menu = end - egui::vec2(29.0, 29.0);
let bare = egui::pos2(img.center().x, img.top() + 0.95 * img.height());
let rel = |p: egui::Pos2| (((p.x - img.left()) / img.width()) as f64, ((p.y - img.top()) / img.height()) as f64);
let (mx, my) = rel(menu);
let (bx, by) = rel(bare);
let r = h.request(
"ui.pointer",
json!({"events": [{"kind": "down", "x": mx, "y": my}, {"kind": "drag", "x": bx, "y": by}, {"kind": "up", "x": bx, "y": by}]}),
T,
);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(guides_len(&h), 2, "the press that left the menu drew no guide");
assert_eq!(h.app.ui.selected_guide, None, "and selected nothing");
assert!(h.app.gesture.is_none(), "and leaves no gesture behind");
assert_eq!(h.app.session.undo.len(), undo, "no edit was written");
}