From 1be48a8360379b01ef5ded05c5ff1a52c92fd5c9 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 22:38:29 -0400 Subject: [PATCH 1/3] fix(web): one press per tap, so menus open again on touch (#2095) In the browser eframe 0.36 turns one finger or pen tap into two primary presses and two releases: one pair from Pointer Events (pointerdown runs a frame of its own) and one from Touch Events (touchstart/touchend). The menu bar titles and press menus toggle on the press, so the twin press closed the menu the first had just opened. The web shell's raw_input_hook now drops a primary press that arrives while the button is already held (pressed within 0.5 s) and a release while it is already up. A press held longer still passes, so a lost release can't lock the pointer down. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 6 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/touch_twins.rs | 107 ++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 crates/ui-egui/src/touch_twins.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index eb350d17eb..b855befb88 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -283,6 +283,12 @@ impl WebShell { } impl eframe::App for WebShell { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + // A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the + // twin press closes the menu the first one opened (#2095). + photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input); + } + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); for f in dropped { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04c37282e6..f66ee2e24e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -214,6 +214,7 @@ mod timeline_ui; mod titlebar; pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, RenderIconFn}; pub mod tone; +pub mod touch_twins; mod tool_cursor; pub mod tool_feedback; pub(crate) mod tool_memory; diff --git a/crates/ui-egui/src/touch_twins.rs b/crates/ui-egui/src/touch_twins.rs new file mode 100644 index 0000000000..a000d0b05b --- /dev/null +++ b/crates/ui-egui/src/touch_twins.rs @@ -0,0 +1,107 @@ +//! One tap, one press (#2095). In the browser eframe turns a finger or pen tap into two presses +//! and two releases: one pair from Pointer Events (`pointerdown` / `pointerup`) and one from Touch +//! Events (`touchstart` / `touchend`), which mobile browsers fire for the same contact. A menu +//! title or press menu toggles on the press, so the second press closed the menu the first had +//! just opened. [`drop_twins`] keeps the first of each and drops its twin. + +use egui::{Event, PointerButton, RawInput}; + +/// Longest gap, in seconds, between a press and its twin from the other event family (both come +/// from one contact, normally within a frame or two). +const TWIN_WINDOW: f64 = 0.5; + +/// Drops a primary press that arrives while the primary button is already held (pressed within +/// [`TWIN_WINDOW`]) and a primary release that arrives while it is already up. A press while held +/// for longer still passes, so a release the browser never sent can't lock the pointer down. +/// Call from `raw_input_hook`, before the frame runs. +pub fn drop_twins(ctx: &egui::Context, raw: &mut RawInput) { + let (mut down, mut pressed_at, last) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.press_start_time(), i.time)); + let now = raw.time.unwrap_or(last + f64::from(raw.predicted_dt)); + raw.events.retain(|e| { + let Event::PointerButton { button: PointerButton::Primary, pressed, .. } = e else { return true }; + if *pressed != down { + down = *pressed; + pressed_at = pressed.then_some(now); + return true; + } + // A press while held is a twin only while fresh; a release while up always is. + *pressed && !pressed_at.is_some_and(|t| now - t <= TWIN_WINDOW) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Modifiers, Pos2, pos2}; + + fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } + } + + fn touch(pos: Pos2, phase: egui::TouchPhase) -> Event { + Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(7), phase, pos, force: None } + } + + /// One browser tap as eframe delivers it: `pointerdown` runs a frame of its own, then + /// `touchstart`, `pointerup` (again a frame of its own) and `touchend`. + fn tap_frames(pos: Pos2) -> [Vec; 4] { + [ + vec![button(pos, true)], + vec![button(pos, true), touch(pos, egui::TouchPhase::Start)], + vec![button(pos, false)], + vec![button(pos, false), Event::PointerGone, touch(pos, egui::TouchPhase::End)], + ] + } + + #[test] + fn a_tap_opens_a_menu_title_every_time() { + use egui_kittest::{Harness, kittest::Queryable}; + + let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app| { + crate::menus::menu_bar(app, ui); + }, + app, + ); + crate::PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + harness.run_steps(3); + let file = harness.get_by_label("File").rect().center(); + let ctx = harness.ctx.clone(); + let tap = |harness: &mut Harness<'_, crate::PhotocraftApp>, dedupe: bool| { + for events in tap_frames(file) { + harness.input_mut().events = events; + if dedupe { + drop_twins(&ctx, harness.input_mut()); + } + harness.step(); + } + harness.run_steps(2); + harness.query_by_label_contains("Open…").is_some() + }; + // Without the hook the second press closes the menu the first opened. + assert!(!tap(&mut harness, false), "the cause: the twin press closes the menu"); + assert!(tap(&mut harness, true), "the first tap opens File"); + assert!(!tap(&mut harness, true), "a second tap closes it"); + assert!(tap(&mut harness, true), "a third tap opens it again"); + } + + #[test] + fn a_press_held_for_long_still_passes() { + let ctx = egui::Context::default(); + let p = pos2(10.0, 10.0); + let frame = |events: Vec, time: f64| { + let mut raw = RawInput { time: Some(time), events, ..Default::default() }; + drop_twins(&ctx, &mut raw); + let kept = raw.events.clone(); + ctx.run_ui(raw, |_| {}).textures_delta.clear(); + kept + }; + assert_eq!(frame(vec![button(p, true)], 1.0).len(), 1); + assert!(frame(vec![button(p, true)], 1.1).is_empty(), "the twin press"); + // The release never came: a later press is a new one, not a twin. + assert_eq!(frame(vec![button(p, true)], 3.0).len(), 1); + assert_eq!(frame(vec![button(p, false), button(p, false)], 3.1).len(), 1, "one release per press"); + assert!(frame(vec![button(p, false)], 3.2).is_empty(), "a release while up"); + } +} From fcea66298994baf485816a29a2e23f6f591fa348 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 11:07:30 -0400 Subject: [PATCH 2/3] style: sort touch_twins module per rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f66ee2e24e..ac46cf3f4d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -214,12 +214,12 @@ mod timeline_ui; mod titlebar; pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, RenderIconFn}; pub mod tone; -pub mod touch_twins; mod tool_cursor; pub mod tool_feedback; pub(crate) mod tool_memory; mod tool_reset; mod tool_shortcuts; +pub mod touch_twins; pub mod transform_fx; pub mod transform_tex; pub mod transform_tool; From 8138fc12e707e2e03c6fc0f185efe75b8cbec720 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 15:04:18 -0400 Subject: [PATCH 3/3] test(web): a press, release and press in one frame keep all three events Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/touch_twins.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/ui-egui/src/touch_twins.rs b/crates/ui-egui/src/touch_twins.rs index a000d0b05b..aee4aa33d3 100644 --- a/crates/ui-egui/src/touch_twins.rs +++ b/crates/ui-egui/src/touch_twins.rs @@ -86,6 +86,17 @@ mod tests { assert!(tap(&mut harness, true), "a third tap opens it again"); } + /// A fast double tap delivered in one frame: each event changes the button's state, so none is + /// a twin. + #[test] + fn a_press_release_press_in_one_frame_keeps_all_three() { + let ctx = egui::Context::default(); + let p = pos2(10.0, 10.0); + let mut raw = RawInput { time: Some(1.0), events: vec![button(p, true), button(p, false), button(p, true)], ..Default::default() }; + drop_twins(&ctx, &mut raw); + assert_eq!(raw.events.len(), 3); + } + #[test] fn a_press_held_for_long_still_passes() { let ctx = egui::Context::default();