From c19265dee05e2657f1ea6fa18be95f48763d7ccc Mon Sep 17 00:00:00 2001 From: mahmatias Date: Wed, 21 Oct 2026 08:00:42 -0300 Subject: [PATCH] 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. --- crates/ui-egui/src/panels/detail.rs | 9 ++++-- crates/ui-egui/src/tests_guided_guides.rs | 34 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 63f17dec2..9c0215bd8 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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)) { diff --git a/crates/ui-egui/src/tests_guided_guides.rs b/crates/ui-egui/src/tests_guided_guides.rs index 7cdb8bebd..84e9def7e 100644 --- a/crates/ui-egui/src/tests_guided_guides.rs +++ b/crates/ui-egui/src/tests_guided_guides.rs @@ -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"); +}