From 77b020433148bf64e702a9b4529b086d966f8774 Mon Sep 17 00:00:00 2001 From: mahmatias Date: Wed, 21 Oct 2026 08:30:32 -0300 Subject: [PATCH] fix(guided): the press position survives press+release in one frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit egui clears `press_origin` on the release, so a slow frame that swallows press, move and release into one batch reports `primary_pressed` with no origin: the gesture fell back to the release position and a real drag wrote nothing (its length check saw a zero-length stub). The gate now reads the primary press back from the frame's own events before that fallback — the press decides what it means, wherever the batch ended. --- crates/ui-egui/src/panels/detail.rs | 14 ++++++++++++- crates/ui-egui/src/tests_guided_guides.rs | 25 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 9c0215bd8..f2fe8c92d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1883,9 +1883,21 @@ fn guided_overlay( // 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. + // A slow frame can also swallow the whole press-move-release into one batch, and egui + // clears `press_origin` on the release: then the press position is read back from the + // frame's own events, so the gesture still starts where the pointer went down. 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()) + && let Some(q) = ui + .input(|i| { + i.pointer.press_origin().or_else(|| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } => Some(*pos), + _ => None, + }) + }) + }) + .or(resp.interact_pointer_pos()) && resp.contains_pointer() && ui.ctx().layer_id_at(q) == Some(resp.layer_id) && resp.rect.contains(q) diff --git a/crates/ui-egui/src/tests_guided_guides.rs b/crates/ui-egui/src/tests_guided_guides.rs index 84e9def7e..d59698350 100644 --- a/crates/ui-egui/src/tests_guided_guides.rs +++ b/crates/ui-egui/src/tests_guided_guides.rs @@ -207,6 +207,31 @@ fn a_new_guide_starts_at_the_press_point() { assert!(rect.right_top().distance(p2) < 2.0, "and ends at the release point: {rect:?} vs {p2:?}"); } +/// A slow frame can swallow press, move and release into a single batch: egui then reports +/// `primary_pressed` with `press_origin` already cleared by the release, and the gesture must +/// still start at the press point — the guide spans the drag, not a zero-length stub at the +/// release. The control channel spreads pointer events one per frame, so the batch is pushed +/// raw into one frame here. +#[test] +fn a_drag_whose_press_and_release_share_one_frame_still_draws() { + let mut h = guided(); + assert_eq!(guides_len(&h), 2); + let undo = h.app.session.undo.len(); + let img = h.app.image_rect.expect("the photo on screen"); + let p1 = egui::pos2(img.left() + 0.35 * img.width(), img.top() + 0.55 * img.height()); + let p2 = egui::pos2(img.left() + 0.6 * img.width(), img.top() + 0.55 * img.height()); + h.events.push(egui::Event::PointerMoved(p1)); + h.events.push(egui::Event::PointerButton { pos: p1, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.events.push(egui::Event::PointerMoved(p2)); + h.events.push(egui::Event::PointerButton { pos: p2, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 3, "the drag that shared one frame still draws its guide"); + assert_eq!(h.app.session.undo.len(), undo + 1, "one edit was written"); + let rect = guide_rect(&h, 2); + assert!(rect.left_top().distance(p1) < 2.0, "the guide starts at the press point: {rect:?} vs {p1:?}"); + assert!(rect.right_top().distance(p2) < 2.0, "and ends at the release point: {rect:?} vs {p2:?}"); +} + /// The right button is the pan / context menu's, never the guides': on a guide it selects /// nothing and draws nothing. #[test]