From e49c5b02c7c11955d0a58e0f4a9beb0e2c54df66 Mon Sep 17 00:00:00 2001 From: Eugene Date: Thu, 30 Apr 2026 00:27:53 +0300 Subject: [PATCH] gesture: refresh PointerID on Press and Enter Click and Hover both stored the first PointerID they observed in their internal pid field and only updated it when not currently hovered/entered. Once the gesture became hovered, any later event under a different PointerID was effectively ignored: Click.Press fell through 'c.pid != e.PointerID' and was silently dropped, and Hover could never reset entered when the matching Leave arrived under a new ID. The Windows backend enables EnableMouseInPointer (app/os_windows.go), under which Windows reassigns the same physical mouse's PointerID across focus changes, window leave/re-enter, and similar events. Once a widget had been hovered, every subsequent press on it failed to register, including widget.Editor's internal clicker that positions the caret on press. Multi-line editors silently refused to move the caret on click after the window had received any focus event. Always take the latest PointerID on Hover.Enter and Click.Press. The Press/Release handshake still works because Press now records the press's own PointerID and Release continues to gate on 'c.pid != e.PointerID' so an unrelated pointer's release can't end the press tracking. Signed-off-by: Eugene --- gesture/gesture.go | 15 ++------- gesture/gesture_test.go | 72 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/gesture/gesture.go b/gesture/gesture.go index 356d7fad..cf761f1d 100644 --- a/gesture/gesture.go +++ b/gesture/gesture.go @@ -61,12 +61,8 @@ func (h *Hover) Update(q input.Source) bool { h.entered = false } case pointer.Enter: - if !h.entered { - h.pid = e.PointerID - } - if h.pid == e.PointerID { - h.entered = true - } + h.pid = e.PointerID + h.entered = true } } return h.entered @@ -222,12 +218,7 @@ func (c *Click) Update(q input.Source) (ClickEvent, bool) { if e.Source == pointer.Mouse && e.Buttons != pointer.ButtonPrimary { break } - if !c.hovered { - c.pid = e.PointerID - } - if c.pid != e.PointerID { - break - } + c.pid = e.PointerID c.pressed = true if e.Time-c.clickedAt < doubleClickDuration { c.clicks++ diff --git a/gesture/gesture_test.go b/gesture/gesture_test.go index af30ffda..ce6403fe 100644 --- a/gesture/gesture_test.go +++ b/gesture/gesture_test.go @@ -100,6 +100,78 @@ func TestMouseClicks(t *testing.T) { } } +func TestClickPointerIDReassignment(t *testing.T) { + // A Click must accept a Press from a PointerID that differs from the + // one its hovered state was previously associated with. Some backends + // reassign a single physical pointer's ID over its lifetime — e.g. the + // Windows pointer API across focus changes — and locking the gesture + // to the first observed ID would silently drop every subsequent press. + // + // The sequence below puts the gesture into the buggy state through + // public events alone: a press under PointerID 1 starts an active + // press cycle, a Move under PointerID 2 arrives mid-press (which the + // router routes as an Enter for PID 2 but the gesture's Enter handler + // is a no-op for pid while pressed), then PID 1 releases. After this, + // the router has the gesture entered for PID 2 (so the next event + // under PID 2 won't trigger another Enter) but the gesture itself + // still has pid=1. + var click Click + var ops op.Ops + rect := image.Rect(0, 0, 100, 100) + stack := clip.Rect(rect).Push(&ops) + click.Add(&ops) + stack.Pop() + + var r input.Router + click.Update(r.Source()) + r.Frame(&ops) + + drain := func() { + for { + if _, ok := click.Update(r.Source()); !ok { + return + } + } + } + + // Press under PointerID 1. + r.Queue( + pointer.Event{Kind: pointer.Move, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 1}, + pointer.Event{Kind: pointer.Press, Source: pointer.Mouse, Buttons: pointer.ButtonPrimary, Position: f32.Pt(50, 50), PointerID: 1}, + ) + drain() + + // Move under PointerID 2 while PointerID 1 is still pressed. The + // router records the gesture as entered for PointerID 2 but the + // gesture's Enter handler is a no-op for pid because c.pressed. + r.Queue(pointer.Event{Kind: pointer.Move, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 2}) + drain() + + // Release PointerID 1. PointerID 1's press tracking ends; the + // gesture's recorded pid stays at 1. + r.Queue(pointer.Event{Kind: pointer.Release, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 1}) + drain() + + // Press under PointerID 2. The router won't refire Enter for PID 2 + // (the gesture is already in PID 2's entered set), so the gesture's + // only chance to refresh its pid is the Press handler itself. + r.Queue(pointer.Event{Kind: pointer.Press, Source: pointer.Mouse, Buttons: pointer.ButtonPrimary, Position: f32.Pt(50, 50), PointerID: 2}) + + var sawPress bool + for { + ev, ok := click.Update(r.Source()) + if !ok { + break + } + if ev.Kind == KindPress { + sawPress = true + } + } + if !sawPress { + t.Fatal("expected KindPress for press under reassigned PointerID; gesture dropped the press because of stale recorded pid") + } +} + func mouseClickEvents(times ...time.Duration) []event.Event { press := pointer.Event{ Kind: pointer.Press,