From cf697934ff571087406f4a09e8b5de3441b47edc Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 13 Aug 2026 16:46:03 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(gesture):=20gesture=20recognition=20sy?= =?UTF-8?q?stem=20(ADR-049)=20=E2=80=94=20full=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arena-based gesture disambiguation (Flutter pattern, source-verified). Unified pointer pipeline: PointerEvent as single source of truth. 22 gesture/ files: Arena, Click, Drag, LongPress, TapAndDrag recognizers, VelocityTracker, Team cooperative groups, per-device thresholds, signals. All 20 interactive widgets implement GestureAware. TextField: drag selection, double-click word, triple-click select-all (#225). OS clipboard: widget.ClipboardProvider DI, platform wiring via gogpu. Event bridge: scroll bounds filtering, non-mouse pointer isolation. 57 files, 120+ gesture tests, 95.5% gesture coverage, 0 lint issues. --- app/event_bridge.go | 470 ++++++++++++++---------- app/event_bridge_modifiers_test.go | 47 ++- app/event_bridge_test.go | 272 +++++++++----- app/gesture_bridge.go | 158 ++++++++ app/gesture_bridge_test.go | 314 ++++++++++++++++ app/window.go | 147 ++++++++ app/window_gesture_test.go | 554 +++++++++++++++++++++++++++++ core/button/widget.go | 47 ++- core/checkbox/widget.go | 47 ++- core/chip/chip.go | 47 ++- core/collapsible/collapsible.go | 55 ++- core/datatable/datatable.go | 38 +- core/docking/host.go | 29 +- core/dropdown/widget.go | 34 +- core/gridview/gridview.go | 36 +- core/listview/widget.go | 37 +- core/menu/menubar.go | 29 +- core/popover/popover.go | 37 +- core/radio/item.go | 55 ++- core/scrollview/event.go | 62 ++++ core/scrollview/widget.go | 47 ++- core/slider/widget.go | 46 ++- core/splitview/splitview.go | 39 +- core/stripe/widget.go | 30 +- core/tabview/widget.go | 65 +++- core/textfield/event.go | 112 ++---- core/textfield/selection.go | 30 +- core/textfield/textfield_test.go | 138 ++++++- core/textfield/widget.go | 163 ++++++++- core/titlebar/titlebar.go | 31 +- core/toolbar/toolbar.go | 31 +- core/treeview/treeview.go | 80 ++++- desktop/desktop.go | 3 + gesture/arena.go | 304 ++++++++++++++++ gesture/arena_test.go | 329 +++++++++++++++++ gesture/aware.go | 34 ++ gesture/aware_test.go | 66 ++++ gesture/click.go | 326 +++++++++++++++++ gesture/click_test.go | 434 ++++++++++++++++++++++ gesture/constants.go | 70 ++++ gesture/doc.go | 44 +++ gesture/drag.go | 377 ++++++++++++++++++++ gesture/drag_test.go | 401 +++++++++++++++++++++ gesture/long_press.go | 289 +++++++++++++++ gesture/long_press_test.go | 287 +++++++++++++++ gesture/pointer_event.go | 194 ++++++++++ gesture/pointer_event_test.go | 107 ++++++ gesture/recognizer.go | 142 ++++++++ gesture/recognizer_test.go | 380 ++++++++++++++++++++ gesture/tap_and_drag.go | 332 +++++++++++++++++ gesture/tap_and_drag_test.go | 295 +++++++++++++++ gesture/team.go | 82 +++++ gesture/team_test.go | 206 +++++++++++ gesture/velocity.go | 143 ++++++++ gesture/velocity_test.go | 164 +++++++++ widget/clipboard.go | 39 ++ widget/clipboard_test.go | 56 +++ 57 files changed, 7968 insertions(+), 463 deletions(-) create mode 100644 app/gesture_bridge.go create mode 100644 app/gesture_bridge_test.go create mode 100644 app/window_gesture_test.go create mode 100644 gesture/arena.go create mode 100644 gesture/arena_test.go create mode 100644 gesture/aware.go create mode 100644 gesture/aware_test.go create mode 100644 gesture/click.go create mode 100644 gesture/click_test.go create mode 100644 gesture/constants.go create mode 100644 gesture/doc.go create mode 100644 gesture/drag.go create mode 100644 gesture/drag_test.go create mode 100644 gesture/long_press.go create mode 100644 gesture/long_press_test.go create mode 100644 gesture/pointer_event.go create mode 100644 gesture/pointer_event_test.go create mode 100644 gesture/recognizer.go create mode 100644 gesture/recognizer_test.go create mode 100644 gesture/tap_and_drag.go create mode 100644 gesture/tap_and_drag_test.go create mode 100644 gesture/team.go create mode 100644 gesture/team_test.go create mode 100644 gesture/velocity.go create mode 100644 gesture/velocity_test.go create mode 100644 widget/clipboard.go create mode 100644 widget/clipboard_test.go diff --git a/app/event_bridge.go b/app/event_bridge.go index ed55926..2a6f69b 100644 --- a/app/event_bridge.go +++ b/app/event_bridge.go @@ -6,126 +6,240 @@ import ( "github.com/gogpu/ui/geometry" ) -// eventBridgeState is shared by the platform callbacks installed by -// attachEventBridge. EventSource callbacks run on the UI thread, so the state -// does not need synchronization. +// eventBridgeState tracks pointer/scroll session state shared among all +// event bridge callbacks. Keeping it in a struct avoids a growing list of +// closure-captured locals and makes the state available to helper functions. type eventBridgeState struct { - pressedButtons event.ButtonState - lastMousePos geometry.Point - cursorInside bool - leaveDispatched bool - mods event.Modifiers + // pressedButtons tracks which mouse buttons are currently pressed so + // that derived MouseMove events carry accurate ButtonState. + pressedButtons event.ButtonState + + // lastMousePos is the last known mouse position so WheelEvents carry + // the correct position (the platform's OnScroll callback doesn't + // provide mouse coordinates). + lastMousePos geometry.Point + + // mods tracks the keyboard modifiers so mouse and wheel events can + // carry them. The platform's mouse callbacks report only a button and + // a position, so without this every MouseEvent is built with ModNone + // and modified clicks would not be expressible. + mods event.Modifiers + + // mouseInsideWindow is true when the mouse pointer is known to be + // inside the window's client area. Set by PointerEnter and legacy + // OnMouseMove (with bounds check). Cleared by PointerLeave, focus + // loss, and PointerCancel (mouse). Used to suppress scroll events + // that arrive after the cursor has left (e.g. macOS momentum scroll). + mouseInsideWindow bool } -// pointInWindow reports whether pos is inside the window's logical content -// bounds. A size can be unavailable briefly during window creation; preserve -// event delivery until the first real size arrives in that case. -func pointInWindow(w *Window, pos geometry.Point) bool { - size := w.WindowSize() - if size.IsEmpty() { +// pointInWindow returns true when (x, y) is inside the window's logical +// bounds. Returns true when no WindowProvider is set (headless). +func (s *eventBridgeState) pointInWindow(w *Window, x, y float32) bool { + if w.wp == nil { return true } - return geometry.FromPointSize(geometry.Point{}, size).Contains(pos) + pw, ph := w.wp.Size() + return x >= 0 && y >= 0 && x < float32(pw) && y < float32(ph) } -// handleBridgeMouseMove rejects ordinary mouse movement outside the window. -// A held button bypasses the gate so pointer capture and drag tracking keep -// receiving moves after the pointer crosses the window edge. -func handleBridgeMouseMove(w *Window, state *eventBridgeState, x, y float64) { - pos := geometry.Pt(float32(x), float32(y)) - inside := pointInWindow(w, pos) - if !inside && state.pressedButtons == 0 { - if state.cursorInside { - // Some platforms keep sending moves in a narrow area outside the - // window without first sending PointerLeave. Synthesize one leave - // on the transition so widget hover and cursor state are cleared. - state.cursorInside = false - state.leaveDispatched = true - w.HandleEvent(event.NewMouseEvent( - event.MouseLeave, - event.ButtonNone, - state.pressedButtons, - pos, - pos, - state.mods, - )) - } - return - } - - state.cursorInside = inside - if inside { - state.leaveDispatched = false - } - state.lastMousePos = pos - w.HandleEvent(event.NewMouseEvent( - event.MouseMove, - event.ButtonNone, - state.pressedButtons, - pos, - pos, // global position same as local for root dispatch - state.mods, - )) +// scrollAllowed returns true when a scroll event should be dispatched. +// Scrolls are allowed when the mouse is inside the window or when a +// mouse button is held (drag in progress). +func (s *eventBridgeState) scrollAllowed() bool { + return s.mouseInsideWindow || s.pressedButtons != 0 } // attachEventBridge registers event callbacks on the EventSource that // translate platform events into ui/event types and dispatch them to // the Window. // +// Unified pointer pipeline (ADR-049 Phase 3): +// PointerEvent is the SINGLE source of all pointer input. If the platform +// provides PointerEventSource, those events drive everything. If the platform +// has only legacy mouse callbacks, they are wrapped into PointerEvents first. +// +// HandlePointerEvent is the sole entry point. It feeds the gesture arena AND +// derives MouseEvent for existing widget dispatch via HandleEvent. +// // This function is called once during App creation when an EventSource // is provided. The callbacks are invoked on the main thread by the host // application's event loop. -// -// Window-bound checks belong here: Window.HandleEvent also serves the public -// App.HandleEvent API and uitest, whose caller-supplied coordinates must remain -// available for synthetic input. func attachEventBridge(es gpucontext.EventSource, w *Window) { - state := &eventBridgeState{} + st := &eventBridgeState{} + + _, hasPointerSource := es.(gpucontext.PointerEventSource) + + // --- Unified pointer pipeline --- + // + // If the platform has PointerEventSource: OnPointer handles ALL pointer + // events (Down/Up/Move/Cancel/Enter/Leave). Legacy mouse callbacks are + // still wired for button state tracking but do NOT dispatch events. + // + // If the platform has only legacy mouse callbacks: they synthesize + // PointerEvents and feed them through HandlePointerEvent. + + if hasPointerSource { + // Platform provides rich pointer events. Wire legacy callbacks + // ONLY for button state tracking (pressedButtons/lastMousePos), + // not for event dispatch. All dispatch goes through OnPointer. + es.OnMouseMove(func(x, y float64) { + st.lastMousePos = geometry.Pt(float32(x), float32(y)) + st.mouseInsideWindow = st.pointInWindow(w, float32(x), float32(y)) + }) + es.OnMousePress(func(button gpucontext.MouseButton, _ float64, _ float64) { + btn := translateMouseButton(button) + st.pressedButtons |= buttonToState(btn) + }) + es.OnMouseRelease(func(button gpucontext.MouseButton, _ float64, _ float64) { + btn := translateMouseButton(button) + st.pressedButtons &^= buttonToState(btn) + }) + } else { + // Legacy-only platform. Synthesize PointerEvents from mouse callbacks + // and feed through the unified HandlePointerEvent path. + es.OnMouseMove(func(x, y float64) { + pos := geometry.Pt(float32(x), float32(y)) + st.lastMousePos = pos + st.mouseInsideWindow = st.pointInWindow(w, float32(x), float32(y)) + // Only synthesize gesture moves when buttons are pressed. + // Unpressed moves are handled as derived MouseMove by HandlePointerEvent + // only when there is a gesture in progress; otherwise dispatch a + // plain MouseMove for hover tracking. + if st.pressedButtons != 0 { + gev := synthesizePointerEvent(event.MouseMove, event.ButtonNone, st.pressedButtons, pos, st.mods) + w.HandlePointerEvent(&gev) + } else { + // No gesture in progress — dispatch MouseMove directly for hover. + e := event.NewMouseEvent(event.MouseMove, event.ButtonNone, + st.pressedButtons, pos, pos, st.mods) + w.HandleEvent(e) + } + }) + + es.OnMousePress(func(button gpucontext.MouseButton, x, y float64) { + pos := geometry.Pt(float32(x), float32(y)) + btn := translateMouseButton(button) + st.pressedButtons |= buttonToState(btn) + gev := synthesizePointerEvent(event.MousePress, btn, st.pressedButtons, pos, st.mods) + w.HandlePointerEvent(&gev) + }) + + es.OnMouseRelease(func(button gpucontext.MouseButton, x, y float64) { + pos := geometry.Pt(float32(x), float32(y)) + btn := translateMouseButton(button) + st.pressedButtons &^= buttonToState(btn) + gev := synthesizePointerEvent(event.MouseRelease, btn, st.pressedButtons, pos, st.mods) + w.HandlePointerEvent(&gev) + }) + } + + attachKeyboardBridge(es, w, &st.mods) + attachScrollBridge(es, w, st) - es.OnMouseMove(func(x, y float64) { - handleBridgeMouseMove(w, state, x, y) + es.OnResize(func(width, height int) { + w.HandleResize(width, height) }) - es.OnMousePress(func(button gpucontext.MouseButton, x, y float64) { - pos := geometry.Pt(float32(x), float32(y)) - btn := translateMouseButton(button) - state.pressedButtons |= buttonToState(btn) - state.cursorInside = pointInWindow(w, pos) - if state.cursorInside { - state.leaveDispatched = false + es.OnFocus(func(focused bool) { + // A modifier believed to be held after the window lost focus would turn + // the next ordinary click into a modified one: the release happened + // somewhere else and this window never saw it. + st.mods = event.ModNone + if !focused { + // The cursor may leave the window while focus is switching to + // another application. Without a PointerLeave, the bridge + // would continue to believe the cursor is inside, allowing + // stale scroll events through. + st.mouseInsideWindow = false + st.pressedButtons = 0 } - state.lastMousePos = pos - e := event.NewMouseEvent( - event.MousePress, - btn, - state.pressedButtons, - pos, - pos, - state.mods, - ) - w.HandleEvent(e) + w.HandleFocusChange(focused) }) - es.OnMouseRelease(func(button gpucontext.MouseButton, x, y float64) { - pos := geometry.Pt(float32(x), float32(y)) - btn := translateMouseButton(button) - state.pressedButtons &^= buttonToState(btn) - state.cursorInside = pointInWindow(w, pos) - if state.cursorInside { - state.leaveDispatched = false + // Wire W3C Pointer Events for the unified pipeline. When the platform + // has PointerEventSource, this handles ALL pointer types (Down/Up/Move/ + // Cancel/Enter/Leave). The unified HandlePointerEvent derives MouseEvent + // for existing widget dispatch. + attachPointerBridge(es, w, st) +} + +// attachScrollBridge wires scroll event callbacks. +// +// If the platform provides ScrollEventSource (position-carrying scroll events), +// OnScrollEvent is used and OnScroll is NOT wired. This avoids double dispatch. +// If only the basic OnScroll is available, it uses lastMousePos from legacy +// mouse tracking. +// +// Scrolls are filtered by mouseInsideWindow to prevent dispatch when the cursor +// has left the window (e.g. macOS momentum/inertial scroll). +func attachScrollBridge(es gpucontext.EventSource, w *Window, st *eventBridgeState) { + if ses, ok := es.(gpucontext.ScrollEventSource); ok { + ses.OnScrollEvent(func(sev gpucontext.ScrollEvent) { + // Decide whether to trust the position embedded in the event + // or fall back to the independently tracked mouse position. + // Some backends report physical (out-of-bounds) coordinates or + // zero for events synthesized from a touchpad. + // + // Decision matrix: + // 1. Reported non-zero position inside window → use it. + // 2. Reported (0,0): ambiguous — could be the real window + // corner or an uninitialized zero from the backend. Fall + // back to lastMousePos when the cursor is inside; use (0,0) + // only when lastMousePos is also (0,0) (confirming it). + // 3. Reported outside, cursor tracked inside (or dragging) → + // fall back to lastMousePos. + // 4. Neither source can confirm inside → suppress. + reportedInBounds := st.pointInWindow(w, float32(sev.X), float32(sev.Y)) + isZeroPos := sev.X == 0 && sev.Y == 0 + + var pos geometry.Point + switch { + case reportedInBounds && !isZeroPos: + // Non-zero position inside bounds. Trusted. + pos = geometry.Pt(float32(sev.X), float32(sev.Y)) + + case isZeroPos && st.mouseInsideWindow: + // Zero position while cursor is tracked inside. Use lastMousePos + // which is the independently confirmed position. When lastMousePos + // is also (0,0), we correctly use (0,0). + pos = st.lastMousePos + + case st.scrollAllowed(): + // Reported position is outside (or untrusted) but cursor is + // tracked inside or dragging. Use the last known good position. + pos = st.lastMousePos + + default: + // Cursor is outside and no drag is active. Suppress. + return + } + + delta := geometry.Pt(float32(sev.DeltaX), float32(sev.DeltaY)) + e := event.NewWheelEvent(delta, pos, pos, translateModifiers(sev.Modifiers)) + w.HandleEvent(e) + }) + // Do NOT wire OnScroll — ScrollEventSource replaces it entirely. + return + } + + es.OnScroll(func(dx, dy float64) { + if !st.scrollAllowed() { + return } - state.lastMousePos = pos - e := event.NewMouseEvent( - event.MouseRelease, - btn, - state.pressedButtons, - pos, - pos, - state.mods, + delta := geometry.Pt(float32(dx), float32(dy)) + e := event.NewWheelEvent( + delta, + st.lastMousePos, + st.lastMousePos, + st.mods, ) w.HandleEvent(e) }) +} +// attachKeyboardBridge wires keyboard and text input callbacks. +func attachKeyboardBridge(es gpucontext.EventSource, w *Window, mods *event.Modifiers) { es.OnKeyPress(func(key gpucontext.Key, platMods gpucontext.Modifiers) { uiKey := translateKey(key) uiMods := translateModifiers(platMods) @@ -133,7 +247,7 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { // alone reports no Shift. Fold the key itself in, or holding a modifier // and clicking — with no other key in between, which is the whole // gesture — would leave the state empty. - state.mods = uiMods | modifierForKey(uiKey) + *mods = uiMods | modifierForKey(uiKey) // Rune=0: character input is delivered separately via OnTextInput. // KeyPress only carries the key code for navigation (arrows, Tab, // Backspace, etc.) and modifier detection (Ctrl+C, etc.). @@ -150,7 +264,7 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { uiKey := translateKey(key) uiMods := translateModifiers(platMods) // Releasing a modifier clears it: the reported state still contains it. - state.mods = uiMods &^ modifierForKey(uiKey) + *mods = uiMods &^ modifierForKey(uiKey) e := event.NewKeyEvent( event.KeyRelease, uiKey, @@ -171,141 +285,107 @@ func attachEventBridge(es gpucontext.EventSource, w *Window) { w.HandleEvent(e) } }) - - attachScrollBridge(es, w, state) - - es.OnResize(func(width, height int) { - w.HandleResize(width, height) - }) - - es.OnFocus(func(focused bool) { - // A modifier believed to be held after the window lost focus would turn - // the next ordinary click into a modified one: the release happened - // somewhere else and this window never saw it. - state.mods = event.ModNone - if !focused { - // Focus loss does not guarantee matching mouse releases or a - // PointerLeave. Reset bridge ownership at this boundary; otherwise - // stale buttons would bypass the outside-window gate indefinitely. - state.pressedButtons = 0 - state.cursorInside = false - } - w.HandleFocusChange(focused) - }) - - // Wire W3C Pointer Events for Enter/Leave (cursor/hover support). - attachPointerBridge(es, w, state) } -// attachScrollBridge prefers ScrollEventSource because it reports the pointer -// position for each wheel event. The basic EventSource callback has no -// position, so it falls back to the last pointer position and cursor state. -func attachScrollBridge(es gpucontext.EventSource, w *Window, state *eventBridgeState) { - if scrollSource, ok := es.(gpucontext.ScrollEventSource); ok { - scrollSource.OnScrollEvent(func(scroll gpucontext.ScrollEvent) { - pos := geometry.Pt(float32(scroll.X), float32(scroll.Y)) - inside := pointInWindow(w, pos) - zeroWithoutPosition := pos.IsZero() && - (!state.cursorInside || !state.lastMousePos.IsZero()) - if !inside || zeroWithoutPosition { - // A few EventSource implementations historically reported wheel - // positions in the wrong coordinate space, or as (0,0). Treat the - // position as untrusted unless the independently tracked cursor - // state (or an active drag) confirms that the event belongs here. - if !state.cursorInside && state.pressedButtons == 0 { - return - } - pos = state.lastMousePos - } - - delta := geometry.Pt(float32(scroll.DeltaX), float32(scroll.DeltaY)) - w.HandleEvent(event.NewWheelEvent( - delta, - pos, - pos, - translateModifiers(scroll.Modifiers), - )) - }) - // gogpu dispatches a detailed scroll event to both its detailed and - // legacy callbacks. Register exactly one to avoid duplicate wheels. - return - } - - es.OnScroll(func(dx, dy float64) { - if !state.cursorInside && state.pressedButtons == 0 { - return - } - - delta := geometry.Pt(float32(dx), float32(dy)) - w.HandleEvent(event.NewWheelEvent( - delta, - state.lastMousePos, - state.lastMousePos, - state.mods, - )) - }) -} - -// attachPointerBridge wires W3C PointerEventSource for Enter/Leave events. +// attachPointerBridge wires W3C PointerEventSource for the unified pointer +// pipeline (ADR-049 Phase 3). // -// The platform generates PointerEnter when the mouse enters the window -// and PointerLeave when it leaves. These are essential for resetting -// hover state when the mouse exits the window entirely. +// When the platform provides PointerEventSource, ALL pointer events flow +// through this function. Enter/Leave are dispatched as MouseEvents directly +// for mouse pointers only (touch/pen enter/leave are ignored to avoid +// disturbing mouse state). Down/Up/Move/Cancel are converted to +// gesture.PointerEvent and fed to HandlePointerEvent, which both feeds the +// gesture arena AND derives MouseEvents for existing widget dispatch. // -// PointerMove/Down/Up are already handled by the legacy OnMouseMove, -// OnMousePress, and OnMouseRelease callbacks. Enter, Leave, and Cancel are -// handled here because the legacy EventSource has no equivalent callbacks. -func attachPointerBridge(es gpucontext.EventSource, w *Window, state *eventBridgeState) { +// When the platform does not provide PointerEventSource, this function is +// a no-op (legacy mouse callbacks handle synthesis in attachEventBridge). +func attachPointerBridge( + es gpucontext.EventSource, + w *Window, + st *eventBridgeState, +) { pes, ok := es.(gpucontext.PointerEventSource) if !ok { return } pes.OnPointer(func(ev gpucontext.PointerEvent) { - // Legacy mouse state and cursor hover must not be changed by an - // independent touch or pen pointer. - if ev.PointerType != gpucontext.PointerTypeMouse { - return - } + isMouse := ev.PointerType == gpucontext.PointerTypeMouse || + ev.PointerType == 0 // zero = unspecified, treat as mouse switch ev.Type { case gpucontext.PointerEnter: + // Only mouse enter/leave affect mouse tracking state. + // Touch/pen enter/leave must not arm scroll fallback or + // dispatch mouse events (they are separate pointer streams). + if !isMouse { + return + } pos := geometry.Pt(float32(ev.X), float32(ev.Y)) - state.cursorInside = true - state.leaveDispatched = false - state.lastMousePos = pos + st.lastMousePos = pos + st.mouseInsideWindow = true e := event.NewMouseEvent( event.MouseEnter, event.ButtonNone, - state.pressedButtons, + st.pressedButtons, pos, pos, translateModifiers(ev.Modifiers), ) w.HandleEvent(e) case gpucontext.PointerLeave: - pos := geometry.Pt(float32(ev.X), float32(ev.Y)) - state.cursorInside = false - if state.leaveDispatched { + if !isMouse { return } - state.leaveDispatched = true + pos := geometry.Pt(float32(ev.X), float32(ev.Y)) + st.mouseInsideWindow = false e := event.NewMouseEvent( event.MouseLeave, event.ButtonNone, - state.pressedButtons, + st.pressedButtons, pos, pos, translateModifiers(ev.Modifiers), ) w.HandleEvent(e) + case gpucontext.PointerDown: + // Update button tracking from rich pointer data. + btn := convertPointerButton(ev.Button) + st.pressedButtons |= buttonToState(btn) + pos := geometry.Pt(float32(ev.X), float32(ev.Y)) + st.lastMousePos = pos + // Unified path: convert and feed through HandlePointerEvent. + if gev, ok := convertPointerEvent(ev); ok { + w.HandlePointerEvent(&gev) + } + + case gpucontext.PointerUp: + btn := convertPointerButton(ev.Button) + st.pressedButtons &^= buttonToState(btn) + pos := geometry.Pt(float32(ev.X), float32(ev.Y)) + st.lastMousePos = pos + if gev, ok := convertPointerEvent(ev); ok { + w.HandlePointerEvent(&gev) + } + + case gpucontext.PointerMove: + pos := geometry.Pt(float32(ev.X), float32(ev.Y)) + st.lastMousePos = pos + if gev, ok := convertPointerEvent(ev); ok { + w.HandlePointerEvent(&gev) + } + case gpucontext.PointerCancel: - // The platform has ended the gesture without guaranteeing legacy - // mouse-release callbacks. Drop bridge and window capture state, then - // fail closed until a new pointer event establishes cursor state. - state.pressedButtons = 0 - state.cursorInside = false - w.cancelPointerState() + // Only cancel mouse state for mouse cancels; touch/pen + // cancels must not disturb mouse capture or held buttons. + if isMouse { + st.mouseInsideWindow = false + st.pressedButtons = 0 + w.cancelPointerState() + } + if gev, ok := convertPointerEvent(ev); ok { + w.HandlePointerEvent(&gev) + } } }) } diff --git a/app/event_bridge_modifiers_test.go b/app/event_bridge_modifiers_test.go index 95df420..de6e10e 100644 --- a/app/event_bridge_modifiers_test.go +++ b/app/event_bridge_modifiers_test.go @@ -44,21 +44,36 @@ func bridgeWithRecorder(t *testing.T) (*mockEventSource, *modRecorder) { return es, rec } +// simulatePointerDown dispatches a PointerDown event through the unified +// pointer pipeline. Used by modifier tests that need a click to reach +// widgets via HandlePointerEvent -> deriveMouseEvent -> HandleEvent. +func simulatePointerDown(es *mockEventSource, x, y float64, mods gpucontext.Modifiers) { + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, + X: x, + Y: y, + PointerID: 1, + PointerType: gpucontext.PointerTypeMouse, + Button: gpucontext.ButtonLeft, + Buttons: gpucontext.ButtonsLeft, + Modifiers: mods, + IsPrimary: true, + }) +} + // A click while a modifier is held has to arrive as a modified click. // -// The platform's mouse callbacks carry only a button and a position, so the -// bridge has to remember what the keyboard is holding. Without that every mouse -// event is built with ModNone and ⌥click, ⇧click and ⌃click cannot be expressed -// at all — an application either does without them or reimplements this -// tracking on top of the key events itself. +// The platform delivers rich PointerEvents with modifier state. The unified +// pipeline converts these to MouseEvents that carry the modifier bits. func TestMouseEventsCarryHeldModifiers(t *testing.T) { es, rec := bridgeWithRecorder(t) - // Alt down, then click. Nothing else is pressed in between: that is the - // gesture, and it is why the key's own modifier bit has to be folded in — - // a key event reports what was held BEFORE it, so this one reports nothing. + // Alt down. Platform modifier state is tracked in the PointerEvent. es.onKeyPress(gpucontext.KeyLeftAlt, gpucontext.Modifiers(0)) - es.onMousePress(gpucontext.MouseButtonLeft, 10, 10) + + // Click via PointerEvent with Alt modifier. In the unified pipeline, + // modifiers are carried by the PointerEvent from the platform. + simulatePointerDown(es, 10, 10, gpucontext.ModAlt) if rec.seen == 0 { t.Fatal("the click never reached the widget") @@ -74,8 +89,13 @@ func TestMouseModifiersClearOnRelease(t *testing.T) { es.onKeyPress(gpucontext.KeyLeftAlt, gpucontext.Modifiers(0)) es.onKeyRelease(gpucontext.KeyLeftAlt, gpucontext.Modifiers(0)) - es.onMousePress(gpucontext.MouseButtonLeft, 10, 10) + // Click after Alt released — PointerEvent carries no modifier. + simulatePointerDown(es, 10, 10, 0) + + if rec.seen == 0 { + t.Fatal("the click never reached the widget") + } if last := rec.got[len(rec.got)-1]; last.Has(event.ModAlt) { t.Errorf("click carried %v after Alt was released", last) } @@ -89,8 +109,13 @@ func TestMouseModifiersClearOnFocusLoss(t *testing.T) { es.onKeyPress(gpucontext.KeyLeftAlt, gpucontext.Modifiers(0)) es.onFocus(false) - es.onMousePress(gpucontext.MouseButtonLeft, 10, 10) + // Click after focus loss — PointerEvent carries no modifier. + simulatePointerDown(es, 10, 10, 0) + + if rec.seen == 0 { + t.Fatal("the click never reached the widget") + } if last := rec.got[len(rec.got)-1]; last.Has(event.ModAlt) { t.Errorf("click carried %v after the window lost focus", last) } diff --git a/app/event_bridge_test.go b/app/event_bridge_test.go index ed7ff27..db0db87 100644 --- a/app/event_bridge_test.go +++ b/app/event_bridge_test.go @@ -28,8 +28,13 @@ func TestEventBridge_MouseMove(t *testing.T) { root := newMockWidget() a.SetRoot(root) - // Simulate mouse move. - es.onMouseMove(100.0, 200.0) + // Unified pipeline: PointerMove through OnPointer derives MouseMove. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerMove, + X: 100.0, + Y: 200.0, + PointerType: gpucontext.PointerTypeMouse, + }) if !root.eventCalled { t.Fatal("event not dispatched") @@ -46,14 +51,6 @@ func TestEventBridge_MouseMove(t *testing.T) { } } -func TestPointInWindow_AllowsEventsBeforeSizeIsKnown(t *testing.T) { - var w Window - - if !pointInWindow(&w, geometry.Pt(10_000, 10_000)) { - t.Fatal("pointInWindow rejected an event before the window size was known") - } -} - func TestEventBridge_MouseMoveOutsideWindow(t *testing.T) { es := &mockEventSource{} root := newBoundedEventBridgeRoot(es) @@ -65,65 +62,77 @@ func TestEventBridge_MouseMoveOutsideWindow(t *testing.T) { } } -func TestEventBridge_MouseMoveOutsideSynthesizesOneLeave(t *testing.T) { +func TestEventBridge_PointerLeaveDispatchesMouseLeave(t *testing.T) { es := &mockEventSource{} root := newBoundedEventBridgeRoot(es) - es.onMouseMove(100, 100) - resetEventBridgeRoot(root) - es.onMouseMove(450, 100) - - leave, ok := root.lastEvent.(*event.MouseEvent) - if !ok || leave.MouseType != event.MouseLeave { - t.Fatalf("inside-to-outside event = %T %#v, want MouseLeave", root.lastEvent, root.lastEvent) - } - + // Enter via PointerEnter, then leave via PointerLeave. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerEnter, + PointerType: gpucontext.PointerTypeMouse, + X: 100, + Y: 100, + }) resetEventBridgeRoot(root) - es.onMouseMove(460, 100) - if root.eventCalled { - t.Errorf("second outside move dispatched %T, want no event", root.lastEvent) - } - // A platform PointerLeave arriving after the synthetic transition is - // the same exit and must not dispatch a duplicate leave. es.onPointer(gpucontext.PointerEvent{ Type: gpucontext.PointerLeave, PointerType: gpucontext.PointerTypeMouse, X: 460, Y: 100, }) - if root.eventCalled { - t.Errorf("PointerLeave after synthetic leave dispatched %T, want no event", root.lastEvent) + + leave, ok := root.lastEvent.(*event.MouseEvent) + if !ok || leave.MouseType != event.MouseLeave { + t.Fatalf("PointerLeave event = %T %#v, want MouseLeave", root.lastEvent, root.lastEvent) } } -func TestEventBridge_DragOutsidePreservesCaptureEvents(t *testing.T) { +func TestEventBridge_DragOutsideViaPointerEvents(t *testing.T) { es := &mockEventSource{} - root := newBoundedEventBridgeRoot(es) + a := New(WithEventSource(es)) + root := newMockWidget() + a.SetRoot(root) - es.onMousePress(gpucontext.MouseButtonLeft, 100, 100) + // Drag via PointerDown -> PointerMove -> PointerUp dispatches correctly. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, + X: 100, + Y: 100, + PointerType: gpucontext.PointerTypeMouse, + Button: gpucontext.ButtonLeft, + Buttons: gpucontext.ButtonsLeft, + }) resetEventBridgeRoot(root) - es.onMouseMove(450, 100) + + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerMove, + X: 450, + Y: 100, + PointerType: gpucontext.PointerTypeMouse, + Buttons: gpucontext.ButtonsLeft, + }) move, ok := root.lastEvent.(*event.MouseEvent) if !ok || move.MouseType != event.MouseMove { t.Fatalf("drag move event = %T %#v, want MouseMove", root.lastEvent, root.lastEvent) } if !move.Buttons.IsLeftPressed() { - t.Error("drag move outside window lost the pressed-button state") + t.Error("drag move should carry left button state") } resetEventBridgeRoot(root) - es.onMouseRelease(gpucontext.MouseButtonLeft, 450, 100) + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerUp, + X: 450, + Y: 100, + PointerType: gpucontext.PointerTypeMouse, + Button: gpucontext.ButtonLeft, + Buttons: 0, + }) release, ok := root.lastEvent.(*event.MouseEvent) if !ok || release.MouseType != event.MouseRelease { - t.Fatalf("outside release event = %T %#v, want MouseRelease", root.lastEvent, root.lastEvent) - } - - resetEventBridgeRoot(root) - es.onMouseMove(460, 100) - if root.eventCalled { - t.Errorf("post-release outside move dispatched %T, want no event", root.lastEvent) + t.Fatalf("release event = %T %#v, want MouseRelease", root.lastEvent, root.lastEvent) } } @@ -133,7 +142,15 @@ func TestEventBridge_MousePress(t *testing.T) { root := newMockWidget() a.SetRoot(root) - es.onMousePress(gpucontext.MouseButtonLeft, 50.0, 75.0) + // Unified pipeline: PointerDown through OnPointer derives MousePress. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, + X: 50.0, + Y: 75.0, + PointerType: gpucontext.PointerTypeMouse, + Button: gpucontext.ButtonLeft, + Buttons: gpucontext.ButtonsLeft, + }) if !root.eventCalled { t.Fatal("event not dispatched") @@ -159,7 +176,15 @@ func TestEventBridge_MouseRelease(t *testing.T) { root := newMockWidget() a.SetRoot(root) - es.onMouseRelease(gpucontext.MouseButtonRight, 30.0, 40.0) + // Unified pipeline: PointerUp through OnPointer derives MouseRelease. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerUp, + X: 30.0, + Y: 40.0, + PointerType: gpucontext.PointerTypeMouse, + Button: gpucontext.ButtonRight, + Buttons: 0, + }) if !root.eventCalled { t.Fatal("event not dispatched") @@ -259,8 +284,15 @@ func TestEventBridge_ScrollOutsideWindow_Fallback(t *testing.T) { es := &mockEventSource{} root := newBoundedEventBridgeRoot(es) - es.onMouseMove(100, 100) - es.onMouseMove(450, 100) + // Enter the window, then leave. Scrolls after leave should be suppressed. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerEnter, PointerType: gpucontext.PointerTypeMouse, + X: 100, Y: 100, + }) + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerLeave, PointerType: gpucontext.PointerTypeMouse, + X: 450, Y: 100, + }) resetEventBridgeRoot(root) es.onScroll(0, -3) @@ -268,7 +300,11 @@ func TestEventBridge_ScrollOutsideWindow_Fallback(t *testing.T) { t.Errorf("outside scroll dispatched %T, want no event", root.lastEvent) } - es.onMouseMove(100, 100) + // Re-enter the window. Scrolls should be dispatched again. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerEnter, PointerType: gpucontext.PointerTypeMouse, + X: 100, Y: 100, + }) resetEventBridgeRoot(root) es.onScroll(0, -3) if _, ok := root.lastEvent.(*event.WheelEvent); !ok { @@ -287,6 +323,7 @@ func TestEventBridge_DetailedScrollUsesEventPositionAndBounds(t *testing.T) { t.Fatal("legacy OnScroll callback registered with detailed source; wheels would dispatch twice") } + // Scroll at a position inside the window bounds (400x300). es.onScrollEvent(gpucontext.ScrollEvent{ X: 120, Y: 130, DeltaX: 2, DeltaY: -4, @@ -306,9 +343,8 @@ func TestEventBridge_DetailedScrollUsesEventPositionAndBounds(t *testing.T) { t.Error("detailed wheel lost its Shift modifier") } - // All available signals now agree the cursor is outside. - es.onMouseMove(100, 100) - es.onMouseMove(450, 130) + // Scroll at a position outside the window bounds should be suppressed + // when the cursor is not tracked inside. resetEventBridgeRoot(root) es.onScrollEvent(gpucontext.ScrollEvent{X: 450, Y: 130, DeltaY: -4}) if root.eventCalled { @@ -320,12 +356,17 @@ func TestEventBridge_DetailedScrollFallsBackForUntrustedPosition(t *testing.T) { es := &mockScrollEventSource{} root := newBoundedEventBridgeRoot(es) - // Keep a trusted pointer position. Contract-violating backends have - // historically reported physical (therefore out-of-bounds) coordinates - // or no position at all; neither should discard an otherwise valid wheel. + // Establish the pointer as inside the window via PointerEnter + + // legacy move tracking (which updates lastMousePos). + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerEnter, PointerType: gpucontext.PointerTypeMouse, + X: 100, Y: 100, + }) es.onMouseMove(100, 100) resetEventBridgeRoot(root) + // A scroll event with an out-of-bounds reported position should fall + // back to lastMousePos when the cursor is known to be inside. es.onScrollEvent(gpucontext.ScrollEvent{X: 1000, Y: 700, DeltaY: -2}) wheel, ok := root.lastEvent.(*event.WheelEvent) if !ok { @@ -335,6 +376,7 @@ func TestEventBridge_DetailedScrollFallsBackForUntrustedPosition(t *testing.T) { t.Errorf("fallback position = %v, want last trusted position (100, 100)", wheel.Position) } + // A scroll event with zero position should also use fallback. resetEventBridgeRoot(root) es.onScrollEvent(gpucontext.ScrollEvent{DeltaY: -2}) wheel, ok = root.lastEvent.(*event.WheelEvent) @@ -345,9 +387,12 @@ func TestEventBridge_DetailedScrollFallsBackForUntrustedPosition(t *testing.T) { t.Errorf("zero-position fallback = %v, want last trusted position (100, 100)", wheel.Position) } - // The same ambiguous zero must not revive a stale in-window position - // after an independently observed exit (including momentum scroll). - es.onMouseMove(450, 100) + // After the cursor leaves, an untrusted zero-position scroll should + // NOT revive a stale in-window position (e.g. macOS momentum scroll). + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerLeave, PointerType: gpucontext.PointerTypeMouse, + X: 450, Y: 100, + }) resetEventBridgeRoot(root) es.onScrollEvent(gpucontext.ScrollEvent{DeltaY: -2, IsMomentum: true}) if root.eventCalled { @@ -399,7 +444,11 @@ func TestEventBridge_FocusLossInvalidatesFallbackScrollPosition(t *testing.T) { es := &mockEventSource{} root := newBoundedEventBridgeRoot(es) - es.onMouseMove(100, 100) + // Establish the cursor as inside via PointerEnter. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerEnter, PointerType: gpucontext.PointerTypeMouse, + X: 100, Y: 100, + }) es.onFocus(false) resetEventBridgeRoot(root) es.onScroll(0, -2) @@ -413,25 +462,29 @@ func TestEventBridge_FocusLossCancelsHeldButtons(t *testing.T) { es := &mockScrollEventSource{} root := newBoundedEventBridgeRoot(es) - // The release may occur while another window has focus, so no matching - // OnMouseRelease callback is guaranteed. - es.onMousePress(gpucontext.MouseButtonLeft, 100, 100) + // Press left button via PointerDown (the unified pipeline dispatch path). + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, PointerType: gpucontext.PointerTypeMouse, + X: 100, Y: 100, + Button: gpucontext.ButtonLeft, Buttons: gpucontext.ButtonsLeft, + }) es.onFocus(false) resetEventBridgeRoot(root) - es.onMouseMove(450, 100) - if root.eventCalled { - t.Errorf("outside move after focus loss dispatched %T, want no event", root.lastEvent) - } - + // After focus loss, outside scroll should be suppressed + // (mouseInsideWindow=false, pressedButtons=0). es.onScrollEvent(gpucontext.ScrollEvent{X: 450, Y: 100, DeltaY: -2}) if root.eventCalled { t.Errorf("outside scroll after focus loss dispatched %T, want no event", root.lastEvent) } // A new gesture starts from a clean button state rather than inheriting - // the lost left-button release. - es.onMousePress(gpucontext.MouseButtonRight, 100, 100) + // the lost left-button release. Use PointerDown for dispatch. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, PointerType: gpucontext.PointerTypeMouse, + X: 100, Y: 100, + Button: gpucontext.ButtonRight, Buttons: gpucontext.ButtonsRight, + }) press, ok := root.lastEvent.(*event.MouseEvent) if !ok { t.Fatalf("new press event = %T, want MouseEvent", root.lastEvent) @@ -449,13 +502,22 @@ func TestEventBridge_PointerCancelCancelsHeldButtonsAndCapture(t *testing.T) { a.SetRoot(root) w := a.Window() - es.onMousePress(gpucontext.MouseButtonLeft, 100, 100) + // Use PointerDown so HandleEvent updates mouseButtonsHeld. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, PointerType: gpucontext.PointerTypeMouse, + X: 100, Y: 100, + Button: gpucontext.ButtonLeft, Buttons: gpucontext.ButtonsLeft, + }) w.ctx.CapturePointer(root) if w.capturedWidget != root { t.Fatal("precondition: root should hold pointer capture") } - es.onPointer(gpucontext.PointerEvent{Type: gpucontext.PointerCancel}) + // Mouse PointerCancel should clear capture and held buttons. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerCancel, + PointerType: gpucontext.PointerTypeMouse, + }) if w.capturedWidget != nil { t.Error("capturedWidget should be nil after PointerCancel") } @@ -467,11 +529,8 @@ func TestEventBridge_PointerCancelCancelsHeldButtonsAndCapture(t *testing.T) { t.Fatalf("captured widget cancellation event = %T %#v, want final MouseRelease", root.lastEvent, root.lastEvent) } + // After cancel, outside scroll should be suppressed. resetEventBridgeRoot(root) - es.onMouseMove(450, 100) - if root.eventCalled { - t.Errorf("outside move after PointerCancel dispatched %T, want no event", root.lastEvent) - } es.onScrollEvent(gpucontext.ScrollEvent{X: 450, Y: 100, DeltaY: -2}) if root.eventCalled { t.Errorf("outside scroll after PointerCancel dispatched %T, want no event", root.lastEvent) @@ -486,6 +545,7 @@ func TestEventBridge_NonMousePointerEventsPreserveMouseState(t *testing.T) { a.SetRoot(root) w := a.Window() + // Touch/pen PointerEnter should NOT dispatch mouse events or arm scroll. for _, pointerType := range []gpucontext.PointerType{ gpucontext.PointerTypeTouch, gpucontext.PointerTypePen, @@ -505,8 +565,14 @@ func TestEventBridge_NonMousePointerEventsPreserveMouseState(t *testing.T) { } } - es.onMouseMove(100, 100) + // Establish mouse as inside via PointerEnter (mouse). + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerEnter, PointerType: gpucontext.PointerTypeMouse, + X: 100, Y: 100, + }) resetEventBridgeRoot(root) + + // Touch/pen PointerLeave should NOT dispatch mouse events. for _, pointerType := range []gpucontext.PointerType{ gpucontext.PointerTypeTouch, gpucontext.PointerTypePen, @@ -528,8 +594,15 @@ func TestEventBridge_NonMousePointerEventsPreserveMouseState(t *testing.T) { t.Fatalf("mouse scroll after non-mouse leave = %T, want WheelEvent", root.lastEvent) } - es.onMousePress(gpucontext.MouseButtonLeft, 100, 100) + // Press mouse button via PointerDown (updates w.mouseButtonsHeld). + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, PointerType: gpucontext.PointerTypeMouse, + X: 100, Y: 100, + Button: gpucontext.ButtonLeft, Buttons: gpucontext.ButtonsLeft, + }) w.ctx.CapturePointer(root) + + // Touch/pen PointerCancel should NOT clear mouse capture. for _, pointerType := range []gpucontext.PointerType{ gpucontext.PointerTypeTouch, gpucontext.PointerTypePen, @@ -548,8 +621,13 @@ func TestEventBridge_NonMousePointerEventsPreserveMouseState(t *testing.T) { t.Errorf("mouseButtonsHeld = %v after touch PointerCancel, want left", w.mouseButtonsHeld) } + // Mouse drag via PointerMove should work normally after touch cancel. resetEventBridgeRoot(root) - es.onMouseMove(450, 100) + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerMove, PointerType: gpucontext.PointerTypeMouse, + X: 450, Y: 100, + Buttons: gpucontext.ButtonsLeft, + }) move, ok := root.lastEvent.(*event.MouseEvent) if !ok || move.MouseType != event.MouseMove { t.Fatalf("mouse drag after touch PointerCancel = %T, want MouseMove", root.lastEvent) @@ -862,13 +940,13 @@ func TestWidgetCursorToPlatform(t *testing.T) { func TestEventBridge_MouseButton_AllVariants(t *testing.T) { buttons := []struct { - name string - btn gpucontext.MouseButton - want event.Button + name string + platBtn gpucontext.Button + want event.Button }{ - {"Left", gpucontext.MouseButtonLeft, event.ButtonLeft}, - {"Right", gpucontext.MouseButtonRight, event.ButtonRight}, - {"Middle", gpucontext.MouseButtonMiddle, event.ButtonMiddle}, + {"Left", gpucontext.ButtonLeft, event.ButtonLeft}, + {"Right", gpucontext.ButtonRight, event.ButtonRight}, + {"Middle", gpucontext.ButtonMiddle, event.ButtonMiddle}, } for _, tt := range buttons { @@ -878,7 +956,14 @@ func TestEventBridge_MouseButton_AllVariants(t *testing.T) { root := newMockWidget() a.SetRoot(root) - es.onMousePress(tt.btn, 10.0, 20.0) + // Unified pipeline: PointerDown with different buttons. + es.onPointer(gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, + X: 10.0, + Y: 20.0, + PointerType: gpucontext.PointerTypeMouse, + Button: tt.platBtn, + }) me, ok := root.lastEvent.(*event.MouseEvent) if !ok { @@ -1009,21 +1094,30 @@ func TestEventBridge_PointerLeave(t *testing.T) { } } -func TestEventBridge_PointerMove_Ignored(t *testing.T) { +func TestEventBridge_PointerMove_DispatchesMouseEvent(t *testing.T) { es := &mockEventSource{} a := New(WithEventSource(es)) root := newMockWidget() a.SetRoot(root) - // PointerMove should be ignored (already handled by OnMouseMove). + // In the unified pipeline, PointerMove through OnPointer derives a + // MouseMove event and dispatches it via HandleEvent. es.onPointer(gpucontext.PointerEvent{ - Type: gpucontext.PointerMove, - X: 50.0, - Y: 50.0, + Type: gpucontext.PointerMove, + X: 50.0, + Y: 50.0, + PointerType: gpucontext.PointerTypeMouse, }) - if root.eventCalled { - t.Error("PointerMove should not dispatch via OnPointer (handled by OnMouseMove)") + if !root.eventCalled { + t.Error("PointerMove should dispatch via unified pipeline as derived MouseMove") + } + me, ok := root.lastEvent.(*event.MouseEvent) + if !ok { + t.Fatal("expected MouseEvent") + } + if me.MouseType != event.MouseMove { + t.Errorf("mouse type = %v, want Move", me.MouseType) } } diff --git a/app/gesture_bridge.go b/app/gesture_bridge.go new file mode 100644 index 0000000..2068b8f --- /dev/null +++ b/app/gesture_bridge.go @@ -0,0 +1,158 @@ +package app + +import ( + "time" + + "github.com/gogpu/gpucontext" + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" +) + +// convertPointerEvent converts a gpucontext.PointerEvent to a gesture.PointerEvent. +// +// This maps W3C Pointer Events Level 3 fields from the platform layer into the +// ui-level gesture.PointerEvent that all gesture recognizers consume. +// +// Enter and Leave events are not gesture events (they have no pointer lifecycle); +// they are handled by the existing attachPointerBridge. This function returns +// a zero event and false for those types. +func convertPointerEvent(pev gpucontext.PointerEvent) (gesture.PointerEvent, bool) { + var evType gesture.PointerEventType + switch pev.Type { + case gpucontext.PointerDown: + evType = gesture.PointerDown + case gpucontext.PointerUp: + evType = gesture.PointerUp + case gpucontext.PointerMove: + evType = gesture.PointerMove + case gpucontext.PointerCancel: + evType = gesture.PointerCancel + default: + // Enter/Leave are not gesture events. + return gesture.PointerEvent{}, false + } + + pos := geometry.Pt(float32(pev.X), float32(pev.Y)) + + gev := gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, translateModifiers(pev.Modifiers)), + EventType: evType, + PointerID: pev.PointerID, + PointerType: convertPointerType(pev.PointerType), + Position: pos, + GlobalPosition: pos, // widget-relative adjusted during dispatch + Pressure: pev.Pressure, + TiltX: pev.TiltX, + TiltY: pev.TiltY, + Twist: pev.Twist, + ContactWidth: pev.Width, + ContactHeight: pev.Height, + Button: convertPointerButton(pev.Button), + Buttons: convertPointerButtons(pev.Buttons), + Delta: geometry.Pt(float32(pev.DeltaX), float32(pev.DeltaY)), + Timestamp: pev.Timestamp, + } + + return gev, true +} + +// synthesizePointerEvent creates a gesture.PointerEvent from legacy mouse callback +// data. Used when the platform does not provide PointerEventSource, so gesture +// recognition still works with mouse-only platforms. +// +// The synthesized event uses PointerID=1 (mouse is always a single pointer), +// PointerTypeMouse, default pressure (0.5 when pressed, 0.0 otherwise), and +// time.Now() as timestamp (since legacy callbacks do not provide timestamps). +func synthesizePointerEvent( + mouseType event.MouseEventType, + btn event.Button, + buttons event.ButtonState, + pos geometry.Point, + mods event.Modifiers, +) gesture.PointerEvent { + var evType gesture.PointerEventType + switch mouseType { + case event.MousePress: + evType = gesture.PointerDown + case event.MouseRelease: + evType = gesture.PointerUp + case event.MouseMove, event.MouseDrag: + evType = gesture.PointerMove + default: + // Enter, Leave, DoubleClick are not gesture events. + return gesture.PointerEvent{} + } + + // Synthesize pressure: 0.5 when any button pressed (W3C mouse default). + var pressure float32 + if buttons != 0 || mouseType == event.MousePress { + pressure = 0.5 + } + + return gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, mods), + EventType: evType, + PointerID: 1, // Mouse always uses pointer ID 1. + PointerType: gesture.PointerTypeMouse, + Position: pos, + GlobalPosition: pos, + Pressure: pressure, + ContactWidth: 1.0, // Default for mouse. + ContactHeight: 1.0, + Button: btn, + Buttons: buttons, + Timestamp: time.Duration(time.Now().UnixNano()), + } +} + +// convertPointerType maps gpucontext.PointerType to gesture.PointerType. +func convertPointerType(pt gpucontext.PointerType) gesture.PointerType { + switch pt { + case gpucontext.PointerTypeTouch: + return gesture.PointerTypeTouch + case gpucontext.PointerTypePen: + return gesture.PointerTypePen + default: + return gesture.PointerTypeMouse + } +} + +// convertPointerButton maps gpucontext.Button to event.Button. +func convertPointerButton(btn gpucontext.Button) event.Button { + switch btn { + case gpucontext.ButtonLeft: + return event.ButtonLeft + case gpucontext.ButtonRight: + return event.ButtonRight + case gpucontext.ButtonMiddle: + return event.ButtonMiddle + case gpucontext.ButtonX1: + return event.ButtonX1 + case gpucontext.ButtonX2: + return event.ButtonX2 + default: + return event.ButtonNone + } +} + +// convertPointerButtons maps gpucontext.Buttons bitmask to event.ButtonState. +func convertPointerButtons(btns gpucontext.Buttons) event.ButtonState { + var state event.ButtonState + if btns.HasLeft() { + state |= event.ButtonStateLeft + } + if btns.HasRight() { + state |= event.ButtonStateRight + } + if btns.HasMiddle() { + state |= event.ButtonStateMiddle + } + if btns.HasX1() { + state |= event.ButtonStateX1 + } + if btns.HasX2() { + state |= event.ButtonStateX2 + } + return state +} diff --git a/app/gesture_bridge_test.go b/app/gesture_bridge_test.go new file mode 100644 index 0000000..263156c --- /dev/null +++ b/app/gesture_bridge_test.go @@ -0,0 +1,314 @@ +package app + +import ( + "testing" + "time" + + "github.com/gogpu/gpucontext" + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" +) + +func TestConvertPointerEvent_AllTypes(t *testing.T) { + tests := []struct { + name string + inType gpucontext.PointerEventType + wantType gesture.PointerEventType + wantOK bool + }{ + {"Down", gpucontext.PointerDown, gesture.PointerDown, true}, + {"Up", gpucontext.PointerUp, gesture.PointerUp, true}, + {"Move", gpucontext.PointerMove, gesture.PointerMove, true}, + {"Cancel", gpucontext.PointerCancel, gesture.PointerCancel, true}, + {"Enter", gpucontext.PointerEnter, 0, false}, + {"Leave", gpucontext.PointerLeave, 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pev := gpucontext.PointerEvent{ + Type: tt.inType, + PointerID: 42, + X: 100.0, + Y: 200.0, + } + gev, ok := convertPointerEvent(pev) + if ok != tt.wantOK { + t.Fatalf("convertPointerEvent ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if gev.EventType != tt.wantType { + t.Errorf("EventType = %v, want %v", gev.EventType, tt.wantType) + } + if gev.PointerID != 42 { + t.Errorf("PointerID = %d, want 42", gev.PointerID) + } + }) + } +} + +func TestConvertPointerEvent_Position(t *testing.T) { + pev := gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, + X: 150.5, + Y: 250.75, + } + gev, ok := convertPointerEvent(pev) + if !ok { + t.Fatal("convertPointerEvent returned false") + } + wantPos := geometry.Pt(150.5, 250.75) + if gev.Position != wantPos { + t.Errorf("Position = %v, want %v", gev.Position, wantPos) + } + if gev.GlobalPosition != wantPos { + t.Errorf("GlobalPosition = %v, want %v", gev.GlobalPosition, wantPos) + } +} + +func TestConvertPointerEvent_RichFields(t *testing.T) { + ts := 500 * time.Millisecond + pev := gpucontext.PointerEvent{ + Type: gpucontext.PointerDown, + PointerID: 3, + X: 10.0, + Y: 20.0, + Pressure: 0.75, + TiltX: 15.0, + TiltY: -10.0, + Twist: 45.0, + Width: 5.0, + Height: 8.0, + PointerType: gpucontext.PointerTypePen, + Button: gpucontext.ButtonLeft, + Buttons: gpucontext.ButtonsLeft, + DeltaX: 1.5, + DeltaY: -2.5, + Timestamp: ts, + Modifiers: gpucontext.ModShift, + } + + gev, ok := convertPointerEvent(pev) + if !ok { + t.Fatal("convertPointerEvent returned false") + } + + if gev.PointerID != 3 { + t.Errorf("PointerID = %d, want 3", gev.PointerID) + } + if gev.PointerType != gesture.PointerTypePen { + t.Errorf("PointerType = %v, want Pen", gev.PointerType) + } + if gev.Pressure != 0.75 { + t.Errorf("Pressure = %v, want 0.75", gev.Pressure) + } + if gev.TiltX != 15.0 { + t.Errorf("TiltX = %v, want 15.0", gev.TiltX) + } + if gev.TiltY != -10.0 { + t.Errorf("TiltY = %v, want -10.0", gev.TiltY) + } + if gev.Twist != 45.0 { + t.Errorf("Twist = %v, want 45.0", gev.Twist) + } + if gev.ContactWidth != 5.0 { + t.Errorf("ContactWidth = %v, want 5.0", gev.ContactWidth) + } + if gev.ContactHeight != 8.0 { + t.Errorf("ContactHeight = %v, want 8.0", gev.ContactHeight) + } + if gev.Button != event.ButtonLeft { + t.Errorf("Button = %v, want Left", gev.Button) + } + if !gev.Buttons.IsLeftPressed() { + t.Error("Buttons should have Left pressed") + } + if gev.Delta.X != 1.5 || gev.Delta.Y != -2.5 { + t.Errorf("Delta = %v, want (1.5, -2.5)", gev.Delta) + } + if gev.Timestamp != ts { + t.Errorf("Timestamp = %v, want %v", gev.Timestamp, ts) + } + if !gev.Modifiers().IsShift() { + t.Error("expected Shift modifier") + } +} + +func TestConvertPointerType(t *testing.T) { + tests := []struct { + name string + in gpucontext.PointerType + want gesture.PointerType + }{ + {"Mouse", gpucontext.PointerTypeMouse, gesture.PointerTypeMouse}, + {"Touch", gpucontext.PointerTypeTouch, gesture.PointerTypeTouch}, + {"Pen", gpucontext.PointerTypePen, gesture.PointerTypePen}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertPointerType(tt.in) + if got != tt.want { + t.Errorf("convertPointerType(%v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestConvertPointerButton(t *testing.T) { + tests := []struct { + name string + in gpucontext.Button + want event.Button + }{ + {"Left", gpucontext.ButtonLeft, event.ButtonLeft}, + {"Right", gpucontext.ButtonRight, event.ButtonRight}, + {"Middle", gpucontext.ButtonMiddle, event.ButtonMiddle}, + {"X1", gpucontext.ButtonX1, event.ButtonX1}, + {"X2", gpucontext.ButtonX2, event.ButtonX2}, + {"None", gpucontext.ButtonNone, event.ButtonNone}, + {"Eraser", gpucontext.ButtonEraser, event.ButtonNone}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertPointerButton(tt.in) + if got != tt.want { + t.Errorf("convertPointerButton(%v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestConvertPointerButtons(t *testing.T) { + tests := []struct { + name string + in gpucontext.Buttons + want event.ButtonState + }{ + {"None", gpucontext.ButtonsNone, 0}, + {"Left", gpucontext.ButtonsLeft, event.ButtonStateLeft}, + {"Right", gpucontext.ButtonsRight, event.ButtonStateRight}, + {"Middle", gpucontext.ButtonsMiddle, event.ButtonStateMiddle}, + {"X1", gpucontext.ButtonsX1, event.ButtonStateX1}, + {"X2", gpucontext.ButtonsX2, event.ButtonStateX2}, + {"LeftRight", gpucontext.ButtonsLeft | gpucontext.ButtonsRight, + event.ButtonStateLeft | event.ButtonStateRight}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertPointerButtons(tt.in) + if got != tt.want { + t.Errorf("convertPointerButtons(%v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestSynthesizePointerEvent_Press(t *testing.T) { + pos := geometry.Pt(100, 200) + gev := synthesizePointerEvent( + event.MousePress, event.ButtonLeft, event.ButtonStateLeft, + pos, event.ModShift, + ) + + if gev.EventType != gesture.PointerDown { + t.Errorf("EventType = %v, want PointerDown", gev.EventType) + } + if gev.PointerID != 1 { + t.Errorf("PointerID = %d, want 1", gev.PointerID) + } + if gev.PointerType != gesture.PointerTypeMouse { + t.Errorf("PointerType = %v, want Mouse", gev.PointerType) + } + if gev.Position != pos { + t.Errorf("Position = %v, want %v", gev.Position, pos) + } + if gev.Pressure != 0.5 { + t.Errorf("Pressure = %v, want 0.5", gev.Pressure) + } + if gev.Button != event.ButtonLeft { + t.Errorf("Button = %v, want Left", gev.Button) + } + if !gev.Buttons.IsLeftPressed() { + t.Error("Buttons should have Left pressed") + } + if gev.Timestamp == 0 { + t.Error("Timestamp should be non-zero for synthesized event") + } + if !gev.Modifiers().IsShift() { + t.Error("expected Shift modifier") + } +} + +func TestSynthesizePointerEvent_Release(t *testing.T) { + pos := geometry.Pt(50, 60) + gev := synthesizePointerEvent( + event.MouseRelease, event.ButtonLeft, 0, + pos, event.ModNone, + ) + + if gev.EventType != gesture.PointerUp { + t.Errorf("EventType = %v, want PointerUp", gev.EventType) + } + if gev.Pressure != 0.0 { + t.Errorf("Pressure = %v, want 0.0 (no buttons pressed)", gev.Pressure) + } +} + +func TestSynthesizePointerEvent_Move(t *testing.T) { + pos := geometry.Pt(75, 80) + gev := synthesizePointerEvent( + event.MouseMove, event.ButtonNone, 0, + pos, event.ModNone, + ) + + if gev.EventType != gesture.PointerMove { + t.Errorf("EventType = %v, want PointerMove", gev.EventType) + } +} + +func TestSynthesizePointerEvent_Drag(t *testing.T) { + pos := geometry.Pt(120, 130) + gev := synthesizePointerEvent( + event.MouseDrag, event.ButtonLeft, event.ButtonStateLeft, + pos, event.ModNone, + ) + + if gev.EventType != gesture.PointerMove { + t.Errorf("EventType = %v, want PointerMove (drag maps to move)", gev.EventType) + } + if gev.Pressure != 0.5 { + t.Errorf("Pressure = %v, want 0.5 (button pressed during drag)", gev.Pressure) + } +} + +func TestSynthesizePointerEvent_NonGestureTypes(t *testing.T) { + // Enter, Leave, DoubleClick should produce zero-value events. + for _, mt := range []event.MouseEventType{ + event.MouseEnter, event.MouseLeave, event.MouseDoubleClick, + } { + gev := synthesizePointerEvent(mt, event.ButtonNone, 0, geometry.Point{}, event.ModNone) + if gev.EventType != 0 { + t.Errorf("synthesizePointerEvent(%v) EventType = %v, want 0", mt, gev.EventType) + } + } +} + +func TestSynthesizePointerEvent_ContactDefaults(t *testing.T) { + gev := synthesizePointerEvent( + event.MousePress, event.ButtonLeft, event.ButtonStateLeft, + geometry.Pt(10, 10), event.ModNone, + ) + + if gev.ContactWidth != 1.0 { + t.Errorf("ContactWidth = %v, want 1.0", gev.ContactWidth) + } + if gev.ContactHeight != 1.0 { + t.Errorf("ContactHeight = %v, want 1.0", gev.ContactHeight) + } +} diff --git a/app/window.go b/app/window.go index 5c85d3e..27c20ce 100644 --- a/app/window.go +++ b/app/window.go @@ -11,6 +11,7 @@ import ( "github.com/gogpu/ui/dnd" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/internal/dirty" ifocus "github.com/gogpu/ui/internal/focus" internalRender "github.com/gogpu/ui/internal/render" @@ -165,6 +166,12 @@ type Window struct { // Used by both internal widget-to-widget drags and OS file drops // bridged via desktop.Run's OnDragDrop callback. dndManager *dnd.Manager + + // gestureArena manages gesture disambiguation for all pointers. + // Created lazily on first HandlePointerEvent call to avoid allocation + // overhead for windows with no GestureAware widgets. One arena per + // window is the Flutter pattern (GestureBinding.gestureArena). + gestureArena *gesture.Arena } // newWindow creates a Window with the given providers. @@ -1306,6 +1313,146 @@ func (w *Window) DndManager() *dnd.Manager { return w.dndManager } +// HandlePointerEvent is the unified entry point for all pointer input +// (ADR-049 Phase 3). +// +// It performs TWO functions: +// 1. Feed the gesture arena: collect GestureAware recognizers on PointerDown, +// route subsequent events, sweep on PointerUp. +// 2. Derive MouseEvent: synthesize a legacy MouseEvent from the PointerEvent +// and dispatch it to the widget tree via HandleEvent, so existing widgets +// continue to work unchanged. +// +// This replaces the previous dual-dispatch architecture where legacy mouse +// callbacks and pointer events were dispatched independently. +func (w *Window) HandlePointerEvent(ev *gesture.PointerEvent) { + if w.root == nil || ev == nil { + return + } + + // Lazy arena creation. + if w.gestureArena == nil { + w.gestureArena = gesture.NewArena() + } + + // --- Part 1: Gesture Arena --- + switch ev.EventType { + case gesture.PointerDown: + // Hit-test: find all GestureAware widgets under the pointer. + // Collect their recognizers and add them to the arena. + recognizers := w.hitTestGestureAware(ev.GlobalPosition) + for _, rec := range recognizers { + rec.AddPointer(ev, w.gestureArena) + } + // Close the arena for this pointer — no more members can join. + w.gestureArena.Close(ev.PointerID) + + case gesture.PointerMove, gesture.PointerUp, gesture.PointerCancel: + // Route to all recognizers tracking this pointer. + w.gestureArena.Route(ev) + } + + // Sweep after PointerUp: if no recognizer has claimed victory, + // the first remaining member wins by default. + if ev.EventType == gesture.PointerUp { + w.gestureArena.Sweep(ev.PointerID) + } + + // --- Part 2: Derive MouseEvent for existing widget dispatch --- + derived := deriveMouseEvent(ev) + if derived != nil { + w.HandleEvent(derived) + } +} + +// deriveMouseEvent synthesizes a legacy MouseEvent from a gesture.PointerEvent +// so that existing widgets continue to receive mouse events unchanged. +// +// Mapping: +// - PointerDown -> MousePress +// - PointerUp -> MouseRelease +// - PointerMove -> MouseMove +// - PointerCancel -> (no MouseEvent derived — cancel is gesture-only) +func deriveMouseEvent(ev *gesture.PointerEvent) *event.MouseEvent { + var mouseType event.MouseEventType + switch ev.EventType { + case gesture.PointerDown: + mouseType = event.MousePress + case gesture.PointerUp: + mouseType = event.MouseRelease + case gesture.PointerMove: + mouseType = event.MouseMove + default: + // PointerCancel has no legacy MouseEvent equivalent. + return nil + } + + return event.NewMouseEvent( + mouseType, + ev.Button, + ev.Buttons, + ev.Position, + ev.GlobalPosition, + ev.Modifiers(), + ) +} + +// GestureArena returns the window's gesture arena, or nil if no pointer +// events have been processed yet. Exposed for testing. +func (w *Window) GestureArena() *gesture.Arena { + return w.gestureArena +} + +// hitTestGestureAware walks the widget tree from root, collecting +// recognizers from all GestureAware widgets whose ScreenBounds contain +// the given position. Children are checked in reverse order (topmost +// first in z-order) to match visual ordering. +// +// This is a separate hit-test from overlayAwareHitTest (used for hover): +// gesture hit-testing collects ALL matching widgets on the path (not just +// the deepest one), because parent and child may both have recognizers +// that compete in the arena. +func (w *Window) hitTestGestureAware(pos geometry.Point) []gesture.Recognizer { + var recognizers []gesture.Recognizer + hitTestGestureRecursive(w.root, pos, &recognizers) + return recognizers +} + +// hitTestGestureRecursive walks the widget tree depth-first, collecting +// recognizers from GestureAware widgets that contain the point. +func hitTestGestureRecursive(w widget.Widget, pos geometry.Point, out *[]gesture.Recognizer) { + if w == nil { + return + } + + // Check visibility. + if base, ok := w.(interface{ IsVisible() bool }); ok && !base.IsVisible() { + return + } + + // Check if the widget's ScreenBounds contains the position. + if sb, ok := w.(interface{ ScreenBounds() geometry.Rect }); ok { + bounds := sb.ScreenBounds() + if !bounds.Contains(pos) { + return + } + } + + // Collect recognizers from GestureAware widgets. + if ga, ok := w.(gesture.GestureAware); ok { + recs := ga.GestureRecognizers() + if len(recs) > 0 { + *out = append(*out, recs...) + } + } + + // Recurse into children (reverse order for z-order consistency). + children := w.Children() + for i := len(children) - 1; i >= 0; i-- { + hitTestGestureRecursive(children[i], pos, out) + } +} + // windowOverlayManager adapts the Window's overlay.Stack to the // widget.OverlayManager interface. This avoids circular imports since // the widget package cannot import the overlay package. diff --git a/app/window_gesture_test.go b/app/window_gesture_test.go new file mode 100644 index 0000000..392ea30 --- /dev/null +++ b/app/window_gesture_test.go @@ -0,0 +1,554 @@ +package app + +import ( + "testing" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" + "github.com/gogpu/ui/widget" +) + +// gestureAwareMock is a widget that implements gesture.GestureAware. +type gestureAwareMock struct { + widget.WidgetBase + recognizers []gesture.Recognizer + layoutSize geometry.Size + layoutCalled bool + drawCalled bool + eventCalled bool + lastEvent event.Event + eventCallback func(event.Event) // optional callback for event sequence tracking +} + +func newGestureAwareMock(recs ...gesture.Recognizer) *gestureAwareMock { + m := &gestureAwareMock{ + recognizers: recs, + layoutSize: geometry.Sz(100, 100), + } + m.SetVisible(true) + m.SetEnabled(true) + return m +} + +func (m *gestureAwareMock) GestureRecognizers() []gesture.Recognizer { + return m.recognizers +} + +func (m *gestureAwareMock) Layout(_ widget.Context, constraints geometry.Constraints) geometry.Size { + m.layoutCalled = true + return constraints.Constrain(m.layoutSize) +} + +func (m *gestureAwareMock) Draw(_ widget.Context, _ widget.Canvas) { + m.drawCalled = true +} + +func (m *gestureAwareMock) Event(_ widget.Context, e event.Event) bool { + m.eventCalled = true + m.lastEvent = e + if m.eventCallback != nil { + m.eventCallback(e) + } + return false +} + +// Compile-time check. +var _ gesture.GestureAware = (*gestureAwareMock)(nil) + +// --- Tests --- + +func TestWindow_HandlePointerEvent_NilRoot(t *testing.T) { + a := New() + w := a.Window() + + ev := &gesture.PointerEvent{ + EventType: gesture.PointerDown, + PointerID: 1, + Position: geometry.Pt(50, 50), + } + // Should not panic. + w.HandlePointerEvent(ev) +} + +func TestWindow_HandlePointerEvent_NilEvent(t *testing.T) { + a := New() + w := a.Window() + root := newMockWidget() + w.SetRoot(root) + + // Should not panic. + w.HandlePointerEvent(nil) +} + +func TestWindow_GestureArena_LazyCreation(t *testing.T) { + a := New() + w := a.Window() + root := newMockWidget() + w.SetRoot(root) + + // Arena should be nil before any pointer events. + if w.GestureArena() != nil { + t.Error("arena should be nil before pointer events") + } + + // First pointer event creates the arena. + ev := &gesture.PointerEvent{ + EventType: gesture.PointerDown, + PointerID: 1, + Position: geometry.Pt(50, 50), + } + w.HandlePointerEvent(ev) + + if w.GestureArena() == nil { + t.Error("arena should be created after first pointer event") + } +} + +func TestWindow_HandlePointerEvent_NoGestureAwareWidgets(t *testing.T) { + a := New() + w := a.Window() + // Regular mock widget (not GestureAware). + root := newMockWidget() + w.SetRoot(root) + w.Frame() // layout so ScreenBounds are set + + ev := &gesture.PointerEvent{ + EventType: gesture.PointerDown, + PointerID: 1, + Position: geometry.Pt(50, 50), + GlobalPosition: geometry.Pt(50, 50), + } + // Should not panic — no recognizers collected, arena closes empty. + w.HandlePointerEvent(ev) + + // Legacy event dispatch should still work. + me := event.NewMouseEvent(event.MousePress, event.ButtonLeft, + event.ButtonStateLeft, geometry.Pt(50, 50), geometry.Pt(50, 50), event.ModNone) + w.HandleEvent(me) + if !root.eventCalled { + t.Error("legacy HandleEvent should still work when no GestureAware widgets") + } +} + +func TestWindow_HandlePointerEvent_GestureAwareWidgetHitTest(t *testing.T) { + a := New() + w := a.Window() + + var clickFired bool + click := gesture.NewClickRecognizer(gesture.ClickConfig{ + OnClick: func(_ gesture.ClickDetails) { + clickFired = true + }, + }) + + root := newGestureAwareMock(click) + root.layoutSize = geometry.Sz(200, 200) + w.SetRoot(root) + w.Frame() // layout + + // Set bounds and screen origin for hit-testing. + // In headless mode Frame() performs layout but not Draw, so + // ScreenOrigin (set during Draw) must be manually applied. + root.SetBounds(geometry.NewRect(0, 0, 200, 200)) + root.SetScreenOrigin(geometry.Pt(0, 0)) + + // PointerDown at a point inside the widget. + down := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerDown, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(100, 100), + GlobalPosition: geometry.Pt(100, 100), + Button: event.ButtonLeft, + Buttons: event.ButtonStateLeft, + } + w.HandlePointerEvent(down) + + // PointerUp at the same point. + up := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerUp, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(100, 100), + GlobalPosition: geometry.Pt(100, 100), + Button: event.ButtonLeft, + Buttons: 0, + } + w.HandlePointerEvent(up) + + if !clickFired { + t.Error("ClickRecognizer.OnClick should have fired after down+up on GestureAware widget") + } +} + +func TestWindow_HandlePointerEvent_OutsideBoundsNotHit(t *testing.T) { + a := New() + w := a.Window() + + var clickFired bool + click := gesture.NewClickRecognizer(gesture.ClickConfig{ + OnClick: func(_ gesture.ClickDetails) { + clickFired = true + }, + }) + + root := newGestureAwareMock(click) + root.layoutSize = geometry.Sz(100, 100) + w.SetRoot(root) + w.Frame() + root.SetBounds(geometry.NewRect(0, 0, 100, 100)) + root.SetScreenOrigin(geometry.Pt(0, 0)) + + // PointerDown OUTSIDE the widget bounds (widget is 100x100). + down := &gesture.PointerEvent{ + EventType: gesture.PointerDown, + PointerID: 1, + Position: geometry.Pt(200, 200), + GlobalPosition: geometry.Pt(200, 200), + Button: event.ButtonLeft, + } + w.HandlePointerEvent(down) + + up := &gesture.PointerEvent{ + EventType: gesture.PointerUp, + PointerID: 1, + Position: geometry.Pt(200, 200), + GlobalPosition: geometry.Pt(200, 200), + } + w.HandlePointerEvent(up) + + if clickFired { + t.Error("click should not fire when pointer is outside widget bounds") + } +} + +// containerGestureMock holds child widgets and implements GestureAware. +type containerGestureMock struct { + widget.WidgetBase + recognizers []gesture.Recognizer +} + +func newContainerGestureMock(children []widget.Widget, recs ...gesture.Recognizer) *containerGestureMock { + m := &containerGestureMock{recognizers: recs} + m.SetVisible(true) + m.SetEnabled(true) + for _, c := range children { + m.AddChild(c) + } + return m +} + +func (m *containerGestureMock) GestureRecognizers() []gesture.Recognizer { + return m.recognizers +} + +func (m *containerGestureMock) Layout(_ widget.Context, constraints geometry.Constraints) geometry.Size { + return constraints.Constrain(geometry.Sz(300, 300)) +} + +func (m *containerGestureMock) Draw(_ widget.Context, _ widget.Canvas) {} + +func (m *containerGestureMock) Event(_ widget.Context, _ event.Event) bool { + return false +} + +func TestWindow_HandlePointerEvent_NestedGestureAwareWidgets(t *testing.T) { + a := New() + w := a.Window() + + var parentClicked, childClicked bool + + parentClick := gesture.NewClickRecognizer(gesture.ClickConfig{ + OnClick: func(_ gesture.ClickDetails) { + parentClicked = true + }, + }) + childClick := gesture.NewClickRecognizer(gesture.ClickConfig{ + OnClick: func(_ gesture.ClickDetails) { + childClicked = true + }, + }) + + child := newGestureAwareMock(childClick) + child.layoutSize = geometry.Sz(50, 50) + + parent := newContainerGestureMock([]widget.Widget{child}, parentClick) + + w.SetRoot(parent) + w.Frame() + + // Set bounds and screen origins for hit-testing. + parent.SetBounds(geometry.NewRect(0, 0, 300, 300)) + parent.SetScreenOrigin(geometry.Pt(0, 0)) + child.SetBounds(geometry.NewRect(10, 10, 60, 60)) + child.SetScreenOrigin(geometry.Pt(10, 10)) + + // PointerDown inside the child (which is inside the parent). + down := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerDown, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(30, 30), + GlobalPosition: geometry.Pt(30, 30), + Button: event.ButtonLeft, + Buttons: event.ButtonStateLeft, + } + w.HandlePointerEvent(down) + + // Both parent and child recognizers should be in the arena. + // The arena auto-resolves: with 2 members, Close() does not auto-pick. + // On PointerUp, Sweep selects the first member. + + up := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerUp, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(30, 30), + GlobalPosition: geometry.Pt(30, 30), + Button: event.ButtonLeft, + Buttons: 0, + } + w.HandlePointerEvent(up) + + // At least one of them should have been accepted (sweep picks first). + if !parentClicked && !childClicked { + t.Error("at least one click recognizer should fire for nested GestureAware widgets") + } +} + +func TestWindow_HandlePointerEvent_MoveRoutedToArena(t *testing.T) { + a := New() + w := a.Window() + + var dragStarted bool + drag := gesture.NewDragRecognizer(gesture.DragConfig{ + OnDragStart: func(_ gesture.DragStartDetails) { + dragStarted = true + }, + OnDragUpdate: func(_ gesture.DragUpdateDetails) {}, + OnDragEnd: func(_ gesture.DragEndDetails) {}, + }) + + root := newGestureAwareMock(drag) + root.layoutSize = geometry.Sz(400, 400) + w.SetRoot(root) + w.Frame() + root.SetBounds(geometry.NewRect(0, 0, 400, 400)) + root.SetScreenOrigin(geometry.Pt(0, 0)) + + // PointerDown. + down := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerDown, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(100, 100), + GlobalPosition: geometry.Pt(100, 100), + Button: event.ButtonLeft, + Buttons: event.ButtonStateLeft, + } + w.HandlePointerEvent(down) + + // PointerMove beyond slop (PrecisePointerSlop = 1px for mouse). + move := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerMove, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(110, 110), + GlobalPosition: geometry.Pt(110, 110), + Buttons: event.ButtonStateLeft, + } + w.HandlePointerEvent(move) + + if !dragStarted { + t.Error("DragRecognizer.OnDragStart should fire after move beyond slop") + } + + // PointerUp ends the drag. + up := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerUp, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(110, 110), + GlobalPosition: geometry.Pt(110, 110), + Buttons: 0, + } + w.HandlePointerEvent(up) +} + +func TestWindow_HandlePointerEvent_LegacyWidgetsUnaffected(t *testing.T) { + a := New() + w := a.Window() + + // Root is a plain widget (NOT GestureAware). + root := newMockWidget() + w.SetRoot(root) + w.Frame() + + // HandlePointerEvent should not break anything. + down := &gesture.PointerEvent{ + EventType: gesture.PointerDown, + PointerID: 1, + Position: geometry.Pt(50, 50), + GlobalPosition: geometry.Pt(50, 50), + } + w.HandlePointerEvent(down) + + // Legacy event dispatch still works. + me := event.NewMouseEvent(event.MousePress, event.ButtonLeft, + event.ButtonStateLeft, geometry.Pt(50, 50), geometry.Pt(50, 50), event.ModNone) + w.HandleEvent(me) + + if !root.eventCalled { + t.Error("legacy HandleEvent must still work for non-GestureAware widgets") + } +} + +func TestWindow_HandlePointerEvent_CancelRejectsAll(t *testing.T) { + a := New() + w := a.Window() + + var cancelCalled bool + click := gesture.NewClickRecognizer(gesture.ClickConfig{ + OnClickCancel: func() { + cancelCalled = true + }, + }) + + root := newGestureAwareMock(click) + root.layoutSize = geometry.Sz(200, 200) + w.SetRoot(root) + w.Frame() + root.SetBounds(geometry.NewRect(0, 0, 200, 200)) + root.SetScreenOrigin(geometry.Pt(0, 0)) + + // PointerDown. + down := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerDown, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(100, 100), + GlobalPosition: geometry.Pt(100, 100), + Button: event.ButtonLeft, + Buttons: event.ButtonStateLeft, + } + w.HandlePointerEvent(down) + + // PointerCancel. + cancel := &gesture.PointerEvent{ + EventType: gesture.PointerCancel, + PointerID: 1, + } + w.HandlePointerEvent(cancel) + + // After cancel, sweep should do nothing (no members or resolved). + // The recognizer's handleCancel should have been called via Route. + if !cancelCalled { + t.Error("ClickRecognizer.OnClickCancel should fire after PointerCancel") + } +} + +func TestHitTestGestureAware_InvisibleWidgetSkipped(t *testing.T) { + a := New() + w := a.Window() + + click := gesture.NewClickRecognizer(gesture.ClickConfig{}) + root := newGestureAwareMock(click) + root.layoutSize = geometry.Sz(200, 200) + root.SetVisible(false) // Invisible! + w.SetRoot(root) + w.Frame() + root.SetBounds(geometry.NewRect(0, 0, 200, 200)) + root.SetScreenOrigin(geometry.Pt(0, 0)) + + recs := w.hitTestGestureAware(geometry.Pt(100, 100)) + if len(recs) != 0 { + t.Errorf("hitTestGestureAware returned %d recognizers for invisible widget, want 0", len(recs)) + } +} + +// TestWindow_GestureAndDerived_DerivedMouseEventFiresAfterGesture verifies that +// the derived MouseEvent from HandlePointerEvent Part 2 still reaches the widget +// AFTER the gesture arena processes in Part 1. This is the integration test for +// the race condition fix: gesture callbacks must not corrupt state that derived +// MouseEvent handlers depend on. +func TestWindow_GestureAndDerived_DerivedMouseEventFiresAfterGesture(t *testing.T) { + a := New() + w := a.Window() + + // Track the sequence of calls. + var sequence []string + + click := gesture.NewClickRecognizer(gesture.ClickConfig{ + OnClickDown: func(_ gesture.ClickDownDetails) { + sequence = append(sequence, "gesture:down") + }, + OnClick: func(_ gesture.ClickDetails) { + sequence = append(sequence, "gesture:click") + }, + }) + + root := newGestureAwareMock(click) + root.layoutSize = geometry.Sz(200, 200) + // Override Event to track derived events. + root.eventCallback = func(e event.Event) { + if me, ok := e.(*event.MouseEvent); ok { + sequence = append(sequence, "derived:"+me.MouseType.String()) + } + } + w.SetRoot(root) + w.Frame() + root.SetBounds(geometry.NewRect(0, 0, 200, 200)) + root.SetScreenOrigin(geometry.Pt(0, 0)) + + // PointerDown. + down := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerDown, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(100, 100), + GlobalPosition: geometry.Pt(100, 100), + Button: event.ButtonLeft, + Buttons: event.ButtonStateLeft, + } + w.HandlePointerEvent(down) + + // PointerUp. + up := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerUp, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: geometry.Pt(100, 100), + GlobalPosition: geometry.Pt(100, 100), + Button: event.ButtonLeft, + Buttons: 0, + } + w.HandlePointerEvent(up) + + // Verify ordering: gesture callbacks fire in Part 1, derived events in Part 2. + // Expected sequence: + // 1. gesture:down (Part 1: AddPointer -> OnClickDown) + // 2. derived:Enter (Part 2: trackMouseButtonOwnership -> updateHover on first press) + // 3. derived:Press (Part 2: derived MousePress) + // 4. gesture:click (Part 1: Route -> handleUp -> fireClick) + // 5. derived:Release (Part 2: derived MouseRelease) + expected := []string{"gesture:down", "derived:Enter", "derived:Press", "gesture:click", "derived:Release"} + if len(sequence) != len(expected) { + t.Fatalf("sequence length = %d, want %d\nsequence: %v", len(sequence), len(expected), sequence) + } + for i, exp := range expected { + if sequence[i] != exp { + t.Errorf("sequence[%d] = %q, want %q\nfull sequence: %v", i, sequence[i], exp, sequence) + } + } +} diff --git a/core/button/widget.go b/core/button/widget.go index be304fa..1c1384a 100644 --- a/core/button/widget.go +++ b/core/button/widget.go @@ -3,6 +3,7 @@ package button import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -35,6 +36,9 @@ type Widget struct { state interactionState painter Painter + // Gesture recognizer for click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Styling overrides set via fluent methods. paddingX float32 paddingY float32 @@ -67,6 +71,30 @@ func New(opts ...Option) *Widget { w.painter = w.cfg.painter } + // Create ClickRecognizer for unified pointer pipeline (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClickDown: func(details gesture.ClickDownDetails) { + if details.Button != event.ButtonLeft { + return + } + w.state = statePressed + w.SetNeedsRedraw(true) + }, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + w.state = stateNormal + w.SetNeedsRedraw(true) + fireOnClick(w) + }, + OnClickCancel: func() { + w.state = stateNormal + w.SetNeedsRedraw(true) + }, + }) + return w } @@ -171,12 +199,25 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the button is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/checkbox/widget.go b/core/checkbox/widget.go index 4be333f..43b2dd5 100644 --- a/core/checkbox/widget.go +++ b/core/checkbox/widget.go @@ -3,6 +3,7 @@ package checkbox import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -35,6 +36,9 @@ type Widget struct { state interactionState painter Painter + // Gesture recognizer for click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Styling overrides set via fluent methods. padding float32 } @@ -60,6 +64,30 @@ func New(opts ...Option) *Widget { w.painter = w.cfg.painter } + // Create ClickRecognizer for unified pointer pipeline (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClickDown: func(details gesture.ClickDownDetails) { + if details.Button != event.ButtonLeft { + return + } + w.state = statePressed + w.SetNeedsRedraw(true) + }, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + w.state = stateNormal + w.SetNeedsRedraw(true) + fireToggle(w) + }, + OnClickCancel: func() { + w.state = stateNormal + w.SetNeedsRedraw(true) + }, + }) + return w } @@ -162,12 +190,25 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the checkbox is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/chip/chip.go b/core/chip/chip.go index 757bfd4..e0662e7 100644 --- a/core/chip/chip.go +++ b/core/chip/chip.go @@ -3,6 +3,7 @@ package chip import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -34,6 +35,9 @@ type Widget struct { state interactionState painter Painter + // Gesture recognizer for click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Styling overrides set via fluent methods. padding float32 } @@ -56,6 +60,30 @@ func New(opts ...Option) *Widget { w.painter = w.cfg.painter } + // Create ClickRecognizer for unified pointer pipeline (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClickDown: func(details gesture.ClickDownDetails) { + if details.Button != event.ButtonLeft { + return + } + w.state = statePressed + w.SetNeedsRedraw(true) + }, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + w.state = stateNormal + w.SetNeedsRedraw(true) + activate(w) + }, + OnClickCancel: func() { + w.state = stateNormal + w.SetNeedsRedraw(true) + }, + }) + return w } @@ -205,9 +233,21 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the chip is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // Padding sets the outer padding around the chip content. // Returns the widget for method chaining. func (w *Widget) Padding(v float32) *Widget { @@ -217,7 +257,8 @@ func (w *Widget) Padding(v float32) *Widget { // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/collapsible/collapsible.go b/core/collapsible/collapsible.go index 81fa161..15317df 100644 --- a/core/collapsible/collapsible.go +++ b/core/collapsible/collapsible.go @@ -6,6 +6,7 @@ import ( "github.com/gogpu/ui/animation" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -35,6 +36,9 @@ type Widget struct { istate interactionState painter Painter + // Gesture recognizer for header click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Animation state. progress float32 // 0.0 = collapsed, 1.0 = expanded animCtrl *animation.Controller @@ -100,6 +104,38 @@ func New(opts ...Option) *Widget { } } + // Create ClickRecognizer for header click handling (ADR-049). + // The callback checks if the click was within the header bounds. + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClickDown: func(details gesture.ClickDownDetails) { + if details.Button != event.ButtonLeft { + return + } + if !headerBounds(w).Contains(details.LocalPosition) { + return + } + w.istate = statePressed + w.SetNeedsRedraw(true) + }, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + if !headerBounds(w).Contains(details.LocalPosition) { + return + } + w.istate = stateNormal + w.SetNeedsRedraw(true) + w.Toggle() + w.MarkNeedsLayout() + }, + OnClickCancel: func() { + w.istate = stateNormal + w.SetNeedsRedraw(true) + }, + }) + return w } @@ -307,12 +343,24 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the widget is removed from the tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } // Cancel any running animation. if w.animCtrl != nil { w.animCtrl.CancelAll() } } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // setExpandedState updates the expanded state and starts animation if needed. func (w *Widget) setExpandedState(expanded bool) { widget.PlaySound(widget.SoundClick) @@ -405,7 +453,8 @@ func (a *progressAdapter) Set(v float32) { // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/datatable/datatable.go b/core/datatable/datatable.go index 2e413ea..77ce2fa 100644 --- a/core/datatable/datatable.go +++ b/core/datatable/datatable.go @@ -8,6 +8,7 @@ import ( "github.com/gogpu/ui/core/scrollview" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -285,6 +286,9 @@ type Widget struct { cfg config painter Painter + // Gesture recognizer for header/row click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Internal scroll view for the data rows (not header). scroll *scrollview.Widget virtual *virtualContent @@ -356,6 +360,19 @@ func New(opts ...Option) *Widget { // Flutter: RenderObject.adoptChild sets parent on each child. w.scroll.SetParent(w) + // Create ClickRecognizer for header/row click handling (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + // Header sort and row selection are handled by content + // mouse event dispatch. The recognizer participates in + // the arena for this widget. + }, + }) + return w } @@ -521,9 +538,21 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the table is removed from the widget tree. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } w.scroll.Unmount() } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // --- Public API --- // SortColumn returns the currently sorted column key and direction. @@ -1268,8 +1297,9 @@ const ( // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) - _ a11y.Accessible = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ a11y.Accessible = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/docking/host.go b/core/docking/host.go index 97ba218..9076b28 100644 --- a/core/docking/host.go +++ b/core/docking/host.go @@ -3,6 +3,7 @@ package docking import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/widget" ) @@ -88,6 +89,9 @@ type Host struct { cfg hostConfig painter Painter + // Gesture recognizer for zone tab click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Zone groups, indexed by Zone constant. zones [zoneCount]group @@ -127,6 +131,17 @@ func NewHost(opts ...HostOption) *Host { } } + // Create ClickRecognizer for zone tab click handling (ADR-049). + h.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + // Zone tab click is handled by handleZoneTabEvents. + }, + }) + return h } @@ -725,5 +740,17 @@ const ( defaultHostHeight float32 = 600 ) +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (h *Host) GestureRecognizers() []gesture.Recognizer { + if h.clickRec == nil { + return nil + } + return []gesture.Recognizer{h.clickRec} +} + // Verify Host implements required interfaces at compile time. -var _ widget.Widget = (*Host)(nil) +var ( + _ widget.Widget = (*Host)(nil) + _ gesture.GestureAware = (*Host)(nil) +) diff --git a/core/dropdown/widget.go b/core/dropdown/widget.go index 88b1412..a384317 100644 --- a/core/dropdown/widget.go +++ b/core/dropdown/widget.go @@ -3,6 +3,7 @@ package dropdown import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/overlay" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" @@ -34,6 +35,9 @@ type Widget struct { open bool selectedIndex int menuWidget *menuWidget // active menu widget (nil when closed) + + // Gesture recognizer for trigger click handling (ADR-049). + clickRec *gesture.ClickRecognizer } // New creates a new dropdown Widget with the given options. @@ -65,6 +69,17 @@ func New(opts ...Option) *Widget { w.selectedIndex = w.cfg.signal.Get() } + // Create ClickRecognizer for gesture arena participation (ADR-049). + // The recognizer participates in the arena but does NOT modify widget state — + // all state transitions (pressed, hover, toggle) are handled by the derived + // MouseEvent handlers in handleMouseEvent. Gesture callbacks that modify + // w.state would race with the derived event (gesture fires in Part 1 of + // HandlePointerEvent, derived event in Part 2), corrupting the state that + // MouseRelease depends on to decide whether to toggle. + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + }) + return w } @@ -358,12 +373,25 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the dropdown is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // Compile-time interface checks. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/gridview/gridview.go b/core/gridview/gridview.go index 0614925..53d0996 100644 --- a/core/gridview/gridview.go +++ b/core/gridview/gridview.go @@ -9,6 +9,7 @@ import ( "github.com/gogpu/ui/core/scrollview" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -384,6 +385,9 @@ type Widget struct { cfg config painter Painter + // Gesture recognizer for cell click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Internal scroll view (composition). scroll *scrollview.Widget virtual *virtualContent @@ -451,6 +455,17 @@ func New(opts ...Option) *Widget { // Flutter: RenderObject.adoptChild sets parent on each child. w.scroll.SetParent(w) + // Create ClickRecognizer for cell click handling (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + // Cell click is handled by content mouse event dispatch. + }, + }) + return w } @@ -589,10 +604,22 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the grid view is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } w.scroll.Unmount() // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // --- Public API --- // ScrollToIndex scrolls to make the cell at the given index visible. @@ -1283,8 +1310,9 @@ func (cc *cellCache) clear() { // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) - _ a11y.Accessible = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ a11y.Accessible = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/listview/widget.go b/core/listview/widget.go index cb9b8f0..f83df13 100644 --- a/core/listview/widget.go +++ b/core/listview/widget.go @@ -7,6 +7,7 @@ import ( "github.com/gogpu/ui/core/scrollview" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -29,6 +30,9 @@ type Widget struct { cfg config painter Painter + // Gesture recognizer for item click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Internal scroll view (composition). scroll *scrollview.Widget virtual *virtualContent @@ -109,6 +113,18 @@ func New(opts ...Option) *Widget { // Flutter: RenderObject.adoptChild sets parent on each child. w.scroll.SetParent(w) + // Create ClickRecognizer for item click handling (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + // Item click is handled by content mouse event dispatch. + // The recognizer participates in the arena for this widget. + }, + }) + return w } @@ -259,11 +275,23 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the list view is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } // Unmount internal scroll view. w.scroll.Unmount() // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // --- Public API --- // ScrollToIndex scrolls to make the item at the given index visible. @@ -429,8 +457,9 @@ const ( // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) - _ a11y.Accessible = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ a11y.Accessible = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/menu/menubar.go b/core/menu/menubar.go index e169e1d..e5dce7f 100644 --- a/core/menu/menubar.go +++ b/core/menu/menubar.go @@ -3,6 +3,7 @@ package menu import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/overlay" "github.com/gogpu/ui/widget" ) @@ -21,6 +22,9 @@ type Bar struct { openIndex int // index of open top-level menu (-1 for none) hoveredIndex int // index of hovered label (-1 for none) + // Gesture recognizer for menu label click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Active menu panel state. activePanel *menuPanel } @@ -52,6 +56,17 @@ func NewBar(menus []TopMenu, opts ...BarOption) *Bar { opt(b) } + // Create ClickRecognizer for menu label click handling (ADR-049). + b.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + // Menu open/close is handled by handleMouseEvent. + }, + }) + return b } @@ -378,8 +393,18 @@ const ( a11yLabelMenuBar = "menu bar" ) +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (b *Bar) GestureRecognizers() []gesture.Recognizer { + if b.clickRec == nil { + return nil + } + return []gesture.Recognizer{b.clickRec} +} + // Compile-time interface checks. var ( - _ widget.Widget = (*Bar)(nil) - _ widget.Focusable = (*Bar)(nil) + _ widget.Widget = (*Bar)(nil) + _ widget.Focusable = (*Bar)(nil) + _ gesture.GestureAware = (*Bar)(nil) ) diff --git a/core/popover/popover.go b/core/popover/popover.go index b2afdc0..264072f 100644 --- a/core/popover/popover.go +++ b/core/popover/popover.go @@ -3,6 +3,7 @@ package popover import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -19,6 +20,9 @@ type Popover struct { painter Painter visible bool + // Gesture recognizer for trigger click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // overlayWidget is the content wrapper pushed to the overlay stack. overlayWidget *overlayContent } @@ -57,6 +61,18 @@ func NewPopover(opts ...Option) *Popover { } } + // Create ClickRecognizer for trigger click handling (ADR-049). + p.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + // Toggle is handled by Event() which has access to ctx. + // The recognizer just marks that a click occurred. + }, + }) + return p } @@ -291,9 +307,21 @@ func (p *Popover) Mount(ctx widget.Context) { // Unmount is called when the popover is removed from the widget tree. func (p *Popover) Unmount() { + if p.clickRec != nil { + p.clickRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (p *Popover) GestureRecognizers() []gesture.Recognizer { + if p.clickRec == nil { + return nil + } + return []gesture.Recognizer{p.clickRec} +} + // overlayContent wraps the popover content widget for the overlay stack. // It implements the overlay.Overlay interface indirectly via the // OverlayManager's PushOverlay contract. @@ -402,8 +430,9 @@ func triggerScreenBoundsOf(w widget.Widget) geometry.Rect { // Compile-time interface checks. var ( - _ widget.Widget = (*Popover)(nil) - _ widget.Focusable = (*Popover)(nil) - _ widget.Lifecycle = (*Popover)(nil) - _ widget.Widget = (*overlayContent)(nil) + _ widget.Widget = (*Popover)(nil) + _ widget.Focusable = (*Popover)(nil) + _ widget.Lifecycle = (*Popover)(nil) + _ gesture.GestureAware = (*Popover)(nil) + _ widget.Widget = (*overlayContent)(nil) ) diff --git a/core/radio/item.go b/core/radio/item.go index ed81128..802d9e9 100644 --- a/core/radio/item.go +++ b/core/radio/item.go @@ -3,6 +3,7 @@ package radio import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/widget" ) @@ -27,6 +28,9 @@ type Item struct { group *Group state interactionState painter Painter + + // Gesture recognizer for click handling (ADR-049). + clickRec *gesture.ClickRecognizer } // newItem creates a new radio item linked to the given group. @@ -39,6 +43,31 @@ func newItem(def ItemDef, group *Group, painter Painter) *Item { } it.SetVisible(true) it.SetEnabled(true) + + // Create ClickRecognizer for unified pointer pipeline (ADR-049). + it.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClickDown: func(details gesture.ClickDownDetails) { + if details.Button != event.ButtonLeft { + return + } + it.state = statePressed + it.SetNeedsRedraw(true) + }, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + it.state = stateNormal + it.SetNeedsRedraw(true) + it.group.selectValue(it.value) + }, + OnClickCancel: func() { + it.state = stateNormal + it.SetNeedsRedraw(true) + }, + }) + return it } @@ -118,8 +147,30 @@ func (it *Item) Children() []widget.Widget { return nil } +// Mount is called when the item is added to the widget tree. +// Implements [widget.Lifecycle]. +func (it *Item) Mount(_ widget.Context) {} + +// Unmount disposes gesture recognizers. Implements [widget.Lifecycle]. +func (it *Item) Unmount() { + if it.clickRec != nil { + it.clickRec.Dispose() + } +} + +// GestureRecognizers returns the gesture recognizers owned by this item. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (it *Item) GestureRecognizers() []gesture.Recognizer { + if it.clickRec == nil { + return nil + } + return []gesture.Recognizer{it.clickRec} +} + // Verify Item implements required interfaces at compile time. var ( - _ widget.Widget = (*Item)(nil) - _ widget.Focusable = (*Item)(nil) + _ widget.Widget = (*Item)(nil) + _ widget.Focusable = (*Item)(nil) + _ widget.Lifecycle = (*Item)(nil) + _ gesture.GestureAware = (*Item)(nil) ) diff --git a/core/scrollview/event.go b/core/scrollview/event.go index 099c993..16d5c65 100644 --- a/core/scrollview/event.go +++ b/core/scrollview/event.go @@ -332,6 +332,68 @@ func setScroll(w *Widget, ctx widget.Context, rawX, rawY float32) { ctx.InvalidateRect(w.Bounds()) } +// setScrollDirect updates scroll position without requiring a widget.Context. +// Used by gesture recognizer callbacks. +func setScrollDirect(w *Widget, rawX, rawY float32) { + newX := clampScroll(rawX, w.contentSize.Width, w.viewportSize.Width) + newY := clampScroll(rawY, w.contentSize.Height, w.viewportSize.Height) + + currentX := w.cfg.ResolvedScrollX() + currentY := w.cfg.ResolvedScrollY() + + if newX == currentX && newY == currentY { + return + } + + if w.cfg.scrollXSignal != nil { + w.cfg.scrollXSignal.Set(newX) + } else { + w.cfg.scrollX = newX + } + + if w.cfg.scrollYSignal != nil { + w.cfg.scrollYSignal.Set(newY) + } else { + w.cfg.scrollY = newY + } + + if w.cfg.onScroll != nil { + w.cfg.onScroll(newX, newY) + } + + w.SetNeedsRedraw(true) +} + +// handleDragUpdateDirect processes a drag position update for scrollbar +// thumb dragging, without requiring a widget.Context. +func handleDragUpdateDirect(w *Widget, pos geometry.Point) { + _, hTrack := w.computeTrackRects() + vTrack, _ := w.computeTrackRects() + + switch w.dragging { + case dragVertical: + deltaPixels := pos.Y - w.dragStart.Y + trackLen := vTrack.Height() + thumbSize := computeThumbSize(w.viewportSize.Height, w.contentSize.Height, trackLen) + scrollableTrack := trackLen - thumbSize + if scrollableTrack > 0 { + maxScrollY := w.contentSize.Height - w.viewportSize.Height + newScrollY := w.dragScrollStart + deltaPixels*(maxScrollY/scrollableTrack) + setScrollDirect(w, w.cfg.ResolvedScrollX(), newScrollY) + } + case dragHorizontal: + deltaPixels := pos.X - w.dragStart.X + trackLen := hTrack.Width() + thumbSize := computeThumbSize(w.viewportSize.Width, w.contentSize.Width, trackLen) + scrollableTrack := trackLen - thumbSize + if scrollableTrack > 0 { + maxScrollX := w.contentSize.Width - w.viewportSize.Width + newScrollX := w.dragScrollStart + deltaPixels*(maxScrollX/scrollableTrack) + setScrollDirect(w, newScrollX, w.cfg.ResolvedScrollY()) + } + } +} + // clampScroll clamps a scroll offset to [0, maxScroll]. func clampScroll(offset, contentSize, viewportSize float32) float32 { maxScroll := contentSize - viewportSize diff --git a/core/scrollview/widget.go b/core/scrollview/widget.go index a40ceb3..1e322b1 100644 --- a/core/scrollview/widget.go +++ b/core/scrollview/widget.go @@ -3,6 +3,7 @@ package scrollview import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -22,6 +23,9 @@ type Widget struct { content widget.Widget painter Painter + // Gesture recognizer for scrollbar thumb drag (ADR-049). + dragRec *gesture.DragRecognizer + // Cached layout measurements. contentSize geometry.Size viewportSize geometry.Size @@ -64,6 +68,30 @@ func New(content widget.Widget, opts ...Option) *Widget { w.painter = w.cfg.painter } + // Create DragRecognizer for scrollbar thumb drag (ADR-049). + w.dragRec = gesture.NewDragRecognizer(gesture.DragConfig{ + OnDragStart: func(_ gesture.DragStartDetails) { + // Drag start is handled by the existing handleMousePress which + // sets dragAxis and dragScrollStart based on hit-testing. + }, + OnDragUpdate: func(details gesture.DragUpdateDetails) { + if w.dragging == dragNone { + return + } + handleDragUpdateDirect(w, details.LocalPosition) + }, + OnDragEnd: func(_ gesture.DragEndDetails) { + w.dragging = dragNone + w.trackRepeat = trackRepeatState{} + w.MarkRedrawLocal() + }, + OnDragCancel: func() { + w.dragging = dragNone + w.trackRepeat = trackRepeatState{} + w.MarkRedrawLocal() + }, + }) + return w } @@ -387,9 +415,21 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the scroll view is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.dragRec != nil { + w.dragRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.dragRec == nil { + return nil + } + return []gesture.Recognizer{w.dragRec} +} + // Content returns the scroll view's content widget. func (w *Widget) Content() widget.Widget { return w.content @@ -552,7 +592,8 @@ func (w *Widget) Padding(_ float32) *Widget { // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/slider/widget.go b/core/slider/widget.go index 40a6e4d..3b21722 100644 --- a/core/slider/widget.go +++ b/core/slider/widget.go @@ -3,6 +3,7 @@ package slider import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -36,6 +37,13 @@ type Widget struct { interaction interactionState painter Painter + // Gesture recognizers for drag and click-to-position (ADR-049). + // Drag is captain in the Team so it wins over click when movement starts. + clickRec *gesture.ClickRecognizer + dragRec *gesture.DragRecognizer + team *gesture.Team + teamRecs []gesture.Recognizer // team-wrapped recognizers + // Styling overrides set via fluent methods. padding float32 } @@ -64,6 +72,25 @@ func New(opts ...Option) *Widget { w.painter = w.cfg.painter } + // Create gesture recognizers for gesture arena participation (ADR-049). + // Slider uses a Team: click (tap-to-position) + drag (thumb drag). + // Drag is captain so it wins when movement exceeds slop. + // + // The recognizers participate in the arena but do NOT modify widget state + // or set values — all interaction (stateDragging, setValue, CapturePointer) + // is handled by the derived MouseEvent handlers in event.go. Gesture + // callbacks that modify w.interaction would race with the derived event + // (gesture fires in Part 1 of HandlePointerEvent, derived event in Part 2). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + }) + w.dragRec = gesture.NewDragRecognizer(gesture.DragConfig{}) + w.team = &gesture.Team{Captain: w.dragRec} + w.teamRecs = []gesture.Recognizer{ + w.team.Add(w.clickRec), + w.team.Add(w.dragRec), + } + return w } @@ -172,12 +199,25 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the slider is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } + if w.dragRec != nil { + w.dragRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + return w.teamRecs +} + // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/splitview/splitview.go b/core/splitview/splitview.go index aa41f97..f24aeec 100644 --- a/core/splitview/splitview.go +++ b/core/splitview/splitview.go @@ -5,6 +5,7 @@ import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -196,6 +197,9 @@ type Widget struct { cfg config painter Painter + // Gesture recognizer for divider drag (ADR-049). + dragRec *gesture.DragRecognizer + // Interaction state. hovered bool dragging bool @@ -244,6 +248,24 @@ func New(opts ...Option) *Widget { } } + // Create DragRecognizer for divider drag (ADR-049). + w.dragRec = gesture.NewDragRecognizer(gesture.DragConfig{ + OnDragStart: func(_ gesture.DragStartDetails) { + w.dragging = true + }, + OnDragUpdate: func(details gesture.DragUpdateDetails) { + w.updateRatioFromDrag(details.LocalPosition) + }, + OnDragEnd: func(_ gesture.DragEndDetails) { + w.dragging = false + w.SetNeedsRedraw(true) + }, + OnDragCancel: func() { + w.dragging = false + w.SetNeedsRedraw(true) + }, + }) + return w } @@ -744,9 +766,21 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the split view is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.dragRec != nil { + w.dragRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.dragRec == nil { + return nil + } + return []gesture.Recognizer{w.dragRec} +} + // Ratio returns the current split ratio. func (w *Widget) Ratio() float32 { return w.cfg.ResolvedRatio() @@ -800,6 +834,7 @@ func clampRatio(r float32) float32 { // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/stripe/widget.go b/core/stripe/widget.go index ac6811d..e9b1ee4 100644 --- a/core/stripe/widget.go +++ b/core/stripe/widget.go @@ -4,6 +4,7 @@ import ( "github.com/gogpu/ui/a11y" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/widget" ) @@ -43,6 +44,9 @@ type Widget struct { painter Painter activeID string + // Gesture recognizer for button click handling (ADR-049). + clickRec *gesture.ClickRecognizer + topStates []buttonState bottomStates []buttonState hoveredIdx int // index into allButtons() or noHover @@ -74,6 +78,18 @@ func New(opts ...Option) *Widget { w.topStates = make([]buttonState, len(w.cfg.topItems)) w.bottomStates = make([]buttonState, len(w.cfg.bottomItems)) + + // Create ClickRecognizer for button click handling (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + // Button click is handled by handlePress/handleRelease. + }, + }) + return w } @@ -435,8 +451,18 @@ func (w *Widget) AccessibilityActions() []a11y.Action { // a11yLabel is the accessibility label for the stripe. const a11yLabel = "Tool Window Strip" +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // Compile-time interface checks. var ( - _ widget.Widget = (*Widget)(nil) - _ a11y.Accessible = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ a11y.Accessible = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/tabview/widget.go b/core/tabview/widget.go index 6d843d7..4140128 100644 --- a/core/tabview/widget.go +++ b/core/tabview/widget.go @@ -3,6 +3,7 @@ package tabview import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -24,6 +25,9 @@ type Widget struct { cfg config painter Painter + // Gesture recognizer for tab click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Computed layout state. tabBarBounds geometry.Rect tabStates []TabState @@ -66,9 +70,51 @@ func New(tabs []Tab, opts ...Option) *Widget { } } + // Create ClickRecognizer for tab click handling (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: w.handleGestureClick, + }) + return w } +// handleGestureClick processes a click from the gesture recognizer. +// Checks close buttons first, then tab selection. +func (w *Widget) handleGestureClick(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + if !w.tabBarBounds.Contains(details.LocalPosition) { + return + } + // Check close buttons first. + for i := range w.tabStates { + ts := &w.tabStates[i] + if !ts.Closeable || ts.CloseButtonBounds.IsEmpty() { + continue + } + if ts.CloseButtonBounds.Contains(details.LocalPosition) { + if w.cfg.onClose != nil { + w.cfg.onClose(i) + } + w.MarkNeedsLayout() + return + } + } + // Check tab selection. + for i := range w.tabStates { + ts := &w.tabStates[i] + if ts.Disabled { + continue + } + if ts.Bounds.Contains(details.LocalPosition) { + w.selectTab(i) + return + } + } +} + // IsFocusable reports whether the tabview can currently receive focus. func (w *Widget) IsFocusable() bool { return w.IsVisible() && w.IsEnabled() @@ -242,9 +288,21 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the tabview is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // TabCount returns the number of tabs. func (w *Widget) TabCount() int { return len(w.cfg.tabs) @@ -346,7 +404,8 @@ func (w *Widget) updateTabStates(selectedIdx int) { // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/textfield/event.go b/core/textfield/event.go index 3f3e469..fe0853a 100644 --- a/core/textfield/event.go +++ b/core/textfield/event.go @@ -22,7 +22,11 @@ func handleEvent(w *Widget, ctx widget.Context, e event.Event) bool { } } -// handleMouseEvent processes mouse events for focus, cursor placement, and selection. +// handleMouseEvent processes mouse events for focus and hover state. +// +// Cursor placement, selection, drag, and multi-click are handled by the +// TapAndDragRecognizer via the gesture system (ADR-049 Phase 3, #225). +// Only hover (Enter/Leave) and focus acquisition (Press) remain here. func handleMouseEvent(w *Widget, ctx widget.Context, e *event.MouseEvent) bool { switch e.MouseType { case event.MouseEnter: @@ -42,22 +46,19 @@ func handleMouseEvent(w *Widget, ctx widget.Context, e *event.MouseEvent) bool { case event.MousePress: return handleMousePress(w, ctx, e) - case event.MouseRelease: - w.dragging = false - return true - - case event.MouseDrag: - return handleMouseDrag(w, ctx, e) - - case event.MouseDoubleClick: - return handleDoubleClick(w, ctx, e) - default: return false } } -// handleMousePress handles a mouse press event to place the cursor. +// handleMousePress handles focus acquisition and cursor placement via the +// derived MouseEvent from HandlePointerEvent. The mouse position is in +// draw-local coordinates (after Box dispatch translation). +// +// The gesture recognizer's OnTapDown also places the cursor (for double/triple +// click handling), but this handler runs AFTER the gesture callback and uses +// the correctly-translated position from the derived event, ensuring accurate +// cursor placement on single clicks. func handleMousePress(w *Widget, ctx widget.Context, e *event.MouseEvent) bool { if e.Button != event.ButtonLeft { return false @@ -65,88 +66,29 @@ func handleMousePress(w *Widget, ctx widget.Context, e *event.MouseEvent) bool { ctx.RequestFocus(w) - // ADR-028: visual only — cursor placement and focus ring. - w.SetNeedsRedraw(true) - ctx.InvalidateRect(w.Bounds()) - - pos := positionFromMouse(w, e) - if e.Modifiers().IsShift() { - w.sel.SetCursorKeepSelection(pos) + // The gesture recognizer's OnTapDown fires BEFORE this derived + // MousePress handler (Part 1 vs Part 2 ordering in HandlePointerEvent). + // For multi-click (double/triple), OnTapDown sets gestureHandledTap=true + // and performs word/line selection. Skip cursor placement to preserve it. + if w.gestureHandledTap { + w.gestureHandledTap = false } else { - w.sel.SetCursor(pos) - } - w.dragging = true - return true -} + runes := w.textRunes() + pos := positionFromLocal(w, e.Position) + pos = clampPos(pos, len(runes)) -// handleMouseDrag handles mouse drag for text selection. -func handleMouseDrag(w *Widget, ctx widget.Context, e *event.MouseEvent) bool { - if !w.dragging { - return false + if e.Modifiers()&event.ModShift != 0 { + w.sel.SetCursorKeepSelection(pos) + } else { + w.sel.SetCursor(pos) + } } - pos := positionFromMouse(w, e) - w.sel.SetCursorKeepSelection(pos) - // ADR-028: visual only — selection highlight change. - w.SetNeedsRedraw(true) - ctx.InvalidateRect(w.Bounds()) - return true -} -// handleDoubleClick selects the word at the click position. -func handleDoubleClick(w *Widget, ctx widget.Context, e *event.MouseEvent) bool { - if e.Button != event.ButtonLeft { - return false - } - runes := w.textRunes() - pos := positionFromMouse(w, e) - start, end := wordBoundsAt(runes, pos) - w.sel.anchor = start - w.sel.cursor = end - // ADR-028: visual only — word selection highlight. w.SetNeedsRedraw(true) ctx.InvalidateRect(w.Bounds()) return true } -// positionFromMouse converts a mouse position to a rune index in the text. -// Uses cached text metrics from the last Draw call for accurate hit-testing. -// Falls back to proportional approximation when no cached metrics are available -// (e.g., before the first draw). -// -// When horizontal scroll is active, the mouse X is adjusted by the inverse -// of scrollOffsetX to map screen coordinates back to text coordinates. -func positionFromMouse(w *Widget, e *event.MouseEvent) int { - runes := w.textRunes() - - // Use cached metrics from last Draw if available. - if w.cachedMetrics != nil { - // Adjust mouse X by inverse of scroll offset: the text is shifted - // by scrollOffsetX, so the unscrolled X is (mouseX - scrollOffsetX). - adjustedX := e.Position.X - w.scrollOffsetX - return w.cachedMetrics.RuneIndexFromX( - w.cachedContentRect, - w.cachedDisplayText, - adjustedX, - ) - } - - // Fallback: approximate using layout metrics padding. - lm := resolveLayoutMetrics(w.painter) - hPad, _ := lm.ContentPadding() - bounds := w.Bounds() - localX := e.Position.X - bounds.Min.X - hPad - w.scrollOffsetX - - if localX <= 0 { - return 0 - } - - // Approximate proportional positioning. - fontSize := lm.TextFieldFontSize() - charW := fontSize * 0.55 // approximate average character width - pos := int(localX / charW) - return clampPos(pos, len(runes)) -} - // handleKeyEvent processes keyboard events for text editing and navigation. func handleKeyEvent(w *Widget, ctx widget.Context, e *event.KeyEvent) bool { if !w.IsFocused() { diff --git a/core/textfield/selection.go b/core/textfield/selection.go index 596eb74..a4438da 100644 --- a/core/textfield/selection.go +++ b/core/textfield/selection.go @@ -1,12 +1,15 @@ package textfield -import "unicode" +import ( + "unicode" + + "github.com/gogpu/ui/widget" +) // selection tracks cursor position and text selection state. type selection struct { - cursor int // cursor position (byte offset in runes slice) - anchor int // anchor for selection (same as cursor when no selection) - clipboard string // internal clipboard (placeholder for platform clipboard) + cursor int // cursor position (byte offset in runes slice) + anchor int // anchor for selection (same as cursor when no selection) } // HasSelection returns true if text is selected (anchor != cursor). @@ -138,14 +141,21 @@ func wordBoundsAt(runes []rune, pos int) (int, int) { return start, end } -// copyToClipboard stores the given text in the internal clipboard. -// This is a placeholder; a real implementation would use platform APIs. +// selectAll selects the entire text content given the total rune count. +// Used for triple-click (select line/all). +func (s *selection) selectAll(runeCount int) { + s.anchor = 0 + s.cursor = runeCount +} + +// copyToClipboard writes text to the system clipboard via the registered +// ClipboardProvider. Falls back to no-op if no provider is registered. func (s *selection) copyToClipboard(text string) { - s.clipboard = text + widget.ClipboardWrite(text) } -// pasteFromClipboard returns the text from the internal clipboard. -// This is a placeholder; a real implementation would use platform APIs. +// pasteFromClipboard reads text from the system clipboard via the registered +// ClipboardProvider. Returns empty string if no provider is registered. func (s *selection) pasteFromClipboard() string { - return s.clipboard + return widget.ClipboardRead() } diff --git a/core/textfield/textfield_test.go b/core/textfield/textfield_test.go index f68f1c7..fbb9c5e 100644 --- a/core/textfield/textfield_test.go +++ b/core/textfield/textfield_test.go @@ -3,11 +3,13 @@ package textfield_test import ( "image" "testing" + "time" "github.com/gogpu/ui/a11y" "github.com/gogpu/ui/core/textfield" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -507,7 +509,14 @@ func TestSelection_BackspaceDeletesSelection(t *testing.T) { // --- Clipboard Tests --- +type testClipboard struct{ text string } + +func (c *testClipboard) ClipboardRead() (string, error) { return c.text, nil } +func (c *testClipboard) ClipboardWrite(text string) error { c.text = text; return nil } + func TestClipboard_CopyPaste(t *testing.T) { + widget.RegisterClipboardProvider(&testClipboard{}) + tf := textfield.New(textfield.InitialValue("hello")) tf.SetBounds(geometry.NewRect(0, 0, 300, 48)) tf.SetFocused(true) @@ -528,6 +537,8 @@ func TestClipboard_CopyPaste(t *testing.T) { } func TestClipboard_Cut(t *testing.T) { + widget.RegisterClipboardProvider(&testClipboard{}) + tf := textfield.New(textfield.InitialValue("hello")) tf.SetBounds(geometry.NewRect(0, 0, 300, 48)) tf.SetFocused(true) @@ -592,19 +603,134 @@ func TestMouse_DoubleClickSelectsWord(t *testing.T) { tf := textfield.New(textfield.InitialValue("hello world")) tf.SetBounds(geometry.NewRect(0, 0, 300, 48)) tf.SetFocused(true) - ctx := widget.NewContext() - - // Double-click on the first character area. - dbl := event.NewMouseEvent(event.MouseDoubleClick, event.ButtonLeft, event.ButtonStateLeft, - geometry.Pt(12+2, 24), geometry.Pt(12+2, 24), event.ModNone) - tf.Event(ctx, dbl) + // Double-click is now handled by the TapAndDragRecognizer. + // Simulate two rapid taps at the same position through the gesture system. + recs := tf.GestureRecognizers() + if len(recs) == 0 { + t.Fatal("TextField should have a TapAndDragRecognizer") + } + arena := gesture.NewArena() + tapPos := geometry.Pt(12+2, 24) + ts := 100 * time.Millisecond + + // First tap: down + up. + down1 := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerDown, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: tapPos, + GlobalPosition: tapPos, + Button: event.ButtonLeft, + Buttons: event.ButtonStateLeft, + Timestamp: ts, + } + recs[0].AddPointer(down1, arena) + arena.Close(1) + + up1 := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerUp, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: tapPos, + GlobalPosition: tapPos, + Timestamp: ts + 50*time.Millisecond, + } + recs[0].HandleEvent(up1) + arena.Sweep(1) + + // Second tap (within DoubleTapTimeout): down + up. + ts2 := ts + 100*time.Millisecond + down2 := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerDown, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: tapPos, + GlobalPosition: tapPos, + Button: event.ButtonLeft, + Buttons: event.ButtonStateLeft, + Timestamp: ts2, + } + recs[0].AddPointer(down2, arena) + arena.Close(1) + + // After the second tap-down with ConsecutiveTapCount=2, word selection + // should have been triggered by OnTapDown. start, end := tf.Selection() if start != 0 || end != 5 { t.Errorf("selection = (%d, %d), want (0, 5) for word 'hello'", start, end) } } +func TestMouse_TripleClickSelectsAll(t *testing.T) { + tf := textfield.New(textfield.InitialValue("hello world")) + tf.SetBounds(geometry.NewRect(0, 0, 300, 48)) + tf.SetFocused(true) + + recs := tf.GestureRecognizers() + if len(recs) == 0 { + t.Fatal("TextField should have a TapAndDragRecognizer") + } + arena := gesture.NewArena() + tapPos := geometry.Pt(14, 24) + + // Three rapid taps at the same position. + ts := 100 * time.Millisecond + for i := 0; i < 3; i++ { + tapTS := ts + time.Duration(i)*100*time.Millisecond + down := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerDown, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: tapPos, + GlobalPosition: tapPos, + Button: event.ButtonLeft, + Buttons: event.ButtonStateLeft, + Timestamp: tapTS, + } + recs[0].AddPointer(down, arena) + arena.Close(1) + + up := &gesture.PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: gesture.PointerUp, + PointerID: 1, + PointerType: gesture.PointerTypeMouse, + Position: tapPos, + GlobalPosition: tapPos, + Timestamp: tapTS + 30*time.Millisecond, + } + recs[0].HandleEvent(up) + arena.Sweep(1) + } + + // Triple click selects all text. + start, end := tf.Selection() + runeCount := len([]rune("hello world")) + if start != 0 || end != runeCount { + t.Errorf("selection = (%d, %d), want (0, %d) for select-all", start, end, runeCount) + } +} + +func TestTextField_GestureAwareInterface(t *testing.T) { + tf := textfield.New() + + // Verify GestureAware interface is implemented. + ga, ok := interface{}(tf).(gesture.GestureAware) + if !ok { + t.Fatal("TextField should implement gesture.GestureAware") + } + + recs := ga.GestureRecognizers() + if len(recs) != 1 { + t.Errorf("GestureRecognizers() returned %d, want 1", len(recs)) + } +} + // --- Disabled State Tests --- func TestDisabled_BlocksKeyInput(t *testing.T) { diff --git a/core/textfield/widget.go b/core/textfield/widget.go index 6766aad..4beabfb 100644 --- a/core/textfield/widget.go +++ b/core/textfield/widget.go @@ -3,6 +3,7 @@ package textfield import ( "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/internal/textmetrics" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" @@ -29,9 +30,14 @@ type Widget struct { sel selection painter Painter + // Gesture recognizer for tap/drag/multi-click text selection. + // Handles single-click (cursor placement), double-click (word selection), + // triple-click (select all), and drag (selection extension). + // Replaces ad-hoc dragging field and MouseDrag/MouseDoubleClick handling. + tapDrag *gesture.TapAndDragRecognizer + // Interaction state. - hovered bool - dragging bool + hovered bool // Validation state. errorMsg string @@ -45,13 +51,14 @@ type Widget struct { // Qt QLineEdit d->hscroll, HTML input.scrollLeft. scrollOffsetX float32 - // Cached text metrics from last Draw call, used by event handlers - // (positionFromMouse) that don't have access to canvas. + // Cached text metrics from last Draw call, used by gesture handlers + // (positionFromGlobal) that don't have access to canvas. cachedMetrics *textmetrics.Metrics // Cached layout values from last Draw call. cachedContentRect geometry.Rect cachedDisplayText string cachedFontSize float32 + gestureHandledTap bool } // New creates a new text field Widget with the given options. @@ -89,6 +96,15 @@ func New(opts ...Option) *Widget { w.errorMsg = runValidation(w.cfg.validation, w.cfg.value) } + // Create TapAndDragRecognizer for unified click/drag/multi-click handling. + // This replaces ad-hoc MousePress, MouseDrag, and MouseDoubleClick handlers + // with gesture system callbacks (ADR-049 Phase 3, #225). + w.tapDrag = gesture.NewTapAndDragRecognizer(gesture.TapAndDragConfig{ + OnTapDown: w.handleGestureTapDown, + OnDragStart: w.handleGestureDragStart, + OnDragUpdate: w.handleGestureDragUpdate, + }) + return w } @@ -164,7 +180,7 @@ func (w *Widget) Draw(_ widget.Context, canvas widget.Canvas) { scrolledRect.Min.X += w.scrollOffsetX scrolledRect.Max.X += w.scrollOffsetX - // Cache for event handlers (positionFromMouse). + // Cache for gesture handlers (positionFromGlobal). w.cachedMetrics = tm w.cachedContentRect = contentRect w.cachedDisplayText = displayText @@ -433,12 +449,143 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the text field is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + // Dispose recognizer to release arena references. + if w.tapDrag != nil { + w.tapDrag.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.tapDrag == nil { + return nil + } + return []gesture.Recognizer{w.tapDrag} +} + +// handleGestureTapDown is the OnTapDown callback for the TapAndDragRecognizer. +// Handles cursor placement (single), word selection (double), and select-all (triple). +func (w *Widget) handleGestureTapDown(details gesture.TapDragDownDetails) { + if details.Button != event.ButtonLeft { + return + } + + // Request focus on any tap. + // The context is not available here directly, so we defer focus + // request to the Event handler (MousePress derived from PointerDown + // still calls RequestFocus). The cursor placement happens immediately. + + runes := w.textRunes() + pos := positionFromGlobal(w, details.LocalPosition) + + switch details.ConsecutiveTapCount { + case 1: + // Single click: place cursor, optionally extend selection with Shift. + if details.Modifiers.IsShift() { + w.sel.SetCursorKeepSelection(pos) + } else { + w.sel.SetCursor(pos) + } + case 2: + // Double click: select word at position. + start, end := wordBoundsAt(runes, pos) + w.sel.anchor = start + w.sel.cursor = end + w.gestureHandledTap = true + default: + // Triple click (or more): select all text. + w.sel.selectAll(len(runes)) + w.gestureHandledTap = true + } + + // ADR-028: visual only. + w.SetNeedsRedraw(true) +} + +// handleGestureDragStart is the OnDragStart callback. Begins selection drag. +func (w *Widget) handleGestureDragStart(_ gesture.TapDragStartDetails) { + // Drag started — selection extension will happen in handleGestureDragUpdate. + // No action needed on start; anchor was set in handleGestureTapDown. +} + +// handleGestureDragUpdate is the OnDragUpdate callback. Extends selection +// based on the consecutive tap count: +// - 1 = character-by-character selection +// - 2 = word-by-word selection +// - 3 = line-by-line (select-all for single-line TextField) +func (w *Widget) handleGestureDragUpdate(details gesture.TapDragUpdateDetails) { + runes := w.textRunes() + pos := positionFromGlobal(w, details.LocalPosition) + + switch details.ConsecutiveTapCount { + case 1: + // Character selection: extend cursor without moving anchor. + w.sel.SetCursorKeepSelection(pos) + case 2: + // Word-by-word selection: snap to word boundaries. + _, end := wordBoundsAt(runes, pos) + w.sel.SetCursorKeepSelection(end) + default: + // Line/all selection: snap to full text. + w.sel.selectAll(len(runes)) + } + + // ADR-028: visual only. + w.SetNeedsRedraw(true) +} + +// positionFromLocal converts a draw-local position (the coordinate space used +// by derived MouseEvents after Box dispatch translation) to a rune index. +// This is the position relative to the widget's parent, matching the coordinate +// space of Bounds() and cachedContentRect. +func positionFromLocal(w *Widget, localPos geometry.Point) int { + runes := w.textRunes() + + // Use cached metrics from last Draw if available. + if w.cachedMetrics != nil { + adjustedX := localPos.X - w.scrollOffsetX + return w.cachedMetrics.RuneIndexFromX( + w.cachedContentRect, + w.cachedDisplayText, + adjustedX, + ) + } + + // Fallback: approximate using layout metrics padding. + lm := resolveLayoutMetrics(w.painter) + hPad, _ := lm.ContentPadding() + bounds := w.Bounds() + localX := localPos.X - bounds.Min.X - hPad - w.scrollOffsetX + + if localX <= 0 { + return 0 + } + + fontSize := lm.TextFieldFontSize() + charW := fontSize * 0.55 + pos := int(localX / charW) + return clampPos(pos, len(runes)) +} + +// positionFromGlobal converts a window-coordinate (global) position to a rune +// index. Used by gesture recognizer callbacks where positions are in window +// coordinates (gesture.PointerEvent.GlobalPosition). +// +// Converts global to draw-local by subtracting the widget's ScreenOrigin +// (accumulated parent transforms) and adding Bounds().Min (parent-local offset). +// This produces the same coordinate space as derived MouseEvent positions. +func positionFromGlobal(w *Widget, globalPos geometry.Point) int { + so := w.ScreenOrigin() + localPos := globalPos.Sub(so).Add(w.Bounds().Min) + return positionFromLocal(w, localPos) +} + // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/titlebar/titlebar.go b/core/titlebar/titlebar.go index a1613d3..2fd9cb6 100644 --- a/core/titlebar/titlebar.go +++ b/core/titlebar/titlebar.go @@ -4,6 +4,7 @@ import ( "github.com/gogpu/ui/a11y" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/widget" ) @@ -90,6 +91,9 @@ type Widget struct { cfg config painter Painter + // Gesture recognizer for control button click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // controlBounds holds the bounds for each control button (min, max, close). controlBounds [controlCount]geometry.Rect @@ -150,6 +154,17 @@ func New(opts ...Option) *Widget { } } + // Create ClickRecognizer for control button click handling (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + // Control button click is handled by handlePress/handleRelease. + }, + }) + return w } @@ -744,9 +759,19 @@ func setBounds(child widget.Widget, bounds geometry.Rect) { } } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // Compile-time interface checks. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ a11y.Accessible = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ a11y.Accessible = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/toolbar/toolbar.go b/core/toolbar/toolbar.go index 59cc19c..35b2ee4 100644 --- a/core/toolbar/toolbar.go +++ b/core/toolbar/toolbar.go @@ -4,6 +4,7 @@ import ( "github.com/gogpu/ui/a11y" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/widget" ) @@ -88,6 +89,9 @@ type Widget struct { painter Painter itemStates []itemState focusIndex int // index of the focused item (-1 = none) + + // Gesture recognizer for toolbar item click handling (ADR-049). + clickRec *gesture.ClickRecognizer } // New creates a new toolbar Widget with the given options. @@ -125,6 +129,17 @@ func New(opts ...Option) *Widget { } } + // Create ClickRecognizer for toolbar item click handling (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + // Item click is handled by handlePress/handleRelease. + }, + }) + return w } @@ -686,9 +701,19 @@ func (w *Widget) AccessibilityActions() []a11y.Action { // a11yLabel is the accessibility label for the toolbar. const a11yLabel = "Toolbar" +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // Compile-time interface checks. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ a11y.Accessible = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ a11y.Accessible = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/core/treeview/treeview.go b/core/treeview/treeview.go index 3faf23c..8650da8 100644 --- a/core/treeview/treeview.go +++ b/core/treeview/treeview.go @@ -6,6 +6,7 @@ import ( "github.com/gogpu/ui/a11y" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/gesture" "github.com/gogpu/ui/state" "github.com/gogpu/ui/widget" ) @@ -27,6 +28,9 @@ type Widget struct { cfg config painter Painter + // Gesture recognizer for row click handling (ADR-049). + clickRec *gesture.ClickRecognizer + // Flattened visible rows (rebuilt on expand/collapse). rows []flatRow @@ -63,6 +67,35 @@ func New(opts ...Option) *Widget { // Build initial flattened rows. w.rebuildRows() + // Create ClickRecognizer for row click handling (ADR-049). + w.clickRec = gesture.NewClickRecognizer(gesture.ClickConfig{ + MaxClickCount: 1, + OnClick: func(details gesture.ClickDetails) { + if details.Button != event.ButtonLeft { + return + } + idx := w.hitTestRow(details.LocalPosition) + if idx < 0 || idx >= len(w.rows) { + return + } + row := w.rows[idx] + bounds := w.Bounds() + rowBounds := w.rowBounds(idx, bounds) + // Check if click is on the expand icon area. + if !row.node.IsLeaf() { + iconBounds := w.expandIconBounds(row.depth, rowBounds) + if iconBounds.Contains(details.LocalPosition) { + w.toggleNode(row.node) + return + } + } + // Click on the row — select the node. + if w.cfg.selectionMode == SelectionSingle { + w.setSelectedNodeIDDirect(row.node.ID) + } + }, + }) + return w } @@ -232,9 +265,21 @@ func (w *Widget) Mount(ctx widget.Context) { // Unmount is called when the tree view is removed from the widget tree. // Implements [widget.Lifecycle]. func (w *Widget) Unmount() { + if w.clickRec != nil { + w.clickRec.Dispose() + } // Bindings are cleaned up automatically by WidgetBase.CleanupBindings(). } +// GestureRecognizers returns the gesture recognizers owned by this widget. +// Implements [gesture.GestureAware] for the unified pointer pipeline (ADR-049). +func (w *Widget) GestureRecognizers() []gesture.Recognizer { + if w.clickRec == nil { + return nil + } + return []gesture.Recognizer{w.clickRec} +} + // --- Public API --- // ScrollToNode scrolls to make the node with the given ID visible. @@ -443,6 +488,32 @@ func (w *Widget) setSelectedNodeID(ctx widget.Context, id string) { ctx.InvalidateRect(w.Bounds()) } +// setSelectedNodeIDDirect updates the selected node without requiring a widget.Context. +// Used by gesture recognizer callbacks. +func (w *Widget) setSelectedNodeIDDirect(id string) { + current := w.cfg.ResolvedSelectedNodeID() + if id == current { + return + } + + if w.cfg.selectedNodeIDSignal != nil { + w.cfg.selectedNodeIDSignal.Set(id) + } else { + w.cfg.selectedNodeID = id + } + + w.SetNeedsRedraw(true) + + if w.cfg.onSelect != nil { + root := w.cfg.ResolvedRoot() + if root != nil { + if node := findNodeByID(root, id); node != nil { + w.cfg.onSelect(node) + } + } + } +} + // toggleNode toggles the expanded state of the given node. func (w *Widget) toggleNode(node *TreeNode) { if node.IsLeaf() { @@ -521,8 +592,9 @@ const noHoveredIndex = -1 // Verify Widget implements required interfaces at compile time. var ( - _ widget.Widget = (*Widget)(nil) - _ widget.Focusable = (*Widget)(nil) - _ widget.Lifecycle = (*Widget)(nil) - _ a11y.Accessible = (*Widget)(nil) + _ widget.Widget = (*Widget)(nil) + _ widget.Focusable = (*Widget)(nil) + _ widget.Lifecycle = (*Widget)(nil) + _ a11y.Accessible = (*Widget)(nil) + _ gesture.GestureAware = (*Widget)(nil) ) diff --git a/desktop/desktop.go b/desktop/desktop.go index 5417b42..002a734 100644 --- a/desktop/desktop.go +++ b/desktop/desktop.go @@ -18,6 +18,7 @@ import ( "github.com/gogpu/ui/dnd" "github.com/gogpu/ui/geometry" "github.com/gogpu/ui/render" + "github.com/gogpu/ui/widget" ) var ( @@ -70,6 +71,8 @@ func Run(gogpuApp *gogpu.App, uiApp *app.App) error { uiApp: uiApp, } + widget.RegisterClipboardProvider(gogpuApp) + gogpuApp.OnDraw(rl.draw) // Bridge OS file drag-and-drop to ui dnd system. diff --git a/gesture/arena.go b/gesture/arena.go new file mode 100644 index 0000000..eeb242f --- /dev/null +++ b/gesture/arena.go @@ -0,0 +1,304 @@ +package gesture + +// Disposition is the result of a recognizer's arena evaluation. +type Disposition uint8 + +const ( + // Accepted indicates the recognizer claims victory in the arena. + Accepted Disposition = iota + + // Rejected indicates the recognizer withdraws from the arena. + Rejected +) + +// ArenaEntry is a handle returned by Arena.Add, used for tracking +// a member's registration in the arena. +type ArenaEntry struct { + // PointerID is the pointer this entry is registered for. + PointerID int + + // Member is the arena participant. + Member ArenaMember +} + +// ArenaMember is the interface that all gesture arena participants implement. +// Each recognizer that wants to claim a pointer sequence registers as an +// ArenaMember in the arena for that pointer's ID. +type ArenaMember interface { + // AcceptGesture is called when this member wins the arena. + // The member should commit its gesture (fire callbacks, transition state). + AcceptGesture(pointerID int) + + // RejectGesture is called when this member loses the arena. + // The member should reset its internal state and release resources. + RejectGesture(pointerID int) +} + +// arenaState tracks the state of a single pointer's arena competition. +type arenaState struct { + members []ArenaMember + isOpen bool // true during PointerDown dispatch, false after Close + isHeld bool // true when Hold is active, prevents Sweep + eagerWinner ArenaMember + resolved bool // true after a winner has been declared +} + +// Arena manages gesture disambiguation for a single window. +// +// When a PointerDown event occurs, all interested recognizers add themselves +// to the arena for that pointer ID. As pointer events arrive, recognizers +// evaluate whether the gesture matches their pattern. A recognizer calls +// Resolve(Accepted) to claim victory or Resolve(Rejected) to withdraw. +// +// Resolution rules (Flutter GestureArenaManager protocol): +// - Resolve(Accepted) while arena is open: store as eager winner. +// - Resolve(Rejected): remove member, call RejectGesture. +// - Arena closes (end of PointerDown dispatch): if eager winner exists, it +// wins; if 1 member remains, it wins. +// - Sweep (after PointerUp): first remaining member wins (last resort). +// - Hold/Release: prevents sweep (used by multi-tap between taps). +type Arena struct { + arenas map[int]*arenaState + + // pendingRoute maps pointer IDs to members that should receive events. + // This is populated when members are added and used by Route. + pendingRoute map[int][]ArenaMember +} + +// NewArena creates a new gesture arena. +func NewArena() *Arena { + return &Arena{ + arenas: make(map[int]*arenaState), + pendingRoute: make(map[int][]ArenaMember), + } +} + +// Add registers a member in the arena for the given pointer ID. +// Must be called during PointerDown dispatch; the arena closes at end of dispatch. +// Returns an ArenaEntry for tracking. +func (a *Arena) Add(pointerID int, member ArenaMember) ArenaEntry { + state := a.getOrCreateState(pointerID) + if state.resolved { + // Arena already resolved for this pointer; reject immediately. + member.RejectGesture(pointerID) + return ArenaEntry{PointerID: pointerID, Member: member} + } + + state.members = append(state.members, member) + a.pendingRoute[pointerID] = append(a.pendingRoute[pointerID], member) + + return ArenaEntry{PointerID: pointerID, Member: member} +} + +// Close marks the arena for a pointer as closed (no more members can join). +// Called at the end of PointerDown dispatch. If exactly one member remains +// or an eager winner exists, resolves immediately. +func (a *Arena) Close(pointerID int) { + state, ok := a.arenas[pointerID] + if !ok { + return + } + state.isOpen = false + a.tryResolve(pointerID, state) +} + +// Resolve declares the member's disposition for the given pointer ID. +func (a *Arena) Resolve(pointerID int, member ArenaMember, disposition Disposition) { + state, ok := a.arenas[pointerID] + if !ok || state.resolved { + return + } + + switch disposition { + case Accepted: + if state.isOpen { + // Arena still open: store as eager winner, resolve when closed. + state.eagerWinner = member + } else { + // Arena closed: this member wins immediately. + a.resolveWinner(pointerID, state, member) + } + case Rejected: + a.removeMember(pointerID, state, member) + member.RejectGesture(pointerID) + // After removal, check if auto-resolve is possible. + if !state.isOpen { + a.tryResolve(pointerID, state) + } + } +} + +// Hold prevents the arena from sweeping for the given pointer ID. +// Used by multi-click recognizers between taps to defer resolution. +func (a *Arena) Hold(pointerID int) { + if state, ok := a.arenas[pointerID]; ok { + state.isHeld = true + } +} + +// Release allows the arena to sweep again for the given pointer ID. +// If sweep was pending, it executes immediately. +func (a *Arena) Release(pointerID int) { + state, ok := a.arenas[pointerID] + if !ok { + return + } + state.isHeld = false +} + +// Sweep resolves all open arenas: first remaining member wins. +// Called after PointerUp dispatch completes. Does nothing if the arena +// is held. +// +// After sweep resolution, the full cleanup (including pendingRoute) +// is performed since the pointer sequence is complete. +func (a *Arena) Sweep(pointerID int) { + state, ok := a.arenas[pointerID] + if !ok || state.resolved || state.isHeld { + // If no arena state but pendingRoute exists (already resolved + // via Close), clean up the route now that PointerUp is done. + if !ok { + delete(a.pendingRoute, pointerID) + } + return + } + + if len(state.members) > 0 { + winner := state.members[0] + a.resolveWinner(pointerID, state, winner) + } else { + // No members left; clean up. + a.cleanup(pointerID) + } + + // Full cleanup after sweep — pointer sequence is complete. + delete(a.pendingRoute, pointerID) +} + +// Route dispatches a pointer event to all members tracking the given pointer. +// Called for PointerMove, PointerUp, and PointerCancel events. +func (a *Arena) Route(ev *PointerEvent) { + members, ok := a.pendingRoute[ev.PointerID] + if !ok { + return + } + // Iterate over a copy of the slice because HandleEvent may modify the arena. + routeMembers := make([]ArenaMember, len(members)) + copy(routeMembers, members) + for _, m := range routeMembers { + if r, ok := m.(Recognizer); ok { + r.HandleEvent(ev) + } + } +} + +// MemberCount returns the number of members currently in the arena for a pointer. +// Returns 0 if no arena exists for the pointer. +func (a *Arena) MemberCount(pointerID int) int { + state, ok := a.arenas[pointerID] + if !ok { + return 0 + } + return len(state.members) +} + +// IsResolved reports whether the arena for the given pointer has been resolved. +func (a *Arena) IsResolved(pointerID int) bool { + state, ok := a.arenas[pointerID] + if !ok { + return false + } + return state.resolved +} + +// IsHeld reports whether the arena for the given pointer is held. +func (a *Arena) IsHeld(pointerID int) bool { + state, ok := a.arenas[pointerID] + if !ok { + return false + } + return state.isHeld +} + +// getOrCreateState returns the arena state for the given pointer, creating +// a new open arena if one does not exist. +func (a *Arena) getOrCreateState(pointerID int) *arenaState { + state, ok := a.arenas[pointerID] + if !ok { + state = &arenaState{isOpen: true} + a.arenas[pointerID] = state + } + return state +} + +// tryResolve attempts to auto-resolve the arena if conditions are met: +// - Eager winner exists, or +// - Exactly one member remains. +func (a *Arena) tryResolve(pointerID int, state *arenaState) { + if state.resolved { + return + } + + if state.eagerWinner != nil { + a.resolveWinner(pointerID, state, state.eagerWinner) + return + } + + if len(state.members) == 1 { + a.resolveWinner(pointerID, state, state.members[0]) + } +} + +// resolveWinner declares the winner and rejects all other members. +// +// After resolution, the arena state is marked resolved and losers are +// rejected, but pendingRoute is preserved so the winning recognizer +// continues to receive PointerMove/Up/Cancel events via Route. +// Full cleanup happens in Sweep (after PointerUp) or when the last +// member is removed. +func (a *Arena) resolveWinner(pointerID int, state *arenaState, winner ArenaMember) { + state.resolved = true + + // Reject all losers. + for _, m := range state.members { + if m != winner { + m.RejectGesture(pointerID) + } + } + + // Retain only the winner in the route list so subsequent + // PointerMove/Up events reach it via Route. + a.pendingRoute[pointerID] = []ArenaMember{winner} + + // Accept the winner. + winner.AcceptGesture(pointerID) + + // Clean up arena state (no longer needed for resolution decisions), + // but keep pendingRoute alive for event delivery. + delete(a.arenas, pointerID) +} + +// removeMember removes a member from the arena's member list. +func (a *Arena) removeMember(pointerID int, state *arenaState, member ArenaMember) { + for i, m := range state.members { + if m == member { + state.members = append(state.members[:i], state.members[i+1:]...) + break + } + } + // Also remove from pendingRoute. + if route, ok := a.pendingRoute[pointerID]; ok { + for i, m := range route { + if m == member { + a.pendingRoute[pointerID] = append(route[:i], route[i+1:]...) + break + } + } + } +} + +// cleanup removes all state for a resolved pointer. +func (a *Arena) cleanup(pointerID int) { + delete(a.arenas, pointerID) + delete(a.pendingRoute, pointerID) +} diff --git a/gesture/arena_test.go b/gesture/arena_test.go new file mode 100644 index 0000000..9f7e8ed --- /dev/null +++ b/gesture/arena_test.go @@ -0,0 +1,329 @@ +package gesture + +import ( + "testing" +) + +// mockMember implements ArenaMember for testing. +type mockMember struct { + name string + accepted bool + rejected bool +} + +func newMock(name string) *mockMember { + return &mockMember{name: name} +} + +func (m *mockMember) AcceptGesture(_ int) { m.accepted = true } +func (m *mockMember) RejectGesture(_ int) { m.rejected = true } + +func TestArena_Add(t *testing.T) { + a := NewArena() + m := newMock("A") + + entry := a.Add(1, m) + if entry.PointerID != 1 { + t.Errorf("entry.PointerID = %d, want 1", entry.PointerID) + } + if entry.Member != m { + t.Error("entry.Member != m") + } + if a.MemberCount(1) != 1 { + t.Errorf("MemberCount = %d, want 1", a.MemberCount(1)) + } +} + +func TestArena_Close_SingleMember(t *testing.T) { + a := NewArena() + m := newMock("A") + + a.Add(1, m) + a.Close(1) + + // With only one member, closing should auto-resolve. + if !m.accepted { + t.Error("single member should be accepted after Close") + } + if a.IsResolved(1) { + // Arena should be cleaned up after resolution. + t.Error("resolved arena should be cleaned up") + } +} + +func TestArena_Close_MultipleMembersNoEagerWinner(t *testing.T) { + a := NewArena() + m1 := newMock("A") + m2 := newMock("B") + + a.Add(1, m1) + a.Add(1, m2) + a.Close(1) + + // Two members: no auto-resolve until one accepts/rejects or sweep. + if m1.accepted || m2.accepted { + t.Error("no member should be accepted with 2 members and no eager winner") + } +} + +func TestArena_ResolveAccepted_Open(t *testing.T) { + a := NewArena() + m1 := newMock("A") + m2 := newMock("B") + + a.Add(1, m1) + a.Add(1, m2) + + // Resolve Accepted while arena is still open: store as eager winner. + a.Resolve(1, m1, Accepted) + if m1.accepted { + t.Error("eager winner should not be accepted until arena closes") + } + + // Close triggers eager winner resolution. + a.Close(1) + if !m1.accepted { + t.Error("eager winner should be accepted after Close") + } + if !m2.rejected { + t.Error("loser should be rejected after Close") + } +} + +func TestArena_ResolveAccepted_Closed(t *testing.T) { + a := NewArena() + m1 := newMock("A") + m2 := newMock("B") + + a.Add(1, m1) + a.Add(1, m2) + a.Close(1) + + // Resolve Accepted after arena is closed: wins immediately. + a.Resolve(1, m1, Accepted) + if !m1.accepted { + t.Error("member should be accepted when resolving Accepted on closed arena") + } + if !m2.rejected { + t.Error("loser should be rejected") + } +} + +func TestArena_ResolveRejected(t *testing.T) { + a := NewArena() + m1 := newMock("A") + m2 := newMock("B") + + a.Add(1, m1) + a.Add(1, m2) + a.Close(1) + + a.Resolve(1, m1, Rejected) + if !m1.rejected { + t.Error("rejected member should have RejectGesture called") + } + // Only one member left after close -> auto-resolve. + if !m2.accepted { + t.Error("remaining member should be auto-accepted") + } +} + +func TestArena_Sweep(t *testing.T) { + a := NewArena() + m1 := newMock("A") + m2 := newMock("B") + + a.Add(1, m1) + a.Add(1, m2) + a.Close(1) + + // Sweep gives victory to the first remaining member. + a.Sweep(1) + if !m1.accepted { + t.Error("first member should win sweep") + } + if !m2.rejected { + t.Error("second member should be rejected on sweep") + } +} + +func TestArena_HoldRelease(t *testing.T) { + a := NewArena() + m1 := newMock("A") + m2 := newMock("B") + + a.Add(1, m1) + a.Add(1, m2) + a.Close(1) + + a.Hold(1) + a.Sweep(1) // Should not resolve because held. + + if m1.accepted || m2.accepted { + t.Error("sweep should not resolve a held arena") + } + + a.Release(1) + // Release does not automatically sweep. + if m1.accepted || m2.accepted { + t.Error("release alone should not sweep") + } + + // Manual sweep after release. + a.Sweep(1) + if !m1.accepted { + t.Error("first member should win sweep after release") + } +} + +func TestArena_NoMembersAfterReject(t *testing.T) { + a := NewArena() + m1 := newMock("A") + + a.Add(1, m1) + a.Close(1) + // Single member auto-resolved, arena cleaned up. + + // Sweep on non-existent arena should be a no-op. + a.Sweep(1) +} + +func TestArena_MultiplePointers(t *testing.T) { + a := NewArena() + m1 := newMock("ptr1") + m2 := newMock("ptr2") + + a.Add(1, m1) + a.Add(2, m2) + a.Close(1) + a.Close(2) + + // Each pointer's arena resolves independently. + if !m1.accepted { + t.Error("pointer 1 member should be accepted") + } + if !m2.accepted { + t.Error("pointer 2 member should be accepted") + } +} + +func TestArena_AddAfterCleanup(t *testing.T) { + a := NewArena() + m1 := newMock("A") + + a.Add(1, m1) + a.Close(1) // m1 auto-accepted, arena cleaned up. + + // After cleanup, adding to the same pointer ID creates a fresh arena. + m2 := newMock("B") + a.Add(1, m2) + + if m2.rejected { + t.Error("member added to fresh arena (after cleanup) should not be rejected") + } + if a.MemberCount(1) != 1 { + t.Errorf("MemberCount = %d, want 1", a.MemberCount(1)) + } +} + +func TestArena_MemberCount(t *testing.T) { + a := NewArena() + + if a.MemberCount(1) != 0 { + t.Error("empty arena should have 0 members") + } + + m1 := newMock("A") + m2 := newMock("B") + m3 := newMock("C") + + a.Add(1, m1) + if a.MemberCount(1) != 1 { + t.Errorf("MemberCount = %d, want 1", a.MemberCount(1)) + } + + a.Add(1, m2) + a.Add(1, m3) + if a.MemberCount(1) != 3 { + t.Errorf("MemberCount = %d, want 3", a.MemberCount(1)) + } + + a.Close(1) + a.Resolve(1, m1, Rejected) + if a.MemberCount(1) != 2 { + t.Errorf("MemberCount after reject = %d, want 2", a.MemberCount(1)) + } +} + +func TestArena_IsHeld(t *testing.T) { + a := NewArena() + m := newMock("A") + + if a.IsHeld(1) { + t.Error("non-existent arena should not be held") + } + + a.Add(1, m) + if a.IsHeld(1) { + t.Error("new arena should not be held") + } + + a.Hold(1) + if !a.IsHeld(1) { + t.Error("held arena should report IsHeld") + } + + a.Release(1) + if a.IsHeld(1) { + t.Error("released arena should not be held") + } +} + +func TestArena_AllRejectThenSweep(t *testing.T) { + a := NewArena() + m1 := newMock("A") + m2 := newMock("B") + + a.Add(1, m1) + a.Add(1, m2) + a.Close(1) + + a.Resolve(1, m1, Rejected) + // m2 is the only member after close -> auto-accepted. + if !m2.accepted { + t.Error("last remaining member should be auto-accepted") + } +} + +func TestArena_Route_CallsHandleEvent(t *testing.T) { + a := NewArena() + rec := &routeTestRecognizer{} + + a.Add(1, rec) + + // Route should call HandleEvent on recognizers. + ev := &PointerEvent{ + EventType: PointerMove, + PointerID: 1, + } + a.Route(ev) + + if !rec.handleCalled { + t.Error("Route should call HandleEvent on recognizers") + } +} + +// routeTestRecognizer is a minimal Recognizer for testing Route. +type routeTestRecognizer struct { + handleCalled bool +} + +func (r *routeTestRecognizer) AcceptGesture(_ int) {} +func (r *routeTestRecognizer) RejectGesture(_ int) {} +func (r *routeTestRecognizer) Dispose() {} +func (r *routeTestRecognizer) HandleEvent(_ *PointerEvent) { + r.handleCalled = true +} +func (r *routeTestRecognizer) AddPointer(_ *PointerEvent, _ *Arena) bool { + return true +} diff --git a/gesture/aware.go b/gesture/aware.go new file mode 100644 index 0000000..0691c9c --- /dev/null +++ b/gesture/aware.go @@ -0,0 +1,34 @@ +package gesture + +// GestureAware is an optional interface implemented by widgets that +// participate in the gesture recognition system. +// +// During PointerDown hit-testing, the Window checks each widget in the +// hit-test path for GestureAware. Widgets that implement it have their +// recognizers registered in the gesture arena for that pointer. +// +// Widgets that do not implement GestureAware continue to receive events +// through the existing Event(ctx, event.Event) path unchanged. This is +// the same opt-in pattern used by [widget.Focusable] and +// [widget.RepaintBoundaryMarker]. +// +// Example: +// +// type MyButton struct { +// widget.WidgetBase +// click *gesture.ClickRecognizer +// } +// +// func (b *MyButton) GestureRecognizers() []gesture.Recognizer { +// return []gesture.Recognizer{b.click} +// } +type GestureAware interface { + // GestureRecognizers returns the gesture recognizers owned by this widget. + // Called during PointerDown hit-testing. The returned recognizers are + // added to the gesture arena for the pointer that triggered the event. + // + // Implementations should return the same recognizer instances across + // calls (created once in the constructor or Mount), not new instances + // each time. The arena manages recognizer lifecycle per-pointer. + GestureRecognizers() []Recognizer +} diff --git a/gesture/aware_test.go b/gesture/aware_test.go new file mode 100644 index 0000000..defbd25 --- /dev/null +++ b/gesture/aware_test.go @@ -0,0 +1,66 @@ +package gesture_test + +import ( + "testing" + + "github.com/gogpu/ui/gesture" +) + +// testGestureWidget is a mock widget implementing GestureAware. +type testGestureWidget struct { + recognizers []gesture.Recognizer +} + +func (w *testGestureWidget) GestureRecognizers() []gesture.Recognizer { + return w.recognizers +} + +// Compile-time interface compliance check. +var _ gesture.GestureAware = (*testGestureWidget)(nil) + +func TestGestureAware_InterfaceCompliance(t *testing.T) { + click := gesture.NewClickRecognizer(gesture.ClickConfig{}) + w := &testGestureWidget{ + recognizers: []gesture.Recognizer{click}, + } + + recs := w.GestureRecognizers() + if len(recs) != 1 { + t.Fatalf("GestureRecognizers() returned %d, want 1", len(recs)) + } + if recs[0] != click { + t.Error("GestureRecognizers() returned wrong recognizer") + } +} + +func TestGestureAware_EmptyRecognizers(t *testing.T) { + w := &testGestureWidget{} + recs := w.GestureRecognizers() + if len(recs) != 0 { + t.Errorf("GestureRecognizers() returned %d, want 0", len(recs)) + } +} + +func TestGestureAware_MultipleRecognizers(t *testing.T) { + click := gesture.NewClickRecognizer(gesture.ClickConfig{}) + drag := gesture.NewDragRecognizer(gesture.DragConfig{}) + w := &testGestureWidget{ + recognizers: []gesture.Recognizer{click, drag}, + } + + recs := w.GestureRecognizers() + if len(recs) != 2 { + t.Fatalf("GestureRecognizers() returned %d, want 2", len(recs)) + } +} + +// nonGestureWidget does NOT implement GestureAware. +type nonGestureWidget struct{} + +func TestGestureAware_TypeAssertionFails(t *testing.T) { + w := &nonGestureWidget{} + _, ok := interface{}(w).(gesture.GestureAware) + if ok { + t.Error("nonGestureWidget should not implement GestureAware") + } +} diff --git a/gesture/click.go b/gesture/click.go new file mode 100644 index 0000000..9fa1968 --- /dev/null +++ b/gesture/click.go @@ -0,0 +1,326 @@ +package gesture + +import ( + "time" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/state" +) + +// clickState tracks the state machine of a ClickRecognizer. +type clickState uint8 + +const ( + clickReady clickState = iota // Waiting for pointer down + clickPossible // Pointer down, waiting for arena or movement + clickAccepted // Arena won, waiting for pointer up to fire +) + +// ClickConfig configures a ClickRecognizer. +type ClickConfig struct { + // MaxClickCount caps the click count. Default: 3 (Chromium standard). + // Set to 1 to detect only single clicks. + MaxClickCount int + + // OnClickDown is called when the pointer goes down with the current + // consecutive click count. Useful for visual feedback before the + // arena resolves. + OnClickDown func(details ClickDownDetails) + + // OnClick is called when a click sequence completes (pointer up + // within slop and within timing window). Provides the final click count. + OnClick func(details ClickDetails) + + // OnClickCancel is called if the click is canceled (pointer moved + // beyond slop, arena lost to another recognizer, pointer canceled). + OnClickCancel func() +} + +// ClickDownDetails carries information about a pointer-down in a click sequence. +type ClickDownDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + ClickCount int + PointerType PointerType + Button event.Button + Modifiers event.Modifiers + Timestamp time.Duration +} + +// ClickDetails carries information about a completed click. +type ClickDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + ClickCount int + PointerType PointerType + Button event.Button + Modifiers event.Modifiers + Timestamp time.Duration +} + +// ClickOption configures a ClickRecognizer via functional options. +type ClickOption func(*ClickRecognizer) + +// WithPressedSignal returns a ClickOption that populates the given signal +// with the pressed state (true while pointer is down, false otherwise). +func WithPressedSignal(sig state.Signal[bool]) ClickOption { + return func(r *ClickRecognizer) { r.pressedSignal = sig } +} + +// ClickRecognizer detects single-click, double-click, and triple-click +// sequences. Click count is synthesized from timing and position constraints, +// replacing the platform-dependent MouseDoubleClick event type. +// +// State machine: +// +// ready -> possible (PointerDown, start deadline timer) +// -> accepted (arena won, PointerUp -> fire OnClick with ClickCount) +// -> rejected (moved > slop, canceled, arena lost) +type ClickRecognizer struct { + RecognizerBase + + config ClickConfig + state clickState + + // Current gesture state. + downPosition geometry.Point + downGlobalPos geometry.Point + downButton event.Button + downModifiers event.Modifiers + downTimestamp time.Duration + downPointerType PointerType + currentPointer int + + // Multi-click tracking. + lastUpTimestamp time.Duration + lastUpPosition geometry.Point + lastUpButton event.Button + lastClickCount int + clickCount int + + // Signal support. + pressedSignal state.Signal[bool] +} + +// NewClickRecognizer creates a recognizer that detects click sequences. +func NewClickRecognizer(cfg ClickConfig, opts ...ClickOption) *ClickRecognizer { + if cfg.MaxClickCount <= 0 { + cfg.MaxClickCount = MaxClickCount + } + r := &ClickRecognizer{ + config: cfg, + state: clickReady, + currentPointer: noPointer, + } + for _, opt := range opts { + opt(r) + } + return r +} + +// AddPointer is called when a new pointer goes down. The click recognizer +// is always interested in pointer-down events for click detection. +func (r *ClickRecognizer) AddPointer(ev *PointerEvent, arena *Arena) bool { + if ev.EventType != PointerDown { + return false + } + + r.SetDeviceKind(ev.PointerType) + r.StartTrackingPointer(ev.PointerID, arena, r) + + r.currentPointer = ev.PointerID + r.downPosition = ev.Position + r.downGlobalPos = ev.GlobalPosition + r.downButton = ev.Button + r.downModifiers = ev.Modifiers() + r.downTimestamp = ev.Timestamp + r.downPointerType = ev.PointerType + + // Compute click count from multi-click sequence. + r.clickCount = r.computeClickCount(ev) + r.state = clickPossible + + if r.pressedSignal != nil { + r.pressedSignal.Set(true) + } + + if r.config.OnClickDown != nil { + r.config.OnClickDown(ClickDownDetails{ + GlobalPosition: ev.GlobalPosition, + LocalPosition: ev.Position, + ClickCount: r.clickCount, + PointerType: ev.PointerType, + Button: ev.Button, + Modifiers: ev.Modifiers(), + Timestamp: ev.Timestamp, + }) + } + + return true +} + +// HandleEvent processes pointer events for the tracked pointer. +func (r *ClickRecognizer) HandleEvent(ev *PointerEvent) { + if ev.PointerID != r.currentPointer { + return + } + + switch ev.EventType { + case PointerMove: + r.handleMove(ev) + case PointerUp: + r.handleUp(ev) + case PointerCancel: + r.handleCancel() + } +} + +// AcceptGesture is called when this recognizer wins the arena. +func (r *ClickRecognizer) AcceptGesture(pointerID int) { + if r.state == clickPossible { + r.state = clickAccepted + } +} + +// RejectGesture is called when this recognizer loses the arena. +func (r *ClickRecognizer) RejectGesture(pointerID int) { + r.reset() + if r.config.OnClickCancel != nil { + r.config.OnClickCancel() + } +} + +// Dispose releases resources. +func (r *ClickRecognizer) Dispose() { + r.RecognizerBase.Dispose() + r.pressedSignal = nil +} + +// handleMove checks if the pointer has moved beyond the slop threshold. +func (r *ClickRecognizer) handleMove(ev *PointerEvent) { + if r.state != clickPossible && r.state != clickAccepted { + return + } + + dist := ev.Position.Distance(r.downPosition) + if dist > r.Slop() { + // Moved too far; cancel this click regardless of arena state. + // If still competing in the arena, resolve rejected so the arena + // can auto-resolve remaining members (prevents ghost member). + if r.state == clickPossible { + r.ResolvePointer(r.currentPointer, Rejected, r) + } + r.reset() + if r.config.OnClickCancel != nil { + r.config.OnClickCancel() + } + } +} + +// handleUp completes the click if the recognizer has been accepted. +func (r *ClickRecognizer) handleUp(ev *PointerEvent) { + switch r.state { + case clickPossible: + // Not yet accepted by arena. Resolve as accepted (arena may auto-resolve). + r.ResolvePointer(ev.PointerID, Accepted, r) + // If we got accepted (state changed to clickAccepted), fire the click. + if r.state == clickAccepted { + r.fireClick(ev) + } + case clickAccepted: + r.fireClick(ev) + } +} + +// handleCancel resets the recognizer on pointer cancellation. +func (r *ClickRecognizer) handleCancel() { + r.reset() + if r.config.OnClickCancel != nil { + r.config.OnClickCancel() + } +} + +// fireClick fires the OnClick callback with the current click count. +func (r *ClickRecognizer) fireClick(ev *PointerEvent) { + // Record for next multi-click computation. + r.lastUpTimestamp = ev.Timestamp + r.lastUpPosition = ev.GlobalPosition + r.lastUpButton = r.downButton + r.lastClickCount = r.clickCount + + details := ClickDetails{ + GlobalPosition: ev.GlobalPosition, + LocalPosition: ev.Position, + ClickCount: r.clickCount, + PointerType: r.downPointerType, + Button: r.downButton, + Modifiers: r.downModifiers, + Timestamp: ev.Timestamp, + } + + r.StopTrackingPointer(ev.PointerID) + r.state = clickReady + + if r.pressedSignal != nil { + r.pressedSignal.Set(false) + } + + if r.config.OnClick != nil { + r.config.OnClick(details) + } +} + +// computeClickCount determines the click count for a new pointer-down event +// based on timing and position relative to the last click. +// +// State machine (from ADR-049 Appendix B): +// +// if (elapsed < DoubleTapTimeout AND +// distance < DoubleTapSlop [touch only] AND +// elapsed > DoubleTapMinTime AND +// same button): +// clickCount = min(lastClickCount + 1, MaxClickCount) +// else: +// clickCount = 1 +func (r *ClickRecognizer) computeClickCount(ev *PointerEvent) int { + if r.lastClickCount == 0 { + return 1 + } + + elapsed := ev.Timestamp - r.lastUpTimestamp + if elapsed < DoubleTapMinTime || elapsed > DoubleTapTimeout { + return 1 + } + + // Button must match for multi-click. + if ev.Button != r.lastUpButton { + return 1 + } + + // For touch devices, check spatial constraint. + if ev.PointerType.DeviceKind() == DeviceKindTouch { + dist := ev.GlobalPosition.Distance(r.lastUpPosition) + if dist > DoubleTapSlop { + return 1 + } + } + + next := r.lastClickCount + 1 + if next > r.config.MaxClickCount { + next = r.config.MaxClickCount + } + return next +} + +// reset returns the recognizer to the ready state. +func (r *ClickRecognizer) reset() { + if r.currentPointer != noPointer { + r.StopTrackingPointer(r.currentPointer) + } + r.state = clickReady + r.currentPointer = noPointer + if r.pressedSignal != nil { + r.pressedSignal.Set(false) + } +} diff --git a/gesture/click_test.go b/gesture/click_test.go new file mode 100644 index 0000000..ff0f1f7 --- /dev/null +++ b/gesture/click_test.go @@ -0,0 +1,434 @@ +package gesture + +import ( + "testing" + "time" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" +) + +// makePointerEvent creates a test pointer event. +func makePointerEvent(t PointerEventType, id int, pt PointerType, pos geometry.Point, + btn event.Button, ts time.Duration) *PointerEvent { + return &PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: t, + PointerID: id, + PointerType: pt, + Position: pos, + GlobalPosition: pos, + Button: btn, + Timestamp: ts, + } +} + +func TestClickRecognizer_SingleClick(t *testing.T) { + var gotDetails ClickDetails + + arena := NewArena() + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(d ClickDetails) { + gotDetails = d + }, + }) + + // Pointer down. + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + if !rec.AddPointer(down, arena) { + t.Fatal("AddPointer should return true") + } + arena.Close(1) + + // Pointer up. + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up) + + if gotDetails.ClickCount != 1 { + t.Errorf("ClickCount = %d, want 1", gotDetails.ClickCount) + } + if gotDetails.Button != event.ButtonLeft { + t.Errorf("Button = %v, want Left", gotDetails.Button) + } +} + +func TestClickRecognizer_DoubleClick(t *testing.T) { + var clicks []int + + arena := NewArena() + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(d ClickDetails) { + clicks = append(clicks, d.ClickCount) + }, + }) + + // First click. + t1 := time.Duration(0) + down1 := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t1) + rec.AddPointer(down1, arena) + arena.Close(1) + up1 := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t1+50*time.Millisecond) + rec.HandleEvent(up1) + + // Second click within timeout. + t2 := t1 + 150*time.Millisecond + arena2 := NewArena() + down2 := makePointerEvent(PointerDown, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t2) + rec.AddPointer(down2, arena2) + arena2.Close(2) + up2 := makePointerEvent(PointerUp, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t2+50*time.Millisecond) + rec.HandleEvent(up2) + + if len(clicks) != 2 { + t.Fatalf("got %d clicks, want 2", len(clicks)) + } + if clicks[0] != 1 { + t.Errorf("first click count = %d, want 1", clicks[0]) + } + if clicks[1] != 2 { + t.Errorf("second click count = %d, want 2", clicks[1]) + } +} + +func TestClickRecognizer_TripleClick(t *testing.T) { + var clicks []int + + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(d ClickDetails) { + clicks = append(clicks, d.ClickCount) + }, + }) + + baseT := time.Duration(0) + for i := 0; i < 3; i++ { + ts := baseT + time.Duration(i)*150*time.Millisecond + arena := NewArena() + down := makePointerEvent(PointerDown, i+1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, ts) + rec.AddPointer(down, arena) + arena.Close(i + 1) + up := makePointerEvent(PointerUp, i+1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, ts+50*time.Millisecond) + rec.HandleEvent(up) + } + + if len(clicks) != 3 { + t.Fatalf("got %d clicks, want 3", len(clicks)) + } + expected := []int{1, 2, 3} + for i, want := range expected { + if clicks[i] != want { + t.Errorf("click[%d] = %d, want %d", i, clicks[i], want) + } + } +} + +func TestClickRecognizer_MaxClickCount(t *testing.T) { + tests := []struct { + name string + maxCount int + nClicks int + wantLast int + }{ + {"max_3_with_4_clicks", 3, 4, 3}, + {"max_1_with_2_clicks", 1, 2, 1}, + {"max_2_with_3_clicks", 2, 3, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var lastCount int + rec := NewClickRecognizer(ClickConfig{ + MaxClickCount: tt.maxCount, + OnClick: func(d ClickDetails) { + lastCount = d.ClickCount + }, + }) + + for i := 0; i < tt.nClicks; i++ { + ts := time.Duration(i) * 150 * time.Millisecond + arena := NewArena() + down := makePointerEvent(PointerDown, i+1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, ts) + rec.AddPointer(down, arena) + arena.Close(i + 1) + up := makePointerEvent(PointerUp, i+1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, ts+50*time.Millisecond) + rec.HandleEvent(up) + } + + if lastCount != tt.wantLast { + t.Errorf("last click count = %d, want %d", lastCount, tt.wantLast) + } + }) + } +} + +func TestClickRecognizer_TimingReset(t *testing.T) { + var clicks []int + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(d ClickDetails) { + clicks = append(clicks, d.ClickCount) + }, + }) + + // First click. + arena1 := NewArena() + down1 := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down1, arena1) + arena1.Close(1) + up1 := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up1) + + // Second click after DoubleTapTimeout (too slow). + t2 := 500 * time.Millisecond + arena2 := NewArena() + down2 := makePointerEvent(PointerDown, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t2) + rec.AddPointer(down2, arena2) + arena2.Close(2) + up2 := makePointerEvent(PointerUp, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t2+50*time.Millisecond) + rec.HandleEvent(up2) + + if len(clicks) != 2 { + t.Fatalf("got %d clicks, want 2", len(clicks)) + } + if clicks[1] != 1 { + t.Errorf("second click count = %d, want 1 (reset due to timeout)", clicks[1]) + } +} + +func TestClickRecognizer_DoubleTapMinTime(t *testing.T) { + var clicks []int + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(d ClickDetails) { + clicks = append(clicks, d.ClickCount) + }, + }) + + // First click. + arena1 := NewArena() + down1 := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down1, arena1) + arena1.Close(1) + up1 := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up1) + + // Second click too fast (anti-bounce). + t2 := 50*time.Millisecond + 10*time.Millisecond // 60ms from start, 10ms from up + arena2 := NewArena() + down2 := makePointerEvent(PointerDown, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t2) + rec.AddPointer(down2, arena2) + arena2.Close(2) + up2 := makePointerEvent(PointerUp, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t2+50*time.Millisecond) + rec.HandleEvent(up2) + + if len(clicks) < 2 { + t.Fatalf("got %d clicks, want 2", len(clicks)) + } + if clicks[1] != 1 { + t.Errorf("second click count = %d, want 1 (reset due to anti-bounce)", clicks[1]) + } +} + +func TestClickRecognizer_ButtonMismatch(t *testing.T) { + var clicks []int + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(d ClickDetails) { + clicks = append(clicks, d.ClickCount) + }, + }) + + // First click with left button. + arena1 := NewArena() + down1 := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down1, arena1) + arena1.Close(1) + up1 := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up1) + + // Second click with right button. + t2 := 150 * time.Millisecond + arena2 := NewArena() + down2 := makePointerEvent(PointerDown, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonRight, t2) + rec.AddPointer(down2, arena2) + arena2.Close(2) + up2 := makePointerEvent(PointerUp, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonRight, t2+50*time.Millisecond) + rec.HandleEvent(up2) + + if len(clicks) < 2 { + t.Fatalf("got %d clicks, want 2", len(clicks)) + } + if clicks[1] != 1 { + t.Errorf("second click count = %d, want 1 (reset due to button mismatch)", clicks[1]) + } +} + +func TestClickRecognizer_DistanceResetTouch(t *testing.T) { + var clicks []int + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(d ClickDetails) { + clicks = append(clicks, d.ClickCount) + }, + }) + + // First tap. + arena1 := NewArena() + down1 := makePointerEvent(PointerDown, 1, PointerTypeTouch, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down1, arena1) + arena1.Close(1) + up1 := makePointerEvent(PointerUp, 1, PointerTypeTouch, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up1) + + // Second tap too far away. + t2 := 150 * time.Millisecond + arena2 := NewArena() + down2 := makePointerEvent(PointerDown, 2, PointerTypeTouch, geometry.Pt(200, 200), event.ButtonLeft, t2) + rec.AddPointer(down2, arena2) + arena2.Close(2) + up2 := makePointerEvent(PointerUp, 2, PointerTypeTouch, geometry.Pt(200, 200), event.ButtonLeft, t2+50*time.Millisecond) + rec.HandleEvent(up2) + + if len(clicks) < 2 { + t.Fatalf("got %d clicks, want 2", len(clicks)) + } + if clicks[1] != 1 { + t.Errorf("second click count = %d, want 1 (reset due to distance)", clicks[1]) + } +} + +func TestClickRecognizer_MouseNoDistanceConstraint(t *testing.T) { + var clicks []int + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(d ClickDetails) { + clicks = append(clicks, d.ClickCount) + }, + }) + + // First click. + arena1 := NewArena() + down1 := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down1, arena1) + arena1.Close(1) + up1 := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up1) + + // Second click at distant position (mouse: no distance constraint). + t2 := 150 * time.Millisecond + arena2 := NewArena() + down2 := makePointerEvent(PointerDown, 2, PointerTypeMouse, geometry.Pt(500, 500), event.ButtonLeft, t2) + rec.AddPointer(down2, arena2) + arena2.Close(2) + up2 := makePointerEvent(PointerUp, 2, PointerTypeMouse, geometry.Pt(500, 500), event.ButtonLeft, t2+50*time.Millisecond) + rec.HandleEvent(up2) + + if len(clicks) < 2 { + t.Fatalf("got %d clicks, want 2", len(clicks)) + } + if clicks[1] != 2 { + t.Errorf("second click count = %d, want 2 (mouse has no distance constraint)", clicks[1]) + } +} + +func TestClickRecognizer_CancelOnSlop(t *testing.T) { + var canceled bool + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(_ ClickDetails) { t.Error("OnClick should not be called") }, + OnClickCancel: func() { canceled = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move beyond precise pointer slop (1px). + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(55, 55), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move) + + if !canceled { + t.Error("click should be canceled when pointer moves beyond slop") + } +} + +func TestClickRecognizer_OnClickDown(t *testing.T) { + var gotDown ClickDownDetails + rec := NewClickRecognizer(ClickConfig{ + OnClickDown: func(d ClickDownDetails) { + gotDown = d + }, + OnClick: func(_ ClickDetails) {}, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + + if gotDown.ClickCount != 1 { + t.Errorf("OnClickDown count = %d, want 1", gotDown.ClickCount) + } + if gotDown.Button != event.ButtonLeft { + t.Errorf("OnClickDown button = %v, want Left", gotDown.Button) + } +} + +// TestClickRecognizer_SlopResolvesRejected verifies that when a click +// recognizer exceeds slop in a multi-recognizer arena, it resolves +// Rejected so the arena can auto-accept the remaining recognizer. +// Regression test for Issue 2: ghost member blocking auto-resolution. +func TestClickRecognizer_SlopResolvesRejected(t *testing.T) { + // Two recognizers compete: click + drag. When the click exceeds slop, + // it must resolve Rejected so the drag auto-accepts. + var dragWon bool + + arena := NewArena() + + click := NewClickRecognizer(ClickConfig{ + OnClick: func(_ ClickDetails) { t.Error("click should not fire") }, + }) + drag := newMock("drag") + + // Both register for the same pointer. + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + click.AddPointer(down, arena) + arena.Add(1, drag) + arena.Close(1) + + // Move beyond slop. Click should resolve Rejected. + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(55, 55), event.ButtonLeft, 50*time.Millisecond) + arena.Route(move) + + // After click rejects, drag should be auto-accepted (only member left). + dragWon = drag.accepted + if !dragWon { + t.Error("drag should be auto-accepted after click resolves Rejected on slop") + } +} + +// TestClickDragArena_MovementExceedsSlop exercises a multi-recognizer arena +// with a real ClickRecognizer and DragRecognizer. Movement exceeds slop: +// click rejects -> drag auto-accepts and fires OnDragStart. +func TestClickDragArena_MovementExceedsSlop(t *testing.T) { + var clickFired, dragStarted bool + + arena := NewArena() + + click := NewClickRecognizer(ClickConfig{ + OnClick: func(_ ClickDetails) { clickFired = true }, + }) + drag := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) { dragStarted = true }, + }) + + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + click.AddPointer(down, arena) + drag.AddPointer(down, arena) + arena.Close(1) + + // Move beyond slop (1px for mouse). Both recognizers see the move via Route. + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(55, 55), event.ButtonLeft, 50*time.Millisecond) + arena.Route(move) + + if clickFired { + t.Error("click should not fire after movement exceeds slop") + } + if !dragStarted { + t.Error("drag should start after movement exceeds slop and click rejects") + } +} diff --git a/gesture/constants.go b/gesture/constants.go new file mode 100644 index 0000000..73ab591 --- /dev/null +++ b/gesture/constants.go @@ -0,0 +1,70 @@ +package gesture + +import "time" + +// Timing thresholds (from Flutter constants.dart, source-verified). +const ( + // PressTimeout is the duration before showing visual feedback (ripple). + // The recognizer has not yet won the arena at this point. + PressTimeout = 100 * time.Millisecond + + // LongPressTimeout is the duration a pointer must be held without + // moving beyond slop to trigger a long-press gesture. + LongPressTimeout = 500 * time.Millisecond + + // DoubleTapTimeout is the maximum time between taps for a multi-click + // sequence. If more than this duration elapses between pointer-up and + // the next pointer-down, the click count resets to 1. + DoubleTapTimeout = 300 * time.Millisecond + + // DoubleTapMinTime is the minimum time between taps (anti-bounce). + // Prevents hardware debounce glitches from being counted as double-taps. + DoubleTapMinTime = 40 * time.Millisecond +) + +// Spatial thresholds. +const ( + // TouchSlop is the minimum distance a touch pointer must move to be + // considered a drag rather than a tap. Accounts for finger imprecision. + // 18 logical pixels (Flutter kTouchSlop, Android ViewConfiguration). + TouchSlop float32 = 18.0 + + // PrecisePointerSlop is the minimum distance a mouse or trackpad pointer + // must move to be considered a drag. Much smaller than TouchSlop because + // precise pointers have sub-pixel accuracy. + // 1 logical pixel (Flutter kPrecisePointerHitSlop). + PrecisePointerSlop float32 = 1.0 + + // DoubleTapSlop is the maximum distance between consecutive tap + // positions for them to count as a multi-tap sequence (touch only). + // Mouse has no distance constraint (cursor stays precise). + // 100 logical pixels (Flutter kDoubleTapSlop). + DoubleTapSlop float32 = 100.0 +) + +// Velocity thresholds. +const ( + // MinFlingVelocity is the minimum velocity (px/s) for a fling gesture. + MinFlingVelocity float32 = 50.0 + + // MaxFlingVelocity caps fling velocity to prevent extreme scrolling. + MaxFlingVelocity float32 = 8000.0 +) + +// MaxClickCount is the maximum click count tracked. +// Chromium caps at 3 (single, double, triple). Going higher has no +// standard UI semantic. +const MaxClickCount = 3 + +// noPointer is the sentinel value for "no active pointer". +// PointerID 0 is valid (mouse is typically 1, but the W3C spec allows 0 +// for system-generated events), so -1 is used to mean "no pointer tracked". +const noPointer = -1 + +// SlopForDevice returns the drag detection threshold for the given device kind. +func SlopForDevice(kind DeviceKind) float32 { + if kind == DeviceKindTouch { + return TouchSlop + } + return PrecisePointerSlop +} diff --git a/gesture/doc.go b/gesture/doc.go new file mode 100644 index 0000000..dcde9d2 --- /dev/null +++ b/gesture/doc.go @@ -0,0 +1,44 @@ +// Package gesture implements a gesture recognition system for gogpu/ui. +// +// The gesture package provides infrastructure for recognizing user input +// patterns such as clicks, drags, long presses, and combined tap-and-drag +// sequences from a stream of pointer events. It sits at the infrastructure +// layer alongside focus/, overlay/, state/, and animation/. +// +// # Architecture +// +// The system is based on Flutter's GestureArena protocol (source-verified): +// +// - [Arena] manages gesture disambiguation for a single window. +// - [Recognizer] is the interface for all gesture recognizers. +// - [RecognizerBase] provides shared tracking logic. +// - Concrete recognizers ([ClickRecognizer], [DragRecognizer], +// [LongPressRecognizer], [TapAndDragRecognizer]) implement specific +// gesture patterns. +// +// # Arena Protocol +// +// When a PointerDown event occurs, all interested recognizers register in the +// arena for that pointer ID. As pointer events arrive, recognizers evaluate +// whether the gesture matches their pattern. A recognizer calls +// Arena.Resolve(Accepted) to claim victory or Resolve(Rejected) to withdraw. +// +// - Resolve(Accepted) while arena is open: stored as eager winner. +// - Resolve(Rejected): member removed, RejectGesture called. +// - Arena closes (end of PointerDown dispatch): if 1 member remains, it wins. +// - Sweep (after PointerUp): first remaining member wins. +// - Hold/Release: defers sweep (used by multi-tap between taps). +// +// # Dependency Rules +// +// gesture/ imports only Layer 1 packages (event/, geometry/) and infrastructure +// (state/). It does NOT import widget/, core/, app/, theme/, or any external +// rendering libraries. +// +// # Signals Integration +// +// Gesture recognizers support opt-in reactive signals via functional options. +// For example, [WithDraggingSignal] binds a [state.Signal] to the drag state. +// Signals use equality suppression for bool values to prevent redundant +// notifications. +package gesture diff --git a/gesture/drag.go b/gesture/drag.go new file mode 100644 index 0000000..1b13d7e --- /dev/null +++ b/gesture/drag.go @@ -0,0 +1,377 @@ +package gesture + +import ( + "math" + "time" + + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/state" +) + +// DragDirection constrains which axis the drag recognizer responds to. +type DragDirection uint8 + +const ( + // DragDirectionPan allows drag in both axes. + DragDirectionPan DragDirection = iota + + // DragDirectionHorizontal restricts drag to the horizontal axis. + DragDirectionHorizontal + + // DragDirectionVertical restricts drag to the vertical axis. + DragDirectionVertical +) + +// DragDirection string constants for goconst compliance. +const ( + dragPanStr = "Pan" + dragHorizontalStr = "Horizontal" + dragVerticalStr = "Vertical" +) + +// String returns a human-readable name for the drag direction. +func (d DragDirection) String() string { + switch d { + case DragDirectionPan: + return dragPanStr + case DragDirectionHorizontal: + return dragHorizontalStr + case DragDirectionVertical: + return dragVerticalStr + default: + return pointerUnknownStr + } +} + +// dragState tracks the state machine of a DragRecognizer. +type dragState uint8 + +const ( + dragReady dragState = iota // Waiting for pointer down + dragPossible // Pointer down, accumulating delta + dragAccepted // Drag confirmed, firing updates +) + +// DragConfig configures a DragRecognizer. +type DragConfig struct { + // Direction constrains which axis is recognized. + Direction DragDirection + + // OnDragStart is called when movement exceeds the slop threshold. + OnDragStart func(details DragStartDetails) + + // OnDragUpdate is called for each pointer move during an active drag. + OnDragUpdate func(details DragUpdateDetails) + + // OnDragEnd is called when the pointer is released during a drag. + // Includes velocity for fling detection. + OnDragEnd func(details DragEndDetails) + + // OnDragCancel is called if the drag is canceled. + OnDragCancel func() +} + +// DragStartDetails carries information about the start of a drag. +type DragStartDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + PointerType PointerType + Timestamp time.Duration +} + +// DragUpdateDetails carries information about a drag movement. +type DragUpdateDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + Delta geometry.Point // Movement since last update + PrimaryDelta float32 // Movement along the drag axis + Timestamp time.Duration +} + +// DragEndDetails carries information about the end of a drag. +type DragEndDetails struct { + Velocity geometry.Point // Pixels per second at release + PrimaryVelocity float32 // Velocity along the drag axis +} + +// DragOption configures a DragRecognizer via functional options. +type DragOption func(*DragRecognizer) + +// WithDraggingSignal returns a DragOption that populates the given signal +// with the current drag state (true while dragging, false otherwise). +func WithDraggingSignal(sig state.Signal[bool]) DragOption { + return func(r *DragRecognizer) { r.draggingSignal = sig } +} + +// WithDragPositionSignal returns a DragOption that populates the given +// signal with the current drag position during an active drag. +func WithDragPositionSignal(sig state.Signal[geometry.Point]) DragOption { + return func(r *DragRecognizer) { r.positionSignal = sig } +} + +// DragRecognizer detects drag gestures (pan, vertical-only, horizontal-only). +// Replaces ad-hoc drag logic in Slider, SplitView, ScrollView, and Docking. +// +// State machine: +// +// ready -> possible (PointerDown, accumulate delta) +// -> accepted (delta > slop, fire OnDragStart) +// -> updates (PointerMove while accepted, fire OnDragUpdate) +// -> ended (PointerUp, fire OnDragEnd with velocity) +type DragRecognizer struct { + RecognizerBase + + config DragConfig + state dragState + + // Current gesture tracking. + currentPointer int + wonArena bool // Arena accepted this recognizer (may precede slop). + downPosition geometry.Point + downGlobalPos geometry.Point + downPointerType PointerType + downTimestamp time.Duration + lastPosition geometry.Point + lastGlobalPos geometry.Point + lastTimestamp time.Duration + + // Velocity tracking. + velocity *VelocityTracker + + // Signal support. + draggingSignal state.Signal[bool] + positionSignal state.Signal[geometry.Point] +} + +// NewDragRecognizer creates a recognizer that detects drag gestures. +func NewDragRecognizer(cfg DragConfig, opts ...DragOption) *DragRecognizer { + r := &DragRecognizer{ + config: cfg, + state: dragReady, + velocity: NewVelocityTracker(), + currentPointer: noPointer, + } + for _, opt := range opts { + opt(r) + } + return r +} + +// AddPointer is called when a new pointer goes down. +func (r *DragRecognizer) AddPointer(ev *PointerEvent, arena *Arena) bool { + if ev.EventType != PointerDown { + return false + } + + r.SetDeviceKind(ev.PointerType) + r.StartTrackingPointer(ev.PointerID, arena, r) + + r.currentPointer = ev.PointerID + r.downPosition = ev.Position + r.downGlobalPos = ev.GlobalPosition + r.downPointerType = ev.PointerType + r.downTimestamp = ev.Timestamp + r.lastPosition = ev.Position + r.lastGlobalPos = ev.GlobalPosition + r.lastTimestamp = ev.Timestamp + r.state = dragPossible + + r.velocity.Reset() + r.velocity.AddPosition(ev.Timestamp, ev.GlobalPosition) + + return true +} + +// HandleEvent processes pointer events for the tracked pointer. +func (r *DragRecognizer) HandleEvent(ev *PointerEvent) { + if ev.PointerID != r.currentPointer { + return + } + + switch ev.EventType { + case PointerMove: + r.handleMove(ev) + case PointerUp: + r.handleUp(ev) + case PointerCancel: + r.handleCancel() + } +} + +// AcceptGesture is called when this recognizer wins the arena. +// This may happen before slop is exceeded (single-member auto-accept). +// Actual drag start is deferred until slop is exceeded. +func (r *DragRecognizer) AcceptGesture(pointerID int) { + r.wonArena = true + // Do NOT transition to dragAccepted until slop is exceeded. + // The drag state machine requires movement beyond slop regardless + // of arena resolution (same behavior as Flutter monodrag.dart). +} + +// RejectGesture is called when this recognizer loses the arena. +func (r *DragRecognizer) RejectGesture(pointerID int) { + r.reset() + if r.config.OnDragCancel != nil { + r.config.OnDragCancel() + } +} + +// Dispose releases resources. +func (r *DragRecognizer) Dispose() { + r.RecognizerBase.Dispose() + r.draggingSignal = nil + r.positionSignal = nil +} + +// handleMove checks for drag start (slop exceeded) and fires updates. +func (r *DragRecognizer) handleMove(ev *PointerEvent) { + r.velocity.AddPosition(ev.Timestamp, ev.GlobalPosition) + + switch r.state { + case dragPossible: + if r.exceedsSlop(ev) { + // Slop exceeded: transition to drag. + if !r.wonArena { + // Still competing in the arena; request acceptance. + r.ResolvePointer(ev.PointerID, Accepted, r) + } + // Whether we already won or just requested, start the drag. + r.state = dragAccepted + r.fireDragStart() + r.fireDragUpdate(ev) + } + case dragAccepted: + r.fireDragUpdate(ev) + } +} + +// handleUp ends the drag or rejects if never started. +func (r *DragRecognizer) handleUp(ev *PointerEvent) { + r.velocity.AddPosition(ev.Timestamp, ev.GlobalPosition) + + switch r.state { + case dragAccepted: + r.fireDragEnd() + case dragPossible: + // Pointer released before slop was exceeded: this was not a drag. + // Resolve as rejected so the arena can auto-resolve remaining + // members (prevents ghost member blocking other recognizers). + if !r.wonArena { + r.ResolvePointer(ev.PointerID, Rejected, r) + } + } + + r.StopTrackingPointer(ev.PointerID) + r.state = dragReady + r.currentPointer = noPointer + r.wonArena = false +} + +// handleCancel resets the recognizer. +func (r *DragRecognizer) handleCancel() { + wasDragging := r.state == dragAccepted + r.reset() + if wasDragging { + if r.config.OnDragCancel != nil { + r.config.OnDragCancel() + } + } +} + +// exceedsSlop checks whether pointer movement exceeds the slop threshold +// along the configured drag axis. +func (r *DragRecognizer) exceedsSlop(ev *PointerEvent) bool { + delta := ev.Position.Sub(r.downPosition) + slop := r.Slop() + + switch r.config.Direction { + case DragDirectionHorizontal: + return float32(math.Abs(float64(delta.X))) > slop + case DragDirectionVertical: + return float32(math.Abs(float64(delta.Y))) > slop + default: // Pan + return delta.Length() > slop + } +} + +// fireDragStart fires the OnDragStart callback and updates signals. +func (r *DragRecognizer) fireDragStart() { + if r.draggingSignal != nil { + r.draggingSignal.Set(true) + } + + if r.config.OnDragStart != nil { + r.config.OnDragStart(DragStartDetails{ + GlobalPosition: r.downGlobalPos, + LocalPosition: r.downPosition, + PointerType: r.downPointerType, + Timestamp: r.downTimestamp, + }) + } +} + +// fireDragUpdate fires the OnDragUpdate callback and updates signals. +func (r *DragRecognizer) fireDragUpdate(ev *PointerEvent) { + delta := ev.Position.Sub(r.lastPosition) + primaryDelta := r.primaryComponent(delta) + + r.lastPosition = ev.Position + r.lastGlobalPos = ev.GlobalPosition + r.lastTimestamp = ev.Timestamp + + if r.positionSignal != nil { + r.positionSignal.Set(ev.Position) + } + + if r.config.OnDragUpdate != nil { + r.config.OnDragUpdate(DragUpdateDetails{ + GlobalPosition: ev.GlobalPosition, + LocalPosition: ev.Position, + Delta: delta, + PrimaryDelta: primaryDelta, + Timestamp: ev.Timestamp, + }) + } +} + +// fireDragEnd fires the OnDragEnd callback and updates signals. +func (r *DragRecognizer) fireDragEnd() { + vel := r.velocity.Velocity() + primaryVel := r.primaryComponent(vel) + + if r.draggingSignal != nil { + r.draggingSignal.Set(false) + } + + if r.config.OnDragEnd != nil { + r.config.OnDragEnd(DragEndDetails{ + Velocity: vel, + PrimaryVelocity: primaryVel, + }) + } +} + +// primaryComponent extracts the component along the configured drag axis. +func (r *DragRecognizer) primaryComponent(p geometry.Point) float32 { + switch r.config.Direction { + case DragDirectionHorizontal: + return p.X + case DragDirectionVertical: + return p.Y + default: + return p.Length() + } +} + +// reset returns the recognizer to the ready state. +func (r *DragRecognizer) reset() { + if r.currentPointer != noPointer { + r.StopTrackingPointer(r.currentPointer) + } + r.state = dragReady + r.currentPointer = noPointer + r.wonArena = false + if r.draggingSignal != nil { + r.draggingSignal.Set(false) + } +} diff --git a/gesture/drag_test.go b/gesture/drag_test.go new file mode 100644 index 0000000..174f124 --- /dev/null +++ b/gesture/drag_test.go @@ -0,0 +1,401 @@ +package gesture + +import ( + "math" + "testing" + "time" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/state" +) + +func TestDragRecognizer_PanDrag(t *testing.T) { + var started, ended bool + var updates []DragUpdateDetails + + arena := NewArena() + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) { + started = true + }, + OnDragUpdate: func(d DragUpdateDetails) { + updates = append(updates, d) + }, + OnDragEnd: func(_ DragEndDetails) { + ended = true + }, + }) + + // Pointer down. + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move beyond slop (1px for mouse). + move1 := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(55, 55), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move1) + + if !started { + t.Error("drag should have started after exceeding slop") + } + + // Another move. + move2 := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(60, 60), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(move2) + + if len(updates) < 1 { + t.Fatal("should have received drag updates") + } + + // Pointer up. + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(60, 60), event.ButtonLeft, 150*time.Millisecond) + rec.HandleEvent(up) + + if !ended { + t.Error("drag should have ended on pointer up") + } +} + +func TestDragRecognizer_HorizontalOnly(t *testing.T) { + var started bool + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionHorizontal, + OnDragStart: func(_ DragStartDetails) { started = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move vertically only: should not trigger horizontal drag. + moveV := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(50, 60), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(moveV) + + if started { + t.Error("vertical movement should not trigger horizontal drag") + } + + // Move horizontally: should trigger. + moveH := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(55, 60), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(moveH) + + if !started { + t.Error("horizontal movement should trigger horizontal drag") + } +} + +func TestDragRecognizer_VerticalOnly(t *testing.T) { + var started bool + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionVertical, + OnDragStart: func(_ DragStartDetails) { started = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move horizontally only: should not trigger. + moveH := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(55, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(moveH) + + if started { + t.Error("horizontal movement should not trigger vertical drag") + } + + // Move vertically: should trigger. + moveV := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(55, 55), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(moveV) + + if !started { + t.Error("vertical movement should trigger vertical drag") + } +} + +func TestDragRecognizer_TouchSlop(t *testing.T) { + var started bool + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) { started = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeTouch, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move 10px (less than touch slop of 18px). + move1 := makePointerEvent(PointerMove, 1, PointerTypeTouch, geometry.Pt(60, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move1) + + if started { + t.Error("10px movement should not exceed touch slop of 18px") + } + + // Move beyond 18px total. + move2 := makePointerEvent(PointerMove, 1, PointerTypeTouch, geometry.Pt(70, 50), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(move2) + + if !started { + t.Error("20px movement should exceed touch slop of 18px") + } +} + +func TestDragRecognizer_RejectOnUp(t *testing.T) { + // Pointer up without exceeding slop should reject. + var canceled bool + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragCancel: func() { canceled = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Up without moving. + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up) + + if canceled { + t.Error("OnDragCancel should not fire on normal rejection (no drag started)") + } +} + +func TestDragRecognizer_Velocity(t *testing.T) { + var endDetails DragEndDetails + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) {}, + OnDragEnd: func(d DragEndDetails) { + endDetails = d + }, + }) + + arena := NewArena() + // Simulate 100px horizontal in 100ms = 1000px/s. + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(0, 0), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + for i := 1; i <= 5; i++ { + ts := time.Duration(i*20) * time.Millisecond + pos := geometry.Pt(float32(i*20), 0) + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, pos, event.ButtonLeft, ts) + rec.HandleEvent(move) + } + + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(100, 0), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(up) + + if math.Abs(float64(endDetails.Velocity.X)-1000) > 100 { + t.Errorf("end velocity X = %.1f, want ~1000", endDetails.Velocity.X) + } +} + +func TestDragRecognizer_PrimaryDelta(t *testing.T) { + tests := []struct { + name string + direction DragDirection + delta geometry.Point + wantSign float32 // +1 or -1 for direction + }{ + {"horizontal_right", DragDirectionHorizontal, geometry.Pt(10, 5), 1}, + {"horizontal_left", DragDirectionHorizontal, geometry.Pt(-10, 5), -1}, + {"vertical_down", DragDirectionVertical, geometry.Pt(5, 10), 1}, + {"vertical_up", DragDirectionVertical, geometry.Pt(5, -10), -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPrimary float32 + rec := NewDragRecognizer(DragConfig{ + Direction: tt.direction, + OnDragStart: func(_ DragStartDetails) {}, + OnDragUpdate: func(d DragUpdateDetails) { + gotPrimary = d.PrimaryDelta + }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // First move triggers drag start. + moveStart := makePointerEvent(PointerMove, 1, PointerTypeMouse, + geometry.Pt(50+tt.delta.X, 50+tt.delta.Y), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(moveStart) + + // Second move to get a clean delta. + moveDelta := makePointerEvent(PointerMove, 1, PointerTypeMouse, + geometry.Pt(50+tt.delta.X*2, 50+tt.delta.Y*2), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(moveDelta) + + if (tt.wantSign > 0 && gotPrimary <= 0) || (tt.wantSign < 0 && gotPrimary >= 0) { + t.Errorf("primaryDelta = %.1f, want sign %.0f", gotPrimary, tt.wantSign) + } + }) + } +} + +func TestDragRecognizer_DraggingSignal(t *testing.T) { + sig := state.NewSignalWithOptions(false, state.Options[bool]{ + Equal: func(a, b bool) bool { return a == b }, + }) + + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) {}, + OnDragEnd: func(_ DragEndDetails) {}, + }, WithDraggingSignal(sig)) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(0, 0), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + if sig.Get() { + t.Error("signal should be false before drag starts") + } + + // Move to trigger drag. + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(10, 10), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move) + + if !sig.Get() { + t.Error("signal should be true during drag") + } + + // End drag. + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(10, 10), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(up) + + if sig.Get() { + t.Error("signal should be false after drag ends") + } +} + +func TestDragRecognizer_PositionSignal(t *testing.T) { + sig := state.NewSignal(geometry.Point{}) + + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) {}, + OnDragUpdate: func(_ DragUpdateDetails) {}, + OnDragEnd: func(_ DragEndDetails) {}, + }, WithDragPositionSignal(sig)) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(0, 0), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move to start drag and update position. + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(25, 30), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move) + + pos := sig.Get() + if pos.X != 25 || pos.Y != 30 { + t.Errorf("position signal = %v, want (25, 30)", pos) + } +} + +func TestDragRecognizer_Cancel(t *testing.T) { + var canceled bool + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) {}, + OnDragCancel: func() { canceled = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(0, 0), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move to start drag. + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(10, 10), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move) + + // Cancel. + cancel := makePointerEvent(PointerCancel, 1, PointerTypeMouse, geometry.Pt(10, 10), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(cancel) + + if !canceled { + t.Error("OnDragCancel should be called on PointerCancel during active drag") + } +} + +func TestDragRecognizer_IgnoreWrongPointer(t *testing.T) { + var started bool + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) { started = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(0, 0), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move with wrong pointer ID. + move := makePointerEvent(PointerMove, 99, PointerTypeMouse, geometry.Pt(100, 100), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move) + + if started { + t.Error("should ignore events for wrong pointer ID") + } +} + +// TestDragRecognizer_UpWithoutDragResolvesRejected verifies that when a +// drag recognizer receives pointer-up without exceeding slop in a multi- +// recognizer arena, it resolves Rejected so the arena can auto-accept +// the remaining member. +// Regression test for Issue 3: ghost member blocking auto-resolution. +func TestDragRecognizer_UpWithoutDragResolvesRejected(t *testing.T) { + arena := NewArena() + + drag := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + }) + other := newMock("other") + + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + drag.AddPointer(down, arena) + arena.Add(1, other) + arena.Close(1) + + // Pointer up without any movement. Drag should reject (slop not exceeded). + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + arena.Route(up) + + // The other member should be auto-accepted after drag rejects. + if !other.accepted { + t.Error("other member should be auto-accepted after drag resolves Rejected on pointer up without drag") + } +} + +func TestDragDirection_String(t *testing.T) { + tests := []struct { + dir DragDirection + want string + }{ + {DragDirectionPan, "Pan"}, + {DragDirectionHorizontal, "Horizontal"}, + {DragDirectionVertical, "Vertical"}, + {DragDirection(99), "Unknown"}, + } + + for _, tt := range tests { + got := tt.dir.String() + if got != tt.want { + t.Errorf("DragDirection(%d).String() = %q, want %q", tt.dir, got, tt.want) + } + } +} diff --git a/gesture/long_press.go b/gesture/long_press.go new file mode 100644 index 0000000..7502de5 --- /dev/null +++ b/gesture/long_press.go @@ -0,0 +1,289 @@ +package gesture + +import ( + "time" + + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/state" +) + +// longPressState tracks the state machine of a LongPressRecognizer. +type longPressState uint8 + +const ( + longPressReady longPressState = iota // Waiting for pointer down + longPressPossible // Pointer down, waiting for timeout + longPressAccepted // Long press triggered +) + +// LongPressConfig configures a LongPressRecognizer. +type LongPressConfig struct { + // OnLongPressDown is called after PressTimeout (100ms) if the pointer + // is still within slop. Used for visual feedback (ripple, highlight). + OnLongPressDown func(details LongPressDetails) + + // OnLongPress is called when the long-press duration (500ms) is reached. + OnLongPress func(details LongPressDetails) + + // OnLongPressMoveUpdate is called if the pointer moves after a + // long-press has been recognized (long-press-drag). + OnLongPressMoveUpdate func(details LongPressMoveDetails) + + // OnLongPressUp is called when the pointer is released after a long-press. + OnLongPressUp func(details LongPressDetails) + + // OnLongPressCancel is called if the long-press is canceled. + OnLongPressCancel func() +} + +// LongPressDetails carries information about a long-press event. +type LongPressDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + PointerType PointerType +} + +// LongPressMoveDetails carries movement information during a long-press-drag. +type LongPressMoveDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + Delta geometry.Point +} + +// LongPressOption configures a LongPressRecognizer via functional options. +type LongPressOption func(*LongPressRecognizer) + +// WithLongPressActiveSignal returns a LongPressOption that populates the +// given signal with the long-press active state. +func WithLongPressActiveSignal(sig state.Signal[bool]) LongPressOption { + return func(r *LongPressRecognizer) { r.activeSignal = sig } +} + +// LongPressRecognizer detects long-press gestures (hold without moving for +// 500ms). Required for context menus on touch devices. +// +// Timer implementation uses frame-based polling via CheckTimer, called by +// the animation scheduler. The recognizer records the PointerDown timestamp +// and checks elapsed time on each frame tick. This keeps all gesture logic +// on the main thread, avoiding concurrency issues. +type LongPressRecognizer struct { + RecognizerBase + + config LongPressConfig + state longPressState + + // Current gesture tracking. + currentPointer int + downPosition geometry.Point + downGlobalPos geometry.Point + downPointerType PointerType + downTimestamp time.Duration + lastPosition geometry.Point + lastGlobalPos geometry.Point + + // Timer state. + pressDownFired bool // Whether OnLongPressDown has been called (PressTimeout) + longPressFired bool // Whether OnLongPress has been called (LongPressTimeout) + wonArena bool // Arena accepted this recognizer (may precede timeout). + + // Signal support. + activeSignal state.Signal[bool] +} + +// NewLongPressRecognizer creates a recognizer that detects long-press gestures. +func NewLongPressRecognizer(cfg LongPressConfig, opts ...LongPressOption) *LongPressRecognizer { + r := &LongPressRecognizer{ + config: cfg, + state: longPressReady, + currentPointer: noPointer, + } + for _, opt := range opts { + opt(r) + } + return r +} + +// AddPointer is called when a new pointer goes down. +func (r *LongPressRecognizer) AddPointer(ev *PointerEvent, arena *Arena) bool { + if ev.EventType != PointerDown { + return false + } + + r.SetDeviceKind(ev.PointerType) + r.StartTrackingPointer(ev.PointerID, arena, r) + + r.currentPointer = ev.PointerID + r.downPosition = ev.Position + r.downGlobalPos = ev.GlobalPosition + r.downPointerType = ev.PointerType + r.downTimestamp = ev.Timestamp + r.lastPosition = ev.Position + r.lastGlobalPos = ev.GlobalPosition + r.pressDownFired = false + r.longPressFired = false + r.state = longPressPossible + + return true +} + +// HandleEvent processes pointer events for the tracked pointer. +func (r *LongPressRecognizer) HandleEvent(ev *PointerEvent) { + if ev.PointerID != r.currentPointer { + return + } + + switch ev.EventType { + case PointerMove: + r.handleMove(ev) + case PointerUp: + r.handleUp(ev) + case PointerCancel: + r.handleCancel() + } +} + +// CheckTimer checks whether the long-press timeout has been reached. +// Must be called from the animation frame loop with the current timestamp. +// This is the frame-based timer approach (no goroutines). +// +// Returns true if the recognizer needs continued animation frames. +func (r *LongPressRecognizer) CheckTimer(now time.Duration) bool { + if r.state != longPressPossible { + return false + } + + elapsed := now - r.downTimestamp + + // Check PressTimeout (100ms) for visual feedback. + if !r.pressDownFired && elapsed >= PressTimeout { + r.pressDownFired = true + if r.config.OnLongPressDown != nil { + r.config.OnLongPressDown(LongPressDetails{ + GlobalPosition: r.downGlobalPos, + LocalPosition: r.downPosition, + PointerType: r.downPointerType, + }) + } + } + + // Check LongPressTimeout (500ms) for long-press trigger. + if !r.longPressFired && elapsed >= LongPressTimeout { + r.longPressFired = true + r.state = longPressAccepted + + if r.activeSignal != nil { + r.activeSignal.Set(true) + } + + // Resolve as accepted in the arena. + r.ResolvePointer(r.currentPointer, Accepted, r) + + if r.config.OnLongPress != nil { + r.config.OnLongPress(LongPressDetails{ + GlobalPosition: r.downGlobalPos, + LocalPosition: r.downPosition, + PointerType: r.downPointerType, + }) + } + return false // No more animation frames needed. + } + + return true // Continue animation frames. +} + +// AcceptGesture is called when this recognizer wins the arena. +// May happen before LongPressTimeout (single-member auto-accept). +// Actual long press is deferred until timeout via CheckTimer. +func (r *LongPressRecognizer) AcceptGesture(pointerID int) { + r.wonArena = true +} + +// RejectGesture is called when this recognizer loses the arena. +func (r *LongPressRecognizer) RejectGesture(pointerID int) { + r.reset() + if r.config.OnLongPressCancel != nil { + r.config.OnLongPressCancel() + } +} + +// Dispose releases resources. +func (r *LongPressRecognizer) Dispose() { + r.RecognizerBase.Dispose() + r.activeSignal = nil +} + +// handleMove checks slop and fires long-press-drag updates. +func (r *LongPressRecognizer) handleMove(ev *PointerEvent) { + switch r.state { + case longPressPossible: + // Check if pointer moved beyond slop. + dist := ev.Position.Distance(r.downPosition) + if dist > r.Slop() { + // Cancel long press. Resolve rejected in the arena so other + // recognizers can be auto-accepted (prevents ghost member). + r.ResolvePointer(r.currentPointer, Rejected, r) + r.reset() + if r.config.OnLongPressCancel != nil { + r.config.OnLongPressCancel() + } + } + case longPressAccepted: + // Long press is active; fire move updates (long-press-drag). + delta := ev.Position.Sub(r.lastPosition) + r.lastPosition = ev.Position + r.lastGlobalPos = ev.GlobalPosition + + if r.config.OnLongPressMoveUpdate != nil { + r.config.OnLongPressMoveUpdate(LongPressMoveDetails{ + GlobalPosition: ev.GlobalPosition, + LocalPosition: ev.Position, + Delta: delta, + }) + } + } +} + +// handleUp ends the long-press gesture. +func (r *LongPressRecognizer) handleUp(ev *PointerEvent) { + switch r.state { + case longPressAccepted: + if r.config.OnLongPressUp != nil { + r.config.OnLongPressUp(LongPressDetails{ + GlobalPosition: ev.GlobalPosition, + LocalPosition: ev.Position, + PointerType: r.downPointerType, + }) + } + case longPressPossible: + // Pointer released before long press timeout; reject. + r.ResolvePointer(ev.PointerID, Rejected, r) + } + + r.reset() +} + +// handleCancel resets the recognizer. +func (r *LongPressRecognizer) handleCancel() { + wasActive := r.state == longPressAccepted + r.reset() + if wasActive { + if r.config.OnLongPressCancel != nil { + r.config.OnLongPressCancel() + } + } +} + +// reset returns the recognizer to the ready state. +func (r *LongPressRecognizer) reset() { + if r.currentPointer != noPointer { + r.StopTrackingPointer(r.currentPointer) + } + r.state = longPressReady + r.currentPointer = noPointer + r.pressDownFired = false + r.longPressFired = false + r.wonArena = false + if r.activeSignal != nil { + r.activeSignal.Set(false) + } +} diff --git a/gesture/long_press_test.go b/gesture/long_press_test.go new file mode 100644 index 0000000..89d1d43 --- /dev/null +++ b/gesture/long_press_test.go @@ -0,0 +1,287 @@ +package gesture + +import ( + "testing" + "time" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/state" +) + +func TestLongPressRecognizer_BasicLongPress(t *testing.T) { + var pressDownFired, longPressFired, upFired bool + + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPressDown: func(_ LongPressDetails) { + pressDownFired = true + }, + OnLongPress: func(_ LongPressDetails) { + longPressFired = true + }, + OnLongPressUp: func(_ LongPressDetails) { + upFired = true + }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Simulate frame ticks. + // At 50ms: PressTimeout not reached (100ms). + needsMore := rec.CheckTimer(50 * time.Millisecond) + if !needsMore { + t.Error("should need more frames before timeout") + } + if pressDownFired { + t.Error("PressTimeout (100ms) not reached at 50ms") + } + + // At 100ms: PressTimeout reached. + needsMore = rec.CheckTimer(100 * time.Millisecond) + if !needsMore { + t.Error("should still need frames before LongPressTimeout") + } + if !pressDownFired { + t.Error("PressTimeout should fire at 100ms") + } + + // At 499ms: LongPressTimeout not reached. + needsMore = rec.CheckTimer(499 * time.Millisecond) + if !needsMore { + t.Error("should still need frames before 500ms") + } + if longPressFired { + t.Error("LongPressTimeout (500ms) not reached at 499ms") + } + + // At 500ms: LongPressTimeout reached. + needsMore = rec.CheckTimer(500 * time.Millisecond) + if needsMore { + t.Error("should not need more frames after LongPressTimeout") + } + if !longPressFired { + t.Error("LongPressTimeout should fire at 500ms") + } + + // Release. + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 600*time.Millisecond) + rec.HandleEvent(up) + + if !upFired { + t.Error("OnLongPressUp should fire on pointer up after long press") + } +} + +func TestLongPressRecognizer_CancelOnMove(t *testing.T) { + var canceled bool + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPressCancel: func() { canceled = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move beyond slop before timeout. + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(55, 55), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move) + + if !canceled { + t.Error("long press should be canceled when pointer moves beyond slop") + } +} + +func TestLongPressRecognizer_CancelOnEarlyUp(t *testing.T) { + var longPressFired bool + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPress: func(_ LongPressDetails) { longPressFired = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Release before timeout. + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 200*time.Millisecond) + rec.HandleEvent(up) + + if longPressFired { + t.Error("long press should not fire on early release") + } +} + +func TestLongPressRecognizer_LongPressDrag(t *testing.T) { + var moveUpdates []LongPressMoveDetails + + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPress: func(_ LongPressDetails) {}, + OnLongPressMoveUpdate: func(d LongPressMoveDetails) { + moveUpdates = append(moveUpdates, d) + }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Trigger long press. + rec.CheckTimer(500 * time.Millisecond) + + // Move while long press is active (long-press-drag). + move1 := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(60, 60), event.ButtonLeft, 600*time.Millisecond) + rec.HandleEvent(move1) + + move2 := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(70, 70), event.ButtonLeft, 700*time.Millisecond) + rec.HandleEvent(move2) + + if len(moveUpdates) != 2 { + t.Errorf("got %d move updates, want 2", len(moveUpdates)) + } +} + +func TestLongPressRecognizer_TouchSlop(t *testing.T) { + var canceled bool + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPressCancel: func() { canceled = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeTouch, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move 10px (less than touch slop of 18px): should not cancel. + move1 := makePointerEvent(PointerMove, 1, PointerTypeTouch, geometry.Pt(60, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move1) + + if canceled { + t.Error("10px movement should not cancel touch long press (slop is 18px)") + } + + // Move beyond 18px from down position. + move2 := makePointerEvent(PointerMove, 1, PointerTypeTouch, geometry.Pt(70, 50), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(move2) + + if !canceled { + t.Error("20px movement should cancel touch long press") + } +} + +func TestLongPressRecognizer_ActiveSignal(t *testing.T) { + sig := state.NewSignalWithOptions(false, state.Options[bool]{ + Equal: func(a, b bool) bool { return a == b }, + }) + + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPress: func(_ LongPressDetails) {}, + }, WithLongPressActiveSignal(sig)) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + if sig.Get() { + t.Error("signal should be false before long press triggers") + } + + rec.CheckTimer(500 * time.Millisecond) + + if !sig.Get() { + t.Error("signal should be true after long press triggers") + } + + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 600*time.Millisecond) + rec.HandleEvent(up) + + if sig.Get() { + t.Error("signal should be false after pointer up") + } +} + +func TestLongPressRecognizer_PointerCancel(t *testing.T) { + var canceled bool + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPress: func(_ LongPressDetails) {}, + OnLongPressCancel: func() { canceled = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Trigger long press. + rec.CheckTimer(500 * time.Millisecond) + + // Cancel. + cancel := makePointerEvent(PointerCancel, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 600*time.Millisecond) + rec.HandleEvent(cancel) + + if !canceled { + t.Error("OnLongPressCancel should fire on PointerCancel during active long press") + } +} + +func TestLongPressRecognizer_CheckTimerNotPossible(t *testing.T) { + rec := NewLongPressRecognizer(LongPressConfig{}) + + // CheckTimer in ready state should return false. + needsMore := rec.CheckTimer(500 * time.Millisecond) + if needsMore { + t.Error("CheckTimer should return false when not in possible state") + } +} + +// TestLongPressRecognizer_SlopResolvesRejected verifies that when a +// long-press recognizer exceeds slop in a multi-recognizer arena, it +// resolves Rejected so the arena can auto-accept remaining members. +// Regression test for Issue 4: ghost member blocking auto-resolution. +func TestLongPressRecognizer_SlopResolvesRejected(t *testing.T) { + arena := NewArena() + + lp := NewLongPressRecognizer(LongPressConfig{ + OnLongPressCancel: func() {}, + }) + other := newMock("other") + + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + lp.AddPointer(down, arena) + arena.Add(1, other) + arena.Close(1) + + // Move beyond slop. LongPress should resolve Rejected. + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(55, 55), event.ButtonLeft, 50*time.Millisecond) + arena.Route(move) + + // The other member should be auto-accepted after long press rejects. + if !other.accepted { + t.Error("other member should be auto-accepted after long press resolves Rejected on slop") + } +} + +func TestLongPressRecognizer_IgnoreWrongPointer(t *testing.T) { + var canceled bool + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPressCancel: func() { canceled = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move with wrong pointer ID. + move := makePointerEvent(PointerMove, 99, PointerTypeMouse, geometry.Pt(100, 100), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move) + + if canceled { + t.Error("should ignore events for wrong pointer ID") + } +} diff --git a/gesture/pointer_event.go b/gesture/pointer_event.go new file mode 100644 index 0000000..fa281cf --- /dev/null +++ b/gesture/pointer_event.go @@ -0,0 +1,194 @@ +package gesture + +import ( + "fmt" + "time" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" +) + +// PointerEventType indicates the type of pointer event. +type PointerEventType uint8 + +const ( + // PointerDown indicates a pointer became active (button pressed, finger touched). + PointerDown PointerEventType = iota + + // PointerUp indicates a pointer was deactivated (button released, finger lifted). + PointerUp + + // PointerMove indicates a pointer position changed. + PointerMove + + // PointerCancel indicates the system canceled the pointer sequence. + PointerCancel +) + +// PointerEventType string constants for goconst compliance. +const ( + pointerDownStr = "Down" + pointerUpStr = "Up" + pointerMoveStr = "Move" + pointerCancelStr = "Cancel" +) + +// String returns a human-readable name for the pointer event type. +func (t PointerEventType) String() string { + switch t { + case PointerDown: + return pointerDownStr + case PointerUp: + return pointerUpStr + case PointerMove: + return pointerMoveStr + case PointerCancel: + return pointerCancelStr + default: + return pointerUnknownStr + } +} + +// PointerType distinguishes the category of pointing device. +type PointerType uint8 + +const ( + // PointerTypeMouse is a mouse or trackpad pointer. + PointerTypeMouse PointerType = iota + + // PointerTypeTouch is a finger on a touch screen. + PointerTypeTouch + + // PointerTypePen is a stylus or pen input device. + PointerTypePen +) + +// PointerType string constants for goconst compliance. +const ( + pointerMouseStr = "Mouse" + pointerTouchStr = "Touch" + pointerPenStr = "Pen" + pointerUnknownStr = "Unknown" +) + +// String returns a human-readable name for the pointer type. +func (t PointerType) String() string { + switch t { + case PointerTypeMouse: + return pointerMouseStr + case PointerTypeTouch: + return pointerTouchStr + case PointerTypePen: + return pointerPenStr + default: + return pointerUnknownStr + } +} + +// DeviceKind classifies a pointing device for threshold selection. +type DeviceKind uint8 + +const ( + // DeviceKindPrecise classifies mouse and trackpad pointers. + DeviceKindPrecise DeviceKind = iota + + // DeviceKindTouch classifies touch and pen pointers. + DeviceKindTouch +) + +// DeviceKind string constants for goconst compliance. +const ( + devicePreciseStr = "Precise" + deviceTouchStr = "Touch" +) + +// String returns a human-readable name for the device kind. +func (k DeviceKind) String() string { + switch k { + case DeviceKindPrecise: + return devicePreciseStr + case DeviceKindTouch: + return deviceTouchStr + default: + return pointerUnknownStr + } +} + +// DeviceKind returns a classification used for threshold selection. +// Touch and Pen use touch thresholds; Mouse uses precise thresholds. +func (t PointerType) DeviceKind() DeviceKind { + switch t { + case PointerTypeTouch, PointerTypePen: + return DeviceKindTouch + default: + return DeviceKindPrecise + } +} + +// PointerEvent carries unified pointer data for gesture recognition. +// It extends event.Base with W3C Pointer Events Level 3 fields from +// gpucontext.PointerEvent, adding widget-relative positioning. +// +// PointerEvent is the sole input type for all Recognizer implementations. +// The event_bridge constructs these from gpucontext.PointerEvent, enriching +// them with widget-relative coordinates during tree dispatch. +type PointerEvent struct { + event.Base + + // EventType is the pointer event type (Down, Up, Move, Cancel). + EventType PointerEventType + + // PointerID uniquely identifies this pointer across its lifetime. + // Mouse: always 1. Touch: per-finger. Pen: per-stylus. + PointerID int + + // PointerType distinguishes the input device. + PointerType PointerType + + // Position is the pointer location relative to the receiving widget. + Position geometry.Point + + // GlobalPosition is the pointer location in window coordinates. + GlobalPosition geometry.Point + + // Pressure is the normalized pressure (0.0-1.0). + // Mouse: 0.5 when pressed, 0.0 when not. Touch/Pen: actual pressure. + Pressure float32 + + // TiltX is the pen tilt angle around the X axis in degrees (-90 to 90). + TiltX float32 + + // TiltY is the pen tilt angle around the Y axis in degrees (-90 to 90). + TiltY float32 + + // Twist is the pen rotation in degrees (0 to 359). + Twist float32 + + // ContactWidth is the touch contact width in logical pixels. + // 1.0 for devices without contact geometry (mouse). + ContactWidth float32 + + // ContactHeight is the touch contact height in logical pixels. + // 1.0 for devices without contact geometry (mouse). + ContactHeight float32 + + // Button is the button that triggered this event (Down/Up only). + Button event.Button + + // Buttons is the bitmask of all currently pressed buttons. + Buttons event.ButtonState + + // Delta is the relative movement since the last event. + // Non-zero only during pointer-locked mode. + Delta geometry.Point + + // Timestamp is the platform event time for velocity calculation. + // Zero if the platform does not provide timestamps. + Timestamp time.Duration +} + +// String returns a human-readable representation of the pointer event. +func (e *PointerEvent) String() string { + return fmt.Sprintf("PointerEvent{Type: %s, ID: %d, Pointer: %s, Pos: %s}", + e.EventType, e.PointerID, e.PointerType, e.Position) +} diff --git a/gesture/pointer_event_test.go b/gesture/pointer_event_test.go new file mode 100644 index 0000000..e07cdd2 --- /dev/null +++ b/gesture/pointer_event_test.go @@ -0,0 +1,107 @@ +package gesture + +import ( + "testing" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" +) + +func TestPointerEventType_String(t *testing.T) { + tests := []struct { + typ PointerEventType + want string + }{ + {PointerDown, "Down"}, + {PointerUp, "Up"}, + {PointerMove, "Move"}, + {PointerCancel, "Cancel"}, + {PointerEventType(99), "Unknown"}, + } + + for _, tt := range tests { + got := tt.typ.String() + if got != tt.want { + t.Errorf("PointerEventType(%d).String() = %q, want %q", tt.typ, got, tt.want) + } + } +} + +func TestPointerType_String(t *testing.T) { + tests := []struct { + typ PointerType + want string + }{ + {PointerTypeMouse, "Mouse"}, + {PointerTypeTouch, "Touch"}, + {PointerTypePen, "Pen"}, + {PointerType(99), "Unknown"}, + } + + for _, tt := range tests { + got := tt.typ.String() + if got != tt.want { + t.Errorf("PointerType(%d).String() = %q, want %q", tt.typ, got, tt.want) + } + } +} + +func TestDeviceKind_String(t *testing.T) { + tests := []struct { + kind DeviceKind + want string + }{ + {DeviceKindPrecise, "Precise"}, + {DeviceKindTouch, "Touch"}, + {DeviceKind(99), "Unknown"}, + } + + for _, tt := range tests { + got := tt.kind.String() + if got != tt.want { + t.Errorf("DeviceKind(%d).String() = %q, want %q", tt.kind, got, tt.want) + } + } +} + +func TestPointerType_DeviceKind(t *testing.T) { + tests := []struct { + typ PointerType + want DeviceKind + }{ + {PointerTypeMouse, DeviceKindPrecise}, + {PointerTypeTouch, DeviceKindTouch}, + {PointerTypePen, DeviceKindTouch}, + } + + for _, tt := range tests { + got := tt.typ.DeviceKind() + if got != tt.want { + t.Errorf("PointerType(%d).DeviceKind() = %v, want %v", tt.typ, got, tt.want) + } + } +} + +func TestPointerEvent_String(t *testing.T) { + ev := &PointerEvent{ + Base: event.NewBase(event.TypeMouse, event.ModNone), + EventType: PointerDown, + PointerID: 1, + PointerType: PointerTypeMouse, + Position: geometry.Pt(10, 20), + } + + s := ev.String() + if s == "" { + t.Error("String() should return non-empty string") + } +} + +func TestSlopForDevice(t *testing.T) { + if got := SlopForDevice(DeviceKindPrecise); got != PrecisePointerSlop { + t.Errorf("SlopForDevice(Precise) = %f, want %f", got, PrecisePointerSlop) + } + if got := SlopForDevice(DeviceKindTouch); got != TouchSlop { + t.Errorf("SlopForDevice(Touch) = %f, want %f", got, TouchSlop) + } +} diff --git a/gesture/recognizer.go b/gesture/recognizer.go new file mode 100644 index 0000000..f24c1f8 --- /dev/null +++ b/gesture/recognizer.go @@ -0,0 +1,142 @@ +package gesture + +// Recognizer is the interface for all gesture recognizers. +// +// Recognizers are stateful objects that observe a stream of PointerEvents +// and decide whether the sequence matches a specific gesture pattern +// (click, drag, long-press, pinch, etc.). +// +// Lifecycle: +// 1. AddPointer is called for each PointerDown; the recognizer decides +// whether to compete in the arena for this pointer. +// 2. HandleEvent receives all subsequent events for tracked pointers. +// 3. The recognizer calls Arena.Resolve(Accepted) or Resolve(Rejected). +// 4. AcceptGesture/RejectGesture is called by the arena. +// 5. Dispose releases resources when the recognizer is removed. +type Recognizer interface { + ArenaMember + + // AddPointer is called when a new pointer goes down. + // If the recognizer is interested, it should add itself to the arena + // and begin tracking the pointer. If not interested, it should return + // false and will not receive further events for this pointer. + AddPointer(ev *PointerEvent, arena *Arena) bool + + // HandleEvent processes a pointer event for a tracked pointer. + // Called for PointerMove, PointerUp, and PointerCancel after AddPointer + // returned true. + HandleEvent(ev *PointerEvent) + + // Dispose releases resources. Called when the widget is unmounted. + Dispose() +} + +// RecognizerBase provides common functionality for recognizer implementations. +// Embed this in concrete recognizers. +type RecognizerBase struct { + // arena is set when the recognizer joins an arena via StartTrackingPointer. + arena *Arena + + // trackedPointers maps pointer IDs to arena entries. + trackedPointers map[int]ArenaEntry + + // deviceKind is the input device classification, set from the first + // PointerDown event. Determines slop thresholds. + deviceKind DeviceKind + + // memberOverride, when non-nil, is registered as the arena member + // instead of the recognizer itself. Used by Team to ensure the + // teamMember wrapper receives AcceptGesture/RejectGesture calls + // from the arena (enabling captain interception). + memberOverride ArenaMember +} + +// SetMemberOverride sets an ArenaMember that will be registered in the arena +// instead of the recognizer itself. This is used by Team to ensure the +// teamMember wrapper (not the inner recognizer) is the arena participant, +// so that AcceptGesture flows through the captain interception logic. +func (r *RecognizerBase) SetMemberOverride(m ArenaMember) { + r.memberOverride = m +} + +// StartTrackingPointer registers the recognizer in the arena for this pointer. +// The member parameter is the concrete recognizer (or team wrapper) that should +// be registered as the arena member. If a memberOverride is set (via +// SetMemberOverride), it takes precedence over the member parameter. +func (r *RecognizerBase) StartTrackingPointer(pointerID int, arena *Arena, member ArenaMember) { + r.arena = arena + if r.trackedPointers == nil { + r.trackedPointers = make(map[int]ArenaEntry) + } + // If a member override is set (e.g., by Team), register the override + // so the arena calls AcceptGesture/RejectGesture on the wrapper. + registered := member + if r.memberOverride != nil { + registered = r.memberOverride + } + entry := arena.Add(pointerID, registered) + r.trackedPointers[pointerID] = entry +} + +// StopTrackingPointer removes tracking for a pointer. +func (r *RecognizerBase) StopTrackingPointer(pointerID int) { + delete(r.trackedPointers, pointerID) +} + +// IsTrackingPointer reports whether the recognizer is tracking the given pointer. +func (r *RecognizerBase) IsTrackingPointer(pointerID int) bool { + _, ok := r.trackedPointers[pointerID] + return ok +} + +// ResolvePointer resolves the arena for a tracked pointer. +// If a memberOverride is set, the override is used as the member identity +// so the arena correctly matches the registered participant. +func (r *RecognizerBase) ResolvePointer(pointerID int, disposition Disposition, member ArenaMember) { + if r.arena == nil { + return + } + resolved := member + if r.memberOverride != nil { + resolved = r.memberOverride + } + r.arena.Resolve(pointerID, resolved, disposition) +} + +// Slop returns the drag detection threshold for the current device kind. +func (r *RecognizerBase) Slop() float32 { + return SlopForDevice(r.deviceKind) +} + +// SetDeviceKind records the device kind from a pointer event. +func (r *RecognizerBase) SetDeviceKind(pt PointerType) { + r.deviceKind = pt.DeviceKind() +} + +// Dispose resets the base recognizer state. +func (r *RecognizerBase) Dispose() { + r.arena = nil + r.trackedPointers = nil + r.memberOverride = nil +} + +// baseProvider is implemented by recognizers embedding RecognizerBase. +// Used by Team to access the embedded base for setting member overrides. +type baseProvider interface { + base() *RecognizerBase +} + +// base returns a pointer to this RecognizerBase. +// Satisfies the baseProvider interface for all types that embed RecognizerBase. +func (r *RecognizerBase) base() *RecognizerBase { + return r +} + +// recognizerBase extracts the embedded RecognizerBase from a Recognizer. +// Returns nil if the recognizer does not embed RecognizerBase (e.g., a mock). +func recognizerBase(r Recognizer) *RecognizerBase { + if bp, ok := r.(baseProvider); ok { + return bp.base() + } + return nil +} diff --git a/gesture/recognizer_test.go b/gesture/recognizer_test.go new file mode 100644 index 0000000..7c647bd --- /dev/null +++ b/gesture/recognizer_test.go @@ -0,0 +1,380 @@ +package gesture + +import ( + "testing" + "time" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/state" +) + +func TestRecognizerBase_IsTrackingPointer(t *testing.T) { + rb := &RecognizerBase{} + if rb.IsTrackingPointer(1) { + t.Error("should not be tracking any pointer initially") + } + + arena := NewArena() + mock := newMock("A") + rb.StartTrackingPointer(1, arena, mock) + + if !rb.IsTrackingPointer(1) { + t.Error("should be tracking pointer 1") + } + if rb.IsTrackingPointer(2) { + t.Error("should not be tracking pointer 2") + } + + rb.StopTrackingPointer(1) + if rb.IsTrackingPointer(1) { + t.Error("should not be tracking pointer 1 after stop") + } +} + +func TestRecognizerBase_Dispose(t *testing.T) { + rb := &RecognizerBase{} + arena := NewArena() + mock := newMock("A") + rb.StartTrackingPointer(1, arena, mock) + + rb.Dispose() + if rb.arena != nil { + t.Error("arena should be nil after dispose") + } + if rb.trackedPointers != nil { + t.Error("trackedPointers should be nil after dispose") + } +} + +func TestRecognizerBase_ResolvePointerNilArena(t *testing.T) { + rb := &RecognizerBase{} + // Should not panic with nil arena. + rb.ResolvePointer(1, Accepted, newMock("A")) +} + +func TestRecognizerBase_SlopByDevice(t *testing.T) { + rb := &RecognizerBase{deviceKind: DeviceKindPrecise} + if rb.Slop() != PrecisePointerSlop { + t.Errorf("Slop() = %f, want %f", rb.Slop(), PrecisePointerSlop) + } + + rb.SetDeviceKind(PointerTypeTouch) + if rb.Slop() != TouchSlop { + t.Errorf("Slop() = %f, want %f", rb.Slop(), TouchSlop) + } +} + +func TestClickRecognizer_PressedSignal(t *testing.T) { + sig := state.NewSignalWithOptions(false, state.Options[bool]{ + Equal: func(a, b bool) bool { return a == b }, + }) + + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(_ ClickDetails) {}, + }, WithPressedSignal(sig)) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + if !sig.Get() { + t.Error("pressed signal should be true after pointer down") + } + + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up) + + if sig.Get() { + t.Error("pressed signal should be false after click completes") + } +} + +func TestClickRecognizer_PointerCancel(t *testing.T) { + var cancelFired bool + rec := NewClickRecognizer(ClickConfig{ + OnClickCancel: func() { cancelFired = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + cancel := makePointerEvent(PointerCancel, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(cancel) + + if !cancelFired { + t.Error("OnClickCancel should fire on PointerCancel") + } +} + +func TestClickRecognizer_Dispose(t *testing.T) { + rec := NewClickRecognizer(ClickConfig{}) + rec.Dispose() + // Should not panic. +} + +func TestDragRecognizer_Dispose(t *testing.T) { + rec := NewDragRecognizer(DragConfig{}) + rec.Dispose() + // Should not panic. +} + +func TestLongPressRecognizer_Dispose(t *testing.T) { + rec := NewLongPressRecognizer(LongPressConfig{}) + rec.Dispose() + // Should not panic. +} + +func TestTapAndDragRecognizer_Dispose(t *testing.T) { + rec := NewTapAndDragRecognizer(TapAndDragConfig{}) + rec.Dispose() + // Should not panic. +} + +func TestClickRecognizer_IgnoreNonDown(t *testing.T) { + rec := NewClickRecognizer(ClickConfig{}) + arena := NewArena() + // AddPointer with a move event should return false. + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + if rec.AddPointer(move, arena) { + t.Error("AddPointer should return false for non-down events") + } +} + +func TestDragRecognizer_IgnoreNonDown(t *testing.T) { + rec := NewDragRecognizer(DragConfig{}) + arena := NewArena() + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + if rec.AddPointer(up, arena) { + t.Error("AddPointer should return false for non-down events") + } +} + +func TestLongPressRecognizer_IgnoreNonDown(t *testing.T) { + rec := NewLongPressRecognizer(LongPressConfig{}) + arena := NewArena() + cancel := makePointerEvent(PointerCancel, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + if rec.AddPointer(cancel, arena) { + t.Error("AddPointer should return false for non-down events") + } +} + +func TestTapAndDragRecognizer_IgnoreNonDown(t *testing.T) { + rec := NewTapAndDragRecognizer(TapAndDragConfig{}) + arena := NewArena() + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + if rec.AddPointer(move, arena) { + t.Error("AddPointer should return false for non-down events") + } +} + +func TestClickRecognizer_RejectGestureViaArena(t *testing.T) { + var cancelFired bool + rec := NewClickRecognizer(ClickConfig{ + OnClickCancel: func() { cancelFired = true }, + }) + + // Two recognizers compete; one wins, one loses. + winner := newMock("winner") + arena := NewArena() + + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Add(1, winner) + arena.Close(1) + + // Winner takes the arena. + arena.Resolve(1, winner, Accepted) + + if !cancelFired { + t.Error("OnClickCancel should fire when arena rejects the click recognizer") + } +} + +func TestDragRecognizer_RejectGestureViaArena(t *testing.T) { + var cancelFired bool + rec := NewDragRecognizer(DragConfig{ + OnDragCancel: func() { cancelFired = true }, + }) + + winner := newMock("winner") + arena := NewArena() + + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Add(1, winner) + arena.Close(1) + + arena.Resolve(1, winner, Accepted) + + if !cancelFired { + t.Error("OnDragCancel should fire when arena rejects the drag recognizer") + } +} + +func TestLongPressRecognizer_RejectGestureViaArena(t *testing.T) { + var cancelFired bool + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPressCancel: func() { cancelFired = true }, + }) + + winner := newMock("winner") + arena := NewArena() + + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Add(1, winner) + arena.Close(1) + + arena.Resolve(1, winner, Accepted) + + if !cancelFired { + t.Error("OnLongPressCancel should fire when arena rejects the long press recognizer") + } +} + +// TestNoPointerSentinel verifies that recognizers use -1 (noPointer) as +// the sentinel for "no active pointer", not 0. PointerID 0 is valid +// per W3C Pointer Events spec. +func TestNoPointerSentinel(t *testing.T) { + t.Run("click_accepts_pointer_zero", func(t *testing.T) { + var clicked bool + rec := NewClickRecognizer(ClickConfig{ + OnClick: func(_ ClickDetails) { clicked = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 0, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + if !rec.AddPointer(down, arena) { + t.Fatal("AddPointer should accept PointerID=0") + } + arena.Close(0) + + up := makePointerEvent(PointerUp, 0, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up) + + if !clicked { + t.Error("click should fire for PointerID=0") + } + }) + + t.Run("drag_accepts_pointer_zero", func(t *testing.T) { + var started bool + rec := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) { started = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 0, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + if !rec.AddPointer(down, arena) { + t.Fatal("AddPointer should accept PointerID=0") + } + arena.Close(0) + + move := makePointerEvent(PointerMove, 0, PointerTypeMouse, geometry.Pt(55, 55), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move) + + if !started { + t.Error("drag should start for PointerID=0") + } + }) + + t.Run("longpress_accepts_pointer_zero", func(t *testing.T) { + var fired bool + rec := NewLongPressRecognizer(LongPressConfig{ + OnLongPress: func(_ LongPressDetails) { fired = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 0, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + if !rec.AddPointer(down, arena) { + t.Fatal("AddPointer should accept PointerID=0") + } + arena.Close(0) + + rec.CheckTimer(500 * time.Millisecond) + + if !fired { + t.Error("long press should fire for PointerID=0") + } + }) +} + +// TestRecognizerBase_MemberOverride verifies that SetMemberOverride +// causes StartTrackingPointer to register the override in the arena. +func TestRecognizerBase_MemberOverride(t *testing.T) { + rb := &RecognizerBase{} + override := newMock("override") + rb.SetMemberOverride(override) + + arena := NewArena() + selfMock := newMock("self") + rb.StartTrackingPointer(1, arena, selfMock) + arena.Close(1) + + // The override should be accepted (it's the registered member), + // not the selfMock that was passed as the member parameter. + if !override.accepted { + t.Error("override should be accepted as the arena member") + } + if selfMock.accepted { + t.Error("self should not be accepted when override is set") + } +} + +// TestRecognizerBase_ResolvePointerUsesOverride verifies that +// ResolvePointer uses the member override for arena identity matching. +func TestRecognizerBase_ResolvePointerUsesOverride(t *testing.T) { + rb := &RecognizerBase{} + override := newMock("override") + rb.SetMemberOverride(override) + + arena := NewArena() + other := newMock("other") + rb.StartTrackingPointer(1, arena, newMock("self")) + arena.Add(1, other) + arena.Close(1) + + // Resolve rejected. Since override is the registered member, + // the arena should remove the override and auto-accept other. + rb.ResolvePointer(1, Rejected, newMock("self")) + + if !other.accepted { + t.Error("other should be auto-accepted after override resolves Rejected") + } +} + +func TestArena_RouteNonExistentPointer(t *testing.T) { + a := NewArena() + ev := &PointerEvent{EventType: PointerMove, PointerID: 99} + // Should not panic. + a.Route(ev) +} + +func TestArena_CloseNonExistent(t *testing.T) { + a := NewArena() + // Should not panic. + a.Close(99) +} + +func TestArena_SweepNonExistent(t *testing.T) { + a := NewArena() + // Should not panic. + a.Sweep(99) +} + +func TestArena_HoldNonExistent(t *testing.T) { + a := NewArena() + // Should not panic. + a.Hold(99) +} + +func TestArena_ReleaseNonExistent(t *testing.T) { + a := NewArena() + // Should not panic. + a.Release(99) +} diff --git a/gesture/tap_and_drag.go b/gesture/tap_and_drag.go new file mode 100644 index 0000000..164812e --- /dev/null +++ b/gesture/tap_and_drag.go @@ -0,0 +1,332 @@ +package gesture + +import ( + "time" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" +) + +// tapDragState tracks the state machine of a TapAndDragRecognizer. +type tapDragState uint8 + +const ( + tapDragReady tapDragState = iota // Waiting for pointer down + tapDragPossible // Pointer down, waiting for slop or up + tapDragDragging // Drag confirmed with tap count +) + +// TapAndDragConfig configures a TapAndDragRecognizer. +type TapAndDragConfig struct { + // OnTapDown is called on pointer down with the current tap count. + OnTapDown func(details TapDragDownDetails) + + // OnTapUp is called on pointer up without exceeding drag slop. + OnTapUp func(details TapDragUpDetails) + + // OnDragStart is called when movement exceeds slop during a tap sequence. + OnDragStart func(details TapDragStartDetails) + + // OnDragUpdate is called for each move during a tap-drag. + OnDragUpdate func(details TapDragUpdateDetails) + + // OnDragEnd is called when the pointer is released during a drag. + OnDragEnd func(details TapDragEndDetails) + + // OnCancel is called if the gesture is canceled. + OnCancel func() +} + +// TapDragDownDetails carries pointer-down information with tap count. +type TapDragDownDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + ConsecutiveTapCount int // 1=single, 2=double, 3=triple + PointerType PointerType + Button event.Button + Modifiers event.Modifiers +} + +// TapDragUpDetails carries pointer-up information with tap count. +type TapDragUpDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + ConsecutiveTapCount int +} + +// TapDragStartDetails carries drag-start information with tap count. +type TapDragStartDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + ConsecutiveTapCount int + PointerType PointerType +} + +// TapDragUpdateDetails carries drag-update information with tap count. +type TapDragUpdateDetails struct { + GlobalPosition geometry.Point + LocalPosition geometry.Point + Delta geometry.Point + ConsecutiveTapCount int +} + +// TapDragEndDetails carries drag-end information. +type TapDragEndDetails struct { + Velocity geometry.Point + ConsecutiveTapCount int +} + +// TapAndDragRecognizer combines click-count tracking with drag detection. +// Every callback receives ConsecutiveTapCount, enabling: +// - Double-tap + drag = word-by-word selection (TextField) +// - Triple-tap + drag = line-by-line selection (TextField) +// +// This is the Flutter TapAndDragGestureRecognizer pattern. +type TapAndDragRecognizer struct { + RecognizerBase + + config TapAndDragConfig + state tapDragState + + // Current gesture tracking. + currentPointer int + downPosition geometry.Point + downGlobalPos geometry.Point + downPointerType PointerType + downButton event.Button + downModifiers event.Modifiers + downTimestamp time.Duration + lastPosition geometry.Point + lastGlobalPos geometry.Point + + // Multi-tap tracking (same logic as ClickRecognizer). + lastUpTimestamp time.Duration + lastUpPosition geometry.Point + lastUpButton event.Button + lastTapCount int + consecutiveTapCount int + + // Velocity tracking. + velocity *VelocityTracker +} + +// NewTapAndDragRecognizer creates a combined tap-and-drag recognizer. +func NewTapAndDragRecognizer(cfg TapAndDragConfig) *TapAndDragRecognizer { + return &TapAndDragRecognizer{ + config: cfg, + state: tapDragReady, + velocity: NewVelocityTracker(), + currentPointer: noPointer, + } +} + +// AddPointer is called when a new pointer goes down. +func (r *TapAndDragRecognizer) AddPointer(ev *PointerEvent, arena *Arena) bool { + if ev.EventType != PointerDown { + return false + } + + r.SetDeviceKind(ev.PointerType) + r.StartTrackingPointer(ev.PointerID, arena, r) + + r.currentPointer = ev.PointerID + r.downPosition = ev.Position + r.downGlobalPos = ev.GlobalPosition + r.downPointerType = ev.PointerType + r.downButton = ev.Button + r.downModifiers = ev.Modifiers() + r.downTimestamp = ev.Timestamp + r.lastPosition = ev.Position + r.lastGlobalPos = ev.GlobalPosition + + r.velocity.Reset() + r.velocity.AddPosition(ev.Timestamp, ev.GlobalPosition) + + // Compute consecutive tap count. + r.consecutiveTapCount = r.computeTapCount(ev) + r.state = tapDragPossible + + if r.config.OnTapDown != nil { + r.config.OnTapDown(TapDragDownDetails{ + GlobalPosition: ev.GlobalPosition, + LocalPosition: ev.Position, + ConsecutiveTapCount: r.consecutiveTapCount, + PointerType: ev.PointerType, + Button: ev.Button, + Modifiers: ev.Modifiers(), + }) + } + + return true +} + +// HandleEvent processes pointer events for the tracked pointer. +func (r *TapAndDragRecognizer) HandleEvent(ev *PointerEvent) { + if ev.PointerID != r.currentPointer { + return + } + + switch ev.EventType { + case PointerMove: + r.handleMove(ev) + case PointerUp: + r.handleUp(ev) + case PointerCancel: + r.handleCancel() + } +} + +// AcceptGesture is called when this recognizer wins the arena. +func (r *TapAndDragRecognizer) AcceptGesture(pointerID int) { + // State transitions happen in handleMove/handleUp. +} + +// RejectGesture is called when this recognizer loses the arena. +func (r *TapAndDragRecognizer) RejectGesture(pointerID int) { + r.resetState() + if r.config.OnCancel != nil { + r.config.OnCancel() + } +} + +// Dispose releases resources. +func (r *TapAndDragRecognizer) Dispose() { + r.RecognizerBase.Dispose() +} + +// handleMove checks for drag start and fires updates. +func (r *TapAndDragRecognizer) handleMove(ev *PointerEvent) { + r.velocity.AddPosition(ev.Timestamp, ev.GlobalPosition) + + switch r.state { + case tapDragPossible: + delta := ev.Position.Sub(r.downPosition) + if delta.Length() > r.Slop() { + r.state = tapDragDragging + r.ResolvePointer(ev.PointerID, Accepted, r) + r.fireDragStart() + r.fireDragUpdate(ev) + } + case tapDragDragging: + r.fireDragUpdate(ev) + } +} + +// handleUp completes the tap or drag. +func (r *TapAndDragRecognizer) handleUp(ev *PointerEvent) { + r.velocity.AddPosition(ev.Timestamp, ev.GlobalPosition) + + switch r.state { + case tapDragPossible: + // Pointer released without dragging: this is a tap. + r.ResolvePointer(ev.PointerID, Accepted, r) + + // Record for next multi-tap computation. + r.lastUpTimestamp = ev.Timestamp + r.lastUpPosition = ev.GlobalPosition + r.lastUpButton = r.downButton + r.lastTapCount = r.consecutiveTapCount + + if r.config.OnTapUp != nil { + r.config.OnTapUp(TapDragUpDetails{ + GlobalPosition: ev.GlobalPosition, + LocalPosition: ev.Position, + ConsecutiveTapCount: r.consecutiveTapCount, + }) + } + + case tapDragDragging: + // End the drag. + vel := r.velocity.Velocity() + r.lastUpTimestamp = ev.Timestamp + r.lastUpPosition = ev.GlobalPosition + r.lastUpButton = r.downButton + r.lastTapCount = r.consecutiveTapCount + + if r.config.OnDragEnd != nil { + r.config.OnDragEnd(TapDragEndDetails{ + Velocity: vel, + ConsecutiveTapCount: r.consecutiveTapCount, + }) + } + } + + r.StopTrackingPointer(ev.PointerID) + r.state = tapDragReady + r.currentPointer = noPointer +} + +// handleCancel resets the recognizer. +func (r *TapAndDragRecognizer) handleCancel() { + r.resetState() + if r.config.OnCancel != nil { + r.config.OnCancel() + } +} + +// fireDragStart fires the OnDragStart callback. +func (r *TapAndDragRecognizer) fireDragStart() { + if r.config.OnDragStart != nil { + r.config.OnDragStart(TapDragStartDetails{ + GlobalPosition: r.downGlobalPos, + LocalPosition: r.downPosition, + ConsecutiveTapCount: r.consecutiveTapCount, + PointerType: r.downPointerType, + }) + } +} + +// fireDragUpdate fires the OnDragUpdate callback. +func (r *TapAndDragRecognizer) fireDragUpdate(ev *PointerEvent) { + delta := ev.Position.Sub(r.lastPosition) + r.lastPosition = ev.Position + r.lastGlobalPos = ev.GlobalPosition + + if r.config.OnDragUpdate != nil { + r.config.OnDragUpdate(TapDragUpdateDetails{ + GlobalPosition: ev.GlobalPosition, + LocalPosition: ev.Position, + Delta: delta, + ConsecutiveTapCount: r.consecutiveTapCount, + }) + } +} + +// computeTapCount determines the consecutive tap count for a new pointer-down. +func (r *TapAndDragRecognizer) computeTapCount(ev *PointerEvent) int { + if r.lastTapCount == 0 { + return 1 + } + + elapsed := ev.Timestamp - r.lastUpTimestamp + if elapsed < DoubleTapMinTime || elapsed > DoubleTapTimeout { + return 1 + } + + if ev.Button != r.lastUpButton { + return 1 + } + + // For touch devices, check spatial constraint. + if ev.PointerType.DeviceKind() == DeviceKindTouch { + dist := ev.GlobalPosition.Distance(r.lastUpPosition) + if dist > DoubleTapSlop { + return 1 + } + } + + next := r.lastTapCount + 1 + if next > MaxClickCount { + return 1 + } + return next +} + +// resetState returns the recognizer to the ready state. +func (r *TapAndDragRecognizer) resetState() { + if r.currentPointer != noPointer { + r.StopTrackingPointer(r.currentPointer) + } + r.state = tapDragReady + r.currentPointer = noPointer +} diff --git a/gesture/tap_and_drag_test.go b/gesture/tap_and_drag_test.go new file mode 100644 index 0000000..1ec9d84 --- /dev/null +++ b/gesture/tap_and_drag_test.go @@ -0,0 +1,295 @@ +package gesture + +import ( + "testing" + "time" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" +) + +func TestTapAndDrag_SingleTap(t *testing.T) { + var tapDownCount, tapUpCount int + + rec := NewTapAndDragRecognizer(TapAndDragConfig{ + OnTapDown: func(d TapDragDownDetails) { + tapDownCount = d.ConsecutiveTapCount + }, + OnTapUp: func(d TapDragUpDetails) { + tapUpCount = d.ConsecutiveTapCount + }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up) + + if tapDownCount != 1 { + t.Errorf("tapDownCount = %d, want 1", tapDownCount) + } + if tapUpCount != 1 { + t.Errorf("tapUpCount = %d, want 1", tapUpCount) + } +} + +func TestTapAndDrag_DoubleTapDrag(t *testing.T) { + var dragStartCount int + var dragUpdates []TapDragUpdateDetails + + rec := NewTapAndDragRecognizer(TapAndDragConfig{ + OnTapDown: func(_ TapDragDownDetails) {}, + OnTapUp: func(_ TapDragUpDetails) {}, + OnDragStart: func(d TapDragStartDetails) { + dragStartCount = d.ConsecutiveTapCount + }, + OnDragUpdate: func(d TapDragUpdateDetails) { + dragUpdates = append(dragUpdates, d) + }, + OnDragEnd: func(_ TapDragEndDetails) {}, + }) + + // First tap. + arena1 := NewArena() + down1 := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down1, arena1) + arena1.Close(1) + up1 := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up1) + + // Second tap -> drag (double-tap-drag for word selection). + t2 := 150 * time.Millisecond + arena2 := NewArena() + down2 := makePointerEvent(PointerDown, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t2) + rec.AddPointer(down2, arena2) + arena2.Close(2) + + // Move beyond slop to start drag. + move := makePointerEvent(PointerMove, 2, PointerTypeMouse, geometry.Pt(60, 50), event.ButtonLeft, t2+50*time.Millisecond) + rec.HandleEvent(move) + + if dragStartCount != 2 { + t.Errorf("drag start consecutiveTapCount = %d, want 2", dragStartCount) + } + + // Continue drag. + move2 := makePointerEvent(PointerMove, 2, PointerTypeMouse, geometry.Pt(80, 50), event.ButtonLeft, t2+100*time.Millisecond) + rec.HandleEvent(move2) + + if len(dragUpdates) == 0 { + t.Fatal("should have received drag updates") + } + if dragUpdates[len(dragUpdates)-1].ConsecutiveTapCount != 2 { + t.Errorf("drag update consecutiveTapCount = %d, want 2", dragUpdates[len(dragUpdates)-1].ConsecutiveTapCount) + } + + // End drag. + up2 := makePointerEvent(PointerUp, 2, PointerTypeMouse, geometry.Pt(80, 50), event.ButtonLeft, t2+150*time.Millisecond) + rec.HandleEvent(up2) +} + +func TestTapAndDrag_TripleTap(t *testing.T) { + var lastTapUpCount int + + rec := NewTapAndDragRecognizer(TapAndDragConfig{ + OnTapDown: func(_ TapDragDownDetails) {}, + OnTapUp: func(d TapDragUpDetails) { + lastTapUpCount = d.ConsecutiveTapCount + }, + }) + + for i := 0; i < 3; i++ { + ts := time.Duration(i) * 150 * time.Millisecond + arena := NewArena() + down := makePointerEvent(PointerDown, i+1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, ts) + rec.AddPointer(down, arena) + arena.Close(i + 1) + up := makePointerEvent(PointerUp, i+1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, ts+50*time.Millisecond) + rec.HandleEvent(up) + } + + if lastTapUpCount != 3 { + t.Errorf("triple tap count = %d, want 3", lastTapUpCount) + } +} + +func TestTapAndDrag_TapCountReset(t *testing.T) { + var tapCounts []int + + rec := NewTapAndDragRecognizer(TapAndDragConfig{ + OnTapDown: func(d TapDragDownDetails) { + tapCounts = append(tapCounts, d.ConsecutiveTapCount) + }, + OnTapUp: func(_ TapDragUpDetails) {}, + }) + + // First tap. + arena1 := NewArena() + down1 := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down1, arena1) + arena1.Close(1) + up1 := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up1) + + // Second tap after timeout. + t2 := 500 * time.Millisecond + arena2 := NewArena() + down2 := makePointerEvent(PointerDown, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t2) + rec.AddPointer(down2, arena2) + arena2.Close(2) + up2 := makePointerEvent(PointerUp, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, t2+50*time.Millisecond) + rec.HandleEvent(up2) + + if len(tapCounts) != 2 { + t.Fatalf("got %d taps, want 2", len(tapCounts)) + } + if tapCounts[1] != 1 { + t.Errorf("second tap count = %d, want 1 (reset due to timeout)", tapCounts[1]) + } +} + +func TestTapAndDrag_DragEndVelocity(t *testing.T) { + var endDetails TapDragEndDetails + + rec := NewTapAndDragRecognizer(TapAndDragConfig{ + OnTapDown: func(_ TapDragDownDetails) {}, + OnDragStart: func(_ TapDragStartDetails) {}, + OnDragEnd: func(d TapDragEndDetails) { + endDetails = d + }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(0, 0), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Drag to start. + for i := 1; i <= 5; i++ { + ts := time.Duration(i*20) * time.Millisecond + move := makePointerEvent(PointerMove, 1, PointerTypeMouse, geometry.Pt(float32(i*20), 0), event.ButtonLeft, ts) + rec.HandleEvent(move) + } + + // End drag. + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(100, 0), event.ButtonLeft, 100*time.Millisecond) + rec.HandleEvent(up) + + if endDetails.ConsecutiveTapCount != 1 { + t.Errorf("drag end consecutiveTapCount = %d, want 1", endDetails.ConsecutiveTapCount) + } + if endDetails.Velocity.X <= 0 { + t.Error("drag end velocity X should be positive") + } +} + +func TestTapAndDrag_Cancel(t *testing.T) { + var canceled bool + rec := NewTapAndDragRecognizer(TapAndDragConfig{ + OnTapDown: func(_ TapDragDownDetails) {}, + OnCancel: func() { canceled = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + cancel := makePointerEvent(PointerCancel, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(cancel) + + if !canceled { + t.Error("OnCancel should fire on PointerCancel") + } +} + +func TestTapAndDrag_ButtonMismatchResets(t *testing.T) { + var tapCounts []int + + rec := NewTapAndDragRecognizer(TapAndDragConfig{ + OnTapDown: func(d TapDragDownDetails) { + tapCounts = append(tapCounts, d.ConsecutiveTapCount) + }, + OnTapUp: func(_ TapDragUpDetails) {}, + }) + + // First tap with left button. + arena1 := NewArena() + down1 := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down1, arena1) + arena1.Close(1) + up1 := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(up1) + + // Second tap with right button. + t2 := 150 * time.Millisecond + arena2 := NewArena() + down2 := makePointerEvent(PointerDown, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonRight, t2) + rec.AddPointer(down2, arena2) + arena2.Close(2) + up2 := makePointerEvent(PointerUp, 2, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonRight, t2+50*time.Millisecond) + rec.HandleEvent(up2) + + if len(tapCounts) < 2 { + t.Fatalf("got %d taps, want 2", len(tapCounts)) + } + if tapCounts[1] != 1 { + t.Errorf("second tap count = %d, want 1 (reset due to button mismatch)", tapCounts[1]) + } +} + +func TestTapAndDrag_IgnoreWrongPointer(t *testing.T) { + var dragStarted bool + rec := NewTapAndDragRecognizer(TapAndDragConfig{ + OnTapDown: func(_ TapDragDownDetails) {}, + OnDragStart: func(_ TapDragStartDetails) { dragStarted = true }, + }) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + rec.AddPointer(down, arena) + arena.Close(1) + + // Move with wrong pointer ID. + move := makePointerEvent(PointerMove, 99, PointerTypeMouse, geometry.Pt(100, 100), event.ButtonLeft, 50*time.Millisecond) + rec.HandleEvent(move) + + if dragStarted { + t.Error("should ignore events for wrong pointer ID") + } +} + +func TestTapAndDrag_MaxClickCountWraps(t *testing.T) { + var tapCounts []int + + rec := NewTapAndDragRecognizer(TapAndDragConfig{ + OnTapDown: func(d TapDragDownDetails) { + tapCounts = append(tapCounts, d.ConsecutiveTapCount) + }, + OnTapUp: func(_ TapDragUpDetails) {}, + }) + + // 4 consecutive taps: should wrap to 1 after MaxClickCount (3). + for i := 0; i < 4; i++ { + ts := time.Duration(i) * 150 * time.Millisecond + arena := NewArena() + down := makePointerEvent(PointerDown, i+1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, ts) + rec.AddPointer(down, arena) + arena.Close(i + 1) + up := makePointerEvent(PointerUp, i+1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, ts+50*time.Millisecond) + rec.HandleEvent(up) + } + + if len(tapCounts) != 4 { + t.Fatalf("got %d taps, want 4", len(tapCounts)) + } + expected := []int{1, 2, 3, 1} + for i, want := range expected { + if tapCounts[i] != want { + t.Errorf("tap[%d] = %d, want %d", i, tapCounts[i], want) + } + } +} diff --git a/gesture/team.go b/gesture/team.go new file mode 100644 index 0000000..a972150 --- /dev/null +++ b/gesture/team.go @@ -0,0 +1,82 @@ +package gesture + +// Team groups recognizers that cooperate rather than compete. +// +// Within a team, when one member would win the arena, the captain (if set) +// is given the chance to claim instead. This enables widgets like Slider +// where Tap (click-to-position) and Drag (thumb-drag) should cooperate: +// if the user starts dragging, the drag recognizer wins without waiting +// for the tap's timeout. +// +// Flutter equivalent: GestureArenaTeam. +type Team struct { + // Captain is the preferred winner when a team member would win. + // If nil, the original winner keeps the victory. + Captain ArenaMember + + members []teamMember +} + +// teamMember wraps a recognizer that belongs to a team. It intercepts +// AcceptGesture to redirect to the team captain if one is set. +type teamMember struct { + inner Recognizer + team *Team +} + +// AcceptGesture intercepts the arena accept. If the team has a captain +// and the captain is not this member's inner recognizer, the captain +// receives AcceptGesture instead and this member is rejected. +func (m *teamMember) AcceptGesture(pointerID int) { + if m.team.Captain != nil && m.team.Captain != m.inner { + m.team.Captain.AcceptGesture(pointerID) + m.inner.RejectGesture(pointerID) + } else { + m.inner.AcceptGesture(pointerID) + } +} + +// RejectGesture delegates to the inner recognizer. +func (m *teamMember) RejectGesture(pointerID int) { + m.inner.RejectGesture(pointerID) +} + +// AddPointer delegates to the inner recognizer, ensuring that the +// teamMember wrapper (not the inner recognizer) is registered in the +// arena. This is critical: when the arena calls AcceptGesture, it must +// call it on the teamMember so the captain interception logic executes. +func (m *teamMember) AddPointer(ev *PointerEvent, arena *Arena) bool { + // Set the member override on the inner recognizer's RecognizerBase + // so that StartTrackingPointer registers this teamMember wrapper + // as the arena participant instead of the inner recognizer. + if base := recognizerBase(m.inner); base != nil { + base.SetMemberOverride(m) + } + return m.inner.AddPointer(ev, arena) +} + +// HandleEvent delegates to the inner recognizer. +func (m *teamMember) HandleEvent(ev *PointerEvent) { + m.inner.HandleEvent(ev) +} + +// Dispose delegates to the inner recognizer. +func (m *teamMember) Dispose() { + m.inner.Dispose() +} + +// Add adds a recognizer to this team. The returned Recognizer is a wrapper +// that intercepts arena accept to support team captain logic. +func (t *Team) Add(r Recognizer) Recognizer { + m := &teamMember{ + inner: r, + team: t, + } + t.members = append(t.members, *m) + return m +} + +// Members returns the number of recognizers in this team. +func (t *Team) Members() int { + return len(t.members) +} diff --git a/gesture/team_test.go b/gesture/team_test.go new file mode 100644 index 0000000..d3bbb5a --- /dev/null +++ b/gesture/team_test.go @@ -0,0 +1,206 @@ +package gesture + +import ( + "testing" + "time" + + "github.com/gogpu/ui/event" + "github.com/gogpu/ui/geometry" +) + +// mockRecognizer implements Recognizer for team testing. +type mockRecognizer struct { + accepted bool + rejected bool + disposed bool + handled []*PointerEvent +} + +func (m *mockRecognizer) AddPointer(_ *PointerEvent, _ *Arena) bool { return true } +func (m *mockRecognizer) HandleEvent(ev *PointerEvent) { m.handled = append(m.handled, ev) } +func (m *mockRecognizer) AcceptGesture(_ int) { m.accepted = true } +func (m *mockRecognizer) RejectGesture(_ int) { m.rejected = true } +func (m *mockRecognizer) Dispose() { m.disposed = true } + +func TestTeam_CaptainWins(t *testing.T) { + team := &Team{} + captain := &mockRecognizer{} + member := &mockRecognizer{} + + team.Captain = captain + wrapped := team.Add(member) + + // When the wrapped member wins the arena, the captain should get + // AcceptGesture and the original member should be rejected. + wrapped.AcceptGesture(1) + + if !captain.accepted { + t.Error("captain should be accepted when team member wins") + } + if !member.rejected { + t.Error("original member should be rejected when captain takes over") + } +} + +func TestTeam_NoCaptain(t *testing.T) { + team := &Team{} + member := &mockRecognizer{} + + wrapped := team.Add(member) + + // Without a captain, the original member should get the accept. + wrapped.AcceptGesture(1) + + if !member.accepted { + t.Error("member should be accepted when no captain is set") + } +} + +func TestTeam_CaptainIsSelf(t *testing.T) { + team := &Team{} + member := &mockRecognizer{} + + wrapped := team.Add(member) + team.Captain = member // Captain is the same recognizer. + + wrapped.AcceptGesture(1) + + if !member.accepted { + t.Error("member should be accepted when captain is self") + } +} + +func TestTeam_RejectDelegates(t *testing.T) { + team := &Team{} + member := &mockRecognizer{} + + wrapped := team.Add(member) + wrapped.RejectGesture(1) + + if !member.rejected { + t.Error("reject should delegate to inner recognizer") + } +} + +func TestTeam_HandleEventDelegates(t *testing.T) { + team := &Team{} + member := &mockRecognizer{} + + wrapped := team.Add(member) + + ev := &PointerEvent{EventType: PointerMove, PointerID: 1} + wrapped.HandleEvent(ev) + + if len(member.handled) != 1 { + t.Error("HandleEvent should delegate to inner recognizer") + } +} + +func TestTeam_DisposeDelegates(t *testing.T) { + team := &Team{} + member := &mockRecognizer{} + + wrapped := team.Add(member) + wrapped.Dispose() + + if !member.disposed { + t.Error("Dispose should delegate to inner recognizer") + } +} + +func TestTeam_Members(t *testing.T) { + team := &Team{} + if team.Members() != 0 { + t.Error("empty team should have 0 members") + } + + team.Add(&mockRecognizer{}) + team.Add(&mockRecognizer{}) + if team.Members() != 2 { + t.Errorf("Members() = %d, want 2", team.Members()) + } +} + +// TestTeam_CaptainReceivesAcceptThroughArena verifies that when a team +// member's inner recognizer is registered via AddPointer -> arena.Add, +// the arena calls AcceptGesture on the teamMember wrapper (not the inner +// recognizer directly), so the captain interception logic executes. +// This tests Issue 1: Team arena registration bypass. +func TestTeam_CaptainReceivesAcceptThroughArena(t *testing.T) { + team := &Team{} + + // Create real recognizers (not mocks) that embed RecognizerBase. + var clickAccepted bool + click := NewClickRecognizer(ClickConfig{ + OnClick: func(_ ClickDetails) { clickAccepted = true }, + }) + + var dragAccepted bool + drag := NewDragRecognizer(DragConfig{ + Direction: DragDirectionPan, + OnDragStart: func(_ DragStartDetails) { dragAccepted = true }, + }) + + // The drag recognizer is the captain. + team.Captain = drag + wrappedClick := team.Add(click) + wrappedDrag := team.Add(drag) + + // Simulate pointer down through the team wrappers into a shared arena. + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + + wrappedClick.AddPointer(down, arena) + wrappedDrag.AddPointer(down, arena) + arena.Close(1) + + // Simulate the click recognizer getting accepted first via pointer up. + // The arena should call AcceptGesture on the teamMember wrapper for click. + // The wrapper should redirect to the captain (drag). + up := makePointerEvent(PointerUp, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 50*time.Millisecond) + arena.Route(up) + + // After pointer up, sweep resolves the first remaining member. + arena.Sweep(1) + + // dragAccepted fires on DragStart (slop exceeded), not just AcceptGesture. + // The captain received AcceptGesture which sets wonArena but doesn't + // fire OnDragStart. That's correct -- drag start requires slop. + // So we only verify that click did NOT fire (captain intercepted). + _ = dragAccepted // intentionally not asserted; see comment above + if clickAccepted { + t.Error("click OnClick should not fire when captain intercepts the accept") + } +} + +// TestTeam_WrapperRegisteredInArena verifies at the arena level that the +// teamMember wrapper (not the inner recognizer) is the registered member. +func TestTeam_WrapperRegisteredInArena(t *testing.T) { + team := &Team{} + + click := NewClickRecognizer(ClickConfig{}) + captain := NewDragRecognizer(DragConfig{Direction: DragDirectionPan}) + team.Captain = captain + + wrappedClick := team.Add(click) + + arena := NewArena() + down := makePointerEvent(PointerDown, 1, PointerTypeMouse, geometry.Pt(50, 50), event.ButtonLeft, 0) + wrappedClick.AddPointer(down, arena) + + // The arena should have 1 member, and it should be the wrapper. + if arena.MemberCount(1) != 1 { + t.Fatalf("MemberCount = %d, want 1", arena.MemberCount(1)) + } + + // Close the arena. Since there's only one member, it auto-accepts. + // AcceptGesture should go through the teamMember wrapper to the captain. + arena.Close(1) + + // Verify: captain.wonArena should be true (AcceptGesture called on captain). + // The inner click recognizer should NOT have state=clickAccepted + // (it should have been rejected by the wrapper). + if click.state == clickAccepted { + t.Error("inner click recognizer should be rejected when captain takes over") + } +} diff --git a/gesture/velocity.go b/gesture/velocity.go new file mode 100644 index 0000000..3f54c16 --- /dev/null +++ b/gesture/velocity.go @@ -0,0 +1,143 @@ +package gesture + +import ( + "math" + "time" + + "github.com/gogpu/ui/geometry" +) + +// velocitySampleCount is the maximum number of position samples retained. +// Flutter uses 20 samples within a 100ms window. +const velocitySampleCount = 20 + +// velocityMaxAge is the maximum age of a sample for velocity estimation. +const velocityMaxAge = 100 * time.Millisecond + +// velocitySample holds a single timestamped position sample. +type velocitySample struct { + timestamp time.Duration + position geometry.Point +} + +// VelocityTracker estimates pointer velocity from a stream of timestamped +// positions. Used by DragRecognizer to provide fling velocity at drag end. +// +// The tracker uses a least-squares linear regression over the most recent +// samples within a 100ms window. Falls back to simple delta/dt when fewer +// than 2 valid samples are available. +type VelocityTracker struct { + samples [velocitySampleCount]velocitySample + index int + count int +} + +// NewVelocityTracker creates a new velocity tracker. +func NewVelocityTracker() *VelocityTracker { + return &VelocityTracker{} +} + +// AddPosition records a timestamped position sample. +func (v *VelocityTracker) AddPosition(timestamp time.Duration, position geometry.Point) { + v.samples[v.index] = velocitySample{ + timestamp: timestamp, + position: position, + } + v.index = (v.index + 1) % velocitySampleCount + if v.count < velocitySampleCount { + v.count++ + } +} + +// Velocity returns the estimated velocity in logical pixels per second. +// Returns (0,0) if insufficient data is available for estimation. +func (v *VelocityTracker) Velocity() geometry.Point { + if v.count < 2 { + return geometry.Point{} + } + + // Find the most recent sample for the age window. + newest := v.newestSample() + cutoff := newest.timestamp - velocityMaxAge + + // Collect samples within the window, ordered oldest to newest. + type sample struct { + t float64 // seconds relative to oldest sample + x, y float64 + } + var valid []sample + for i := 0; i < v.count; i++ { + idx := (v.index - v.count + i + velocitySampleCount) % velocitySampleCount + s := v.samples[idx] + if s.timestamp >= cutoff { + valid = append(valid, sample{ + t: s.timestamp.Seconds(), + x: float64(s.position.X), + y: float64(s.position.Y), + }) + } + } + + if len(valid) < 2 { + return geometry.Point{} + } + + // Least-squares linear regression: velocity = slope of position vs time. + vx := leastSquaresSlope(valid, func(s sample) (float64, float64) { return s.t, s.x }) + vy := leastSquaresSlope(valid, func(s sample) (float64, float64) { return s.t, s.y }) + + return geometry.Point{ + X: clampVelocity(float32(vx)), + Y: clampVelocity(float32(vy)), + } +} + +// Reset clears all recorded samples. +func (v *VelocityTracker) Reset() { + v.index = 0 + v.count = 0 +} + +// SampleCount returns the number of samples currently stored. +func (v *VelocityTracker) SampleCount() int { + return v.count +} + +// newestSample returns the most recently added sample. +func (v *VelocityTracker) newestSample() velocitySample { + idx := (v.index - 1 + velocitySampleCount) % velocitySampleCount + return v.samples[idx] +} + +// leastSquaresSlope computes the slope of a least-squares linear fit. +// The extract function returns (x, y) from each sample. +func leastSquaresSlope[T any](data []T, extract func(T) (float64, float64)) float64 { + var sumX, sumY, sumXY, sumXX float64 + n := float64(len(data)) + + for _, d := range data { + x, y := extract(d) + sumX += x + sumY += y + sumXY += x * y + sumXX += x * x + } + + denominator := n*sumXX - sumX*sumX + if math.Abs(denominator) < 1e-12 { + return 0 + } + + return (n*sumXY - sumX*sumY) / denominator +} + +// clampVelocity clamps a velocity component to the allowed range. +func clampVelocity(v float32) float32 { + if v > MaxFlingVelocity { + return MaxFlingVelocity + } + if v < -MaxFlingVelocity { + return -MaxFlingVelocity + } + return v +} diff --git a/gesture/velocity_test.go b/gesture/velocity_test.go new file mode 100644 index 0000000..f231c56 --- /dev/null +++ b/gesture/velocity_test.go @@ -0,0 +1,164 @@ +package gesture + +import ( + "math" + "testing" + "time" + + "github.com/gogpu/ui/geometry" +) + +func TestVelocityTracker_Empty(t *testing.T) { + vt := NewVelocityTracker() + vel := vt.Velocity() + if vel.X != 0 || vel.Y != 0 { + t.Errorf("empty tracker velocity = %v, want (0, 0)", vel) + } +} + +func TestVelocityTracker_SingleSample(t *testing.T) { + vt := NewVelocityTracker() + vt.AddPosition(0, geometry.Pt(0, 0)) + vel := vt.Velocity() + if vel.X != 0 || vel.Y != 0 { + t.Errorf("single sample velocity = %v, want (0, 0)", vel) + } +} + +func TestVelocityTracker_ConstantHorizontalVelocity(t *testing.T) { + vt := NewVelocityTracker() + + // 100 px/s horizontal velocity: 10px every 100ms. + for i := 0; i < 5; i++ { + ts := time.Duration(i*100) * time.Millisecond + vt.AddPosition(ts, geometry.Pt(float32(i*10), 0)) + } + + vel := vt.Velocity() + if math.Abs(float64(vel.X)-100) > 5 { + t.Errorf("horizontal velocity = %.1f, want ~100", vel.X) + } + if math.Abs(float64(vel.Y)) > 1 { + t.Errorf("vertical velocity = %.1f, want ~0", vel.Y) + } +} + +func TestVelocityTracker_ConstantVerticalVelocity(t *testing.T) { + vt := NewVelocityTracker() + + // 200 px/s vertical velocity: 20px every 100ms. + for i := 0; i < 5; i++ { + ts := time.Duration(i*100) * time.Millisecond + vt.AddPosition(ts, geometry.Pt(0, float32(i*20))) + } + + vel := vt.Velocity() + if math.Abs(float64(vel.Y)-200) > 5 { + t.Errorf("vertical velocity = %.1f, want ~200", vel.Y) + } + if math.Abs(float64(vel.X)) > 1 { + t.Errorf("horizontal velocity = %.1f, want ~0", vel.X) + } +} + +func TestVelocityTracker_DiagonalVelocity(t *testing.T) { + vt := NewVelocityTracker() + + // 100 px/s in both axes. + for i := 0; i < 5; i++ { + ts := time.Duration(i*100) * time.Millisecond + vt.AddPosition(ts, geometry.Pt(float32(i*10), float32(i*10))) + } + + vel := vt.Velocity() + if math.Abs(float64(vel.X)-100) > 5 { + t.Errorf("X velocity = %.1f, want ~100", vel.X) + } + if math.Abs(float64(vel.Y)-100) > 5 { + t.Errorf("Y velocity = %.1f, want ~100", vel.Y) + } +} + +func TestVelocityTracker_VelocityClamped(t *testing.T) { + vt := NewVelocityTracker() + + // Extreme velocity: 10000 px in 10ms. + vt.AddPosition(0, geometry.Pt(0, 0)) + vt.AddPosition(10*time.Millisecond, geometry.Pt(10000, 0)) + + vel := vt.Velocity() + if vel.X > MaxFlingVelocity { + t.Errorf("velocity = %.1f, should be clamped to %.1f", vel.X, MaxFlingVelocity) + } +} + +func TestVelocityTracker_Reset(t *testing.T) { + vt := NewVelocityTracker() + + vt.AddPosition(0, geometry.Pt(0, 0)) + vt.AddPosition(100*time.Millisecond, geometry.Pt(100, 0)) + + vt.Reset() + if vt.SampleCount() != 0 { + t.Errorf("SampleCount after reset = %d, want 0", vt.SampleCount()) + } + vel := vt.Velocity() + if vel.X != 0 || vel.Y != 0 { + t.Errorf("velocity after reset = %v, want (0, 0)", vel) + } +} + +func TestVelocityTracker_OldSamplesDiscarded(t *testing.T) { + vt := NewVelocityTracker() + + // Add old samples (before the window). + vt.AddPosition(0, geometry.Pt(0, 0)) + vt.AddPosition(10*time.Millisecond, geometry.Pt(1000, 0)) + + // Add recent samples with different velocity. + // These should dominate because old ones are outside the 100ms window. + base := 200 * time.Millisecond + for i := 0; i < 5; i++ { + ts := base + time.Duration(i*20)*time.Millisecond + vt.AddPosition(ts, geometry.Pt(float32(i*2), 0)) + } + + vel := vt.Velocity() + // The recent velocity is 2px/20ms = 100 px/s. + if math.Abs(float64(vel.X)-100) > 15 { + t.Errorf("velocity from recent samples = %.1f, want ~100", vel.X) + } +} + +func TestVelocityTracker_CircularBuffer(t *testing.T) { + vt := NewVelocityTracker() + + // Fill beyond capacity. + for i := 0; i <= velocitySampleCount+5; i++ { + ts := time.Duration(i*5) * time.Millisecond + vt.AddPosition(ts, geometry.Pt(float32(i), 0)) + } + + if vt.SampleCount() != velocitySampleCount { + t.Errorf("SampleCount = %d, want %d", vt.SampleCount(), velocitySampleCount) + } + + // Should still produce valid velocity. + vel := vt.Velocity() + if vel.X <= 0 { + t.Error("velocity should be positive after filling buffer") + } +} + +func TestVelocityTracker_NegativeVelocityClamped(t *testing.T) { + vt := NewVelocityTracker() + + // Extreme negative velocity. + vt.AddPosition(0, geometry.Pt(10000, 0)) + vt.AddPosition(10*time.Millisecond, geometry.Pt(0, 0)) + + vel := vt.Velocity() + if vel.X < -MaxFlingVelocity { + t.Errorf("velocity = %.1f, should be clamped to %.1f", vel.X, -MaxFlingVelocity) + } +} diff --git a/widget/clipboard.go b/widget/clipboard.go new file mode 100644 index 0000000..14b2887 --- /dev/null +++ b/widget/clipboard.go @@ -0,0 +1,39 @@ +package widget + +// ClipboardProvider reads and writes text to the system clipboard. +// Registered by the app/desktop layer to bridge widget clipboard +// requests to the platform API (gogpu PlatformProvider) without a +// direct import. The same DI pattern as SoundPlayer. +type ClipboardProvider interface { + ClipboardRead() (string, error) + ClipboardWrite(text string) error +} + +var clipboardProvider ClipboardProvider + +// RegisterClipboardProvider registers the platform clipboard implementation. +// Called by the desktop layer during initialization to inject the platform +// clipboard. Only one provider may be registered; subsequent calls replace +// the previous one. +func RegisterClipboardProvider(p ClipboardProvider) { + clipboardProvider = p +} + +// ClipboardRead reads text from the system clipboard. +// Returns empty string if no provider is registered or clipboard is empty. +func ClipboardRead() string { + if clipboardProvider == nil { + return "" + } + text, _ := clipboardProvider.ClipboardRead() + return text +} + +// ClipboardWrite writes text to the system clipboard. +// No-op if no provider is registered. +func ClipboardWrite(text string) { + if clipboardProvider == nil { + return + } + _ = clipboardProvider.ClipboardWrite(text) +} diff --git a/widget/clipboard_test.go b/widget/clipboard_test.go new file mode 100644 index 0000000..f52206d --- /dev/null +++ b/widget/clipboard_test.go @@ -0,0 +1,56 @@ +package widget + +import "testing" + +type mockClipboard struct { + text string +} + +func (m *mockClipboard) ClipboardRead() (string, error) { return m.text, nil } +func (m *mockClipboard) ClipboardWrite(text string) error { m.text = text; return nil } + +func TestClipboardRead_NoProvider(t *testing.T) { + old := clipboardProvider + defer func() { clipboardProvider = old }() + + clipboardProvider = nil + if got := ClipboardRead(); got != "" { + t.Errorf("ClipboardRead() = %q, want empty", got) + } +} + +func TestClipboardWrite_NoProvider(t *testing.T) { + old := clipboardProvider + defer func() { clipboardProvider = old }() + + clipboardProvider = nil + ClipboardWrite("test") // should not panic +} + +func TestClipboard_RoundTrip(t *testing.T) { + old := clipboardProvider + defer func() { clipboardProvider = old }() + + mock := &mockClipboard{} + RegisterClipboardProvider(mock) + + ClipboardWrite("hello clipboard") + if got := ClipboardRead(); got != "hello clipboard" { + t.Errorf("ClipboardRead() = %q, want %q", got, "hello clipboard") + } +} + +func TestRegisterClipboardProvider_Replaces(t *testing.T) { + old := clipboardProvider + defer func() { clipboardProvider = old }() + + first := &mockClipboard{text: "first"} + second := &mockClipboard{text: "second"} + + RegisterClipboardProvider(first) + RegisterClipboardProvider(second) + + if got := ClipboardRead(); got != "second" { + t.Errorf("ClipboardRead() = %q, want %q (second provider)", got, "second") + } +} From 38c7714c69ddb435bfbb76bb48ec478904b21898 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 13 Aug 2026 17:12:05 +0300 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20update=20public=20documentation=20f?= =?UTF-8?q?or=20v0.1.54=20=E2=80=94=20gesture=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG: v0.1.54 section with gesture system, unified pipeline, clipboard. ARCHITECTURE: gesture/ package, arena protocol, event pipeline diagram. README: gesture recognition in features, updated metrics (220K LOC, 70 packages). ROADMAP: gesture system completed, updated phase status. CONTRIBUTING: GestureAware requirement for new widgets. --- CHANGELOG.md | 19 +++++++ CONTRIBUTING.md | 3 +- README.md | 10 +++- ROADMAP.md | 30 ++++++----- docs/ARCHITECTURE.md | 119 ++++++++++++++++++++++++++++++++++--------- 5 files changed, 141 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc19565..af1ba52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.54] — 2026-08-13 + +### Added + +- **Gesture recognition system** (ADR-049) — Flutter-inspired arena-based gesture disambiguation. New `gesture/` package: `Arena`, `ClickRecognizer`, `DragRecognizer`, `LongPressRecognizer`, `TapAndDragRecognizer`, `VelocityTracker`, `Team` cooperative groups. `GestureAware` interface for widget opt-in. Per-device thresholds (mouse 1px, touch 18px). Signals integration via functional options. 124 tests, 96.0% coverage, ~5K LOC. +- **Unified pointer pipeline** — `PointerEvent` as single source of all pointer input. Replaces legacy mouse event callbacks with a structured pointer event type that carries device kind, pointer ID, button state, and coordinates. Foundation for future touch/pen/stylus input. +- **TextField drag selection** ([#225](https://github.com/gogpu/ui/issues/225)) — mouse drag to select text, double-click to select word, triple-click to select all. +- **OS clipboard** — `widget.ClipboardProvider` DI pattern (same as `SoundPlayer`), wired to platform clipboard (Win32, macOS, Linux) via `widget.RegisterClipboardProvider()`. `widget.ClipboardRead()` / `widget.ClipboardWrite()` for widget-level clipboard access. + +### Changed + +- All 20+ interactive widgets implement `gesture.GestureAware` — button, checkbox, radio, textfield, dropdown, slider, dialog, scrollview, tabview, listview, gridview, collapsible, popover, splitview, treeview, datatable, toolbar, menu, docking, chip, stripe, titlebar. +- Event bridge: unified pointer pipeline (ADR-049 Phase 3) — pointer events flow through gesture arena before widget dispatch. + +### Fixed + +- **fix(desktop): retain unchanged boundaries on resize** ([#176](https://github.com/gogpu/ui/issues/176), @besmpl) — unchanged boundaries preserved during window resize. +- **fix(app): keep pointer events within window bounds** ([#179](https://github.com/gogpu/ui/issues/179), @besmpl) — pointer coordinates clamped to window dimensions. + ## [0.1.53] — 2026-08-11 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1745c9d..f313cdd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -209,9 +209,10 @@ Look for issues labeled `good first issue`: ### Larger Contributions For significant changes, **open an issue first** to discuss: -- New widgets +- New widgets (must implement `gesture.GestureAware` for pointer interaction) - Layout algorithms - Theme implementations +- Custom gesture recognizers (implement `gesture.Recognizer` interface) - Accessibility features --- diff --git a/README.md b/README.md index f6fa514..943c632 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ | **CGO-free** | Yes | No | Yes | | **WebGPU rendering** | Yes (Vulkan/DX12/Metal/GLES/Software) | OpenGL | Direct GPU | | **Reactive state** | Signals (push-pull, zero glitch) | Binding | Events | +| **Gesture recognition** | Arena-based (Flutter pattern) | No | No | | **Layout engine** | Flexbox + Grid + per-widget cache | Custom | Flex | | **Accessibility** | Day 1 (35+ ARIA roles) | Limited | Limited | | **Design systems** | 4 (M3, DevTools, Fluent, Cupertino) | 1 | 1 | @@ -212,10 +213,11 @@ func main() { | `core/stripe` | Vertical sidebar strip: tool window buttons, top/bottom items, icon buttons | 96%+ | | `core/titlebar` | Window title bar: CSD, drag, minimize/maximize/close, WindowChrome interface | 96%+ | +| `gesture` | Gesture recognition: Arena, ClickRecognizer, DragRecognizer, LongPressRecognizer, TapAndDragRecognizer, VelocityTracker, Team | 96.0% | | `compositor` | Layer Tree compositor: OffsetLayer, PictureLayer, ClipRectLayer, OpacityLayer — production render pipeline | 95%+ | | `desktop` | Production render loop: Layer Tree → GPU textures → damage-aware blit | — | -**Total: ~207,000+ lines of code | 56+ packages | ~7,500+ tests | 97%+ average coverage** +**Total: ~220,000+ lines of code | 70 packages | ~7,800+ tests | 97%+ average coverage** ### Backend Selection @@ -262,6 +264,8 @@ The **software backend** runs on any machine without GPU drivers. It uses the sa ├─────────────────────────────────────────────────────────────┤ │ icon/ │ i18n/ │ dnd/ │ uitest/ │ theme/font/ │ ├─────────────────────────────────────────────────────────────┤ +│ gesture/ (Arena, Click, Drag, LongPress, Team) │ +├─────────────────────────────────────────────────────────────┤ │ app/ + FocusManager │ focus/ │ overlay/ │ render/ │ ├─────────────────────────────────────────────────────────────┤ │ desktop/ (Layer Tree Compositor + Damage-Aware Blit) │ @@ -708,6 +712,10 @@ testApp.Window().Frame() // processes layout + draw - [x] OS file drag-and-drop bridge (KindFile, DropExternal) - [x] Vector icon rendering (SVG → scene geometry, no bitmap) - [x] JetBrains SVG window controls (pixel-perfect filled rects) +- [x] Gesture recognition system (ADR-049, arena-based, Flutter pattern) +- [x] Unified pointer pipeline (PointerEvent as single source of pointer input) +- [x] OS clipboard (ClipboardProvider DI, Win32/macOS/Linux) +- [x] TextField drag selection (drag-to-select, double-click word, triple-click all) - [ ] Platform accessibility adapters (UIA, AT-SPI2, NSAccessibility) - [ ] Software backend performance optimization (naga Go+SIMD, SPIR-V SIMD) - [ ] Android support (wgpu#268, Vulkan arm64) diff --git a/ROADMAP.md b/ROADMAP.md index 98e8015..5d44a33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # gogpu/ui Roadmap -> **Version:** 0.1.52 +> **Version:** 0.1.54 > **Updated:** August 2026 > **Go Version:** 1.25+ @@ -23,6 +23,7 @@ Go has waited 17 years for a professional graphics ecosystem. We're building it: - Pure Go by default (zero CGO), Rust backend optional via `-tags rust` (ADR-038 triple-backend) - WebGPU-first rendering via gogpu/wgpu (Vulkan/Metal/DX12/GLES/Software/Browser) - Signals-based reactive state (coregx/signals — hybrid push-pull, zero glitch) +- Arena-based gesture recognition (Flutter GestureArena protocol, ADR-049) - Layer Tree compositor with damage-aware blit (Flutter/Chrome patterns) - Four design systems: Material 3, DevTools (JetBrains), Fluent, Cupertino - Polymorphic Content[C] pattern (CDK — inspired by taiga-family/polymorpheus) @@ -35,14 +36,15 @@ Go has waited 17 years for a professional graphics ecosystem. We're building it: | Metric | Value | |--------|-------| -| Packages | 56+ | -| Total LOC (scc) | ~207,000+ | -| Test Functions | ~7,500+ | +| Packages | 70 | +| Total LOC (scc) | ~220,000+ | +| Test Functions | ~7,800+ | | Test Coverage | 97%+ | | Linter Issues | 0 | | Interactive Widgets | 27 | | Design Systems | 4 (M3, DevTools, Fluent, Cupertino) | | Painters | 70 (24 + 24 + 11 + 11) | +| Gesture Recognition | Arena-based (ADR-049), 4 recognizers, Flutter pattern | | Layout Cache | Per-widget (ADR-032), O(affected subtree) | | Render Pipeline | Unified draw queue (ADR-051/052), backend-agnostic | @@ -140,6 +142,10 @@ Slider, Dialog, Animation engine (Tween, Spring, M3 motion), ScrollView, TabView | Overlay boundary pipeline | Dropdown/dialog via Layer Tree | | Custom font pipeline | FontRegistry, StyledTextDrawer | | PointerCapturer | ADR-031, widget-level mouse capture | +| Gesture recognition (ADR-049) | Arena-based disambiguation, 4 recognizers, Team groups, VelocityTracker | +| Unified pointer pipeline | PointerEvent as single source of pointer input | +| OS clipboard | ClipboardProvider DI, Win32/macOS/Linux | +| TextField drag selection | Drag-to-select, double-click word, triple-click all | | 34 integration tests | Multi-frame lifecycle, visibility matrix | | Badge widget | Notification badge (dot/count), signal bindings | | Chip widget | Action/filter chip (M3 spec), toggleable, two-way signal | @@ -215,7 +221,7 @@ Platform-specific features for native feel. | **Native file dialogs** | Open/Save/Folder via system dialogs | P1 | | **Clipboard rich content** | HTML/RTF clipboard support | P2 | | **IME support** | Input method for CJK languages | P2 | -| **Touch/gesture input** | Pinch, swipe, long press | P2 | +| **Touch/gesture input** | Pinch, swipe (gesture/ infrastructure in place — ADR-049) | P2 | ### Phase 9: API Freeze & Stabilization (v0.9.x — Q2-Q3 2027) @@ -343,6 +349,7 @@ All releases must follow this cascade. Breaking changes in lower layers require | Layer Tree compositor | Flutter, Chrome, Qt6, Android | `compositor/` package | | Pluggable Painters | All design systems (Swing L&F, Qt styles) | Painter interfaces per widget | | Polymorphic Content[C] | taiga-family/polymorpheus | `cdk/` package | +| Arena gesture disambiguation | Flutter GestureArena | `gesture/` package | | Signal-driven reactivity | Angular Signals, SolidJS, Preact | `state/` + coregx/signals | | Functional Options | Go community best practice | All widget constructors | | RepaintBoundary | Flutter RenderObject.isRepaintBoundary | `widget.WidgetBase` property | @@ -363,13 +370,13 @@ All releases must follow this cascade. Breaking changes in lower layers require | Dependency | Version | Purpose | |------------|---------|---------| -| gogpu/gg | v0.50.6 | 2D rendering + unified draw queue (ADR-051/052) | -| gogpu/gogpu | v0.44.8 | Windowing, input (examples) | -| gogpu/gpucontext | v0.21.1 | Shared interfaces (opaque struct tokens) | +| gogpu/gg | v0.52.2 | 2D rendering + unified draw queue (ADR-051/052) | +| gogpu/gogpu | v0.52.1 | Windowing, input (examples) | +| gogpu/gpucontext | v0.27.0 | Shared interfaces (opaque struct tokens) | | coregx/signals | v0.1.1 | Reactive state management | | golang.org/x/image | v0.44.0 | Inter font (standard) | -**Indirect:** gogpu/wgpu v0.30.21, gogpu/naga v0.17.15, gogpu/gputypes v0.5.1, go-text/typesetting v0.3.4 +**Indirect:** gogpu/wgpu v0.31.2, gogpu/naga v0.18.0, gogpu/gputypes v0.5.2, go-text/typesetting v0.3.4 --- @@ -403,10 +410,7 @@ All releases must follow this cascade. Breaking changes in lower layers require | UI Repository | https://github.com/gogpu/ui | | Discussions | https://github.com/orgs/gogpu/discussions/18 | | awesome-go listing | https://github.com/avelino/awesome-go | -| Kanban Tasks | `docs/dev/kanban/` | -| Research | `docs/dev/research/` | -| ADRs | `docs/dev/architecture/` | --- -*This roadmap evolves with the project. Last updated: July 2026.* +*This roadmap evolves with the project. Last updated: August 2026.* diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 730bc3f..37ad3fd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,27 +49,29 @@ | Point, Size, Rect, Constraints, Insets | +==============================================================+ | Infrastructure | -| focus/ | layout/ | state/ | -| Focus Manager | Flex, Stack, Grid| Signals, Binding | -| (delegation) | (public API) | Scheduler, Lifecycle | +| gesture/ | focus/ | state/ | +| Arena, Click, | Focus Manager | Signals, Binding | +| Drag, LongPress, | (delegation) | Scheduler, Lifecycle | +| TapAndDrag, Team | | | +------------------+-------------------+-----------------------+ -| a11y/ | registry/ | plugin/ | -| Accessible | Widget Registry | Plugin System | -| Node, Tree, Role | Categories | Manager, Assets | +| layout/ | a11y/ | plugin/ | +| Flex, Stack, Grid| Accessible | Plugin System | +| (public API) | Node, Tree, Role | Manager, Assets | +------------------+-------------------+-----------------------+ -| animation/ | transition/ | icon/ | -| Tween, Spring, | Fade, Slide, | Vector paths, | -| M3 Presets, | Scale, Show/Hide | IconWidget, | -| Orchestration | Enter/Exit | 10 built-in icons | +| registry/ | animation/ | transition/ | +| Widget Registry | Tween, Spring, | Fade, Slide, | +| Categories | M3 Presets, | Scale, Show/Hide | +| | Orchestration | Enter/Exit | +------------------+-------------------+-----------------------+ -| dnd/ | theme/font/ | i18n/ | -| DragSource, | Font Registry, | Locale, Bundle, | -| DropTarget, | CSS weight match | Translator, | -| Manager | Family/Face | CLDR plural, RTL | +| icon/ | dnd/ | i18n/ | +| Vector paths, | DragSource, | Locale, Bundle, | +| IconWidget, | DropTarget, | Translator, | +| 10 built-in | Manager | CLDR plural, RTL | +------------------+-------------------+-----------------------+ -| uitest/ | | | -| MockCanvas, | MockContext, | Event factories, | -| Widget helpers | Assertions | Reusable mocks | +| theme/font/ | uitest/ | | +| Font Registry, | MockCanvas, | Event factories, | +| CSS weight match | MockContext, | Widget helpers | +| Family/Face | Assertions | Reusable mocks | +------------------+-------------------+-----------------------+ | overlay/ | render/ | app/ | | Stack, Container | Canvas factory | App, Window, | @@ -98,7 +100,7 @@ | Package | Purpose | Key Types | |---------|---------|-----------| -| `widget/` | Core widget abstractions | `Widget`, `WidgetBase`, `Context`, `Canvas`, `Focusable`, `PointerCapturer` (ADR-031), `Lifecycle`, `SchedulerRef`, `ThemeProvider`, `Color` | +| `widget/` | Core widget abstractions | `Widget`, `WidgetBase`, `Context`, `Canvas`, `Focusable`, `PointerCapturer` (ADR-031), `Lifecycle`, `SchedulerRef`, `ThemeProvider`, `Color`, `ClipboardProvider` | | `event/` | Input event types | `MouseEvent`, `KeyEvent`, `FocusEvent`, `WheelEvent`, `Modifiers` | | `geometry/` | Geometric primitives | `Point`, `Size`, `Rect`, `Constraints`, `Insets` | @@ -153,6 +155,7 @@ | Package | Purpose | Key Types | |---------|---------|-----------| +| `gesture/` | Gesture recognition (arena-based, Flutter pattern) | `Arena`, `Recognizer`, `RecognizerBase`, `ClickRecognizer`, `DragRecognizer`, `LongPressRecognizer`, `TapAndDragRecognizer`, `VelocityTracker`, `Team`, `GestureAware`, `PointerEvent` | | `overlay/` | Overlay/popup infrastructure | `Stack`, `Container`, `Position` | | `focus/` | Focus management (public API) | `Manager`, `Shortcut`, `DrawFocusRing` | | `layout/` | Layout tree and algorithms | `NodeID`, `NodeLayout`, `Result`, `Algorithm` | @@ -452,6 +455,70 @@ Methods: `Has`, `HasAny`, `IsShift`, `IsCtrl`, `IsAlt`, `IsSuper`, `With`, `With Events are dispatched from the root widget down through the tree. A widget's `Event` method returns `true` to consume the event and stop propagation. There is no explicit capture/bubble phase -- widgets check bounds and delegate to children as appropriate. +### Gesture Recognition (ADR-049) + +The `gesture/` package provides arena-based gesture disambiguation, modeled after Flutter's `GestureArena` protocol. It sits at the infrastructure layer alongside `focus/`, `overlay/`, and `state/`. + +**Architecture:** + +``` +PointerDown event arrives + → hit-test: find widgets under pointer (deepest first) + → check each widget for GestureAware interface + → register each widget's recognizers in the Arena + → Arena closes (end of PointerDown dispatch) + → as PointerMove/PointerUp arrive, arena resolves winner + → winner fires gesture callbacks (OnClick, OnDragUpdate, etc.) + → losers reset their state +``` + +**Recognizers:** + +| Recognizer | Pattern | Use Case | +|------------|---------|----------| +| `ClickRecognizer` | Pointer down → up within slop distance | Buttons, checkboxes, list items | +| `DragRecognizer` | Pointer down → move beyond slop threshold | Scrolling, slider thumb, splitview divider | +| `LongPressRecognizer` | Pointer down → held for 500ms without movement | Context menus, drag initiation | +| `TapAndDragRecognizer` | Tap (click) + immediate drag sequence | TextField text selection | + +**Arena protocol:** +- When a `PointerDown` event occurs, all interested recognizers register. +- As pointer events arrive, recognizers evaluate the gesture pattern. +- A recognizer calls `Arena.Resolve(Accepted)` to claim victory or `Resolve(Rejected)` to withdraw. +- If only one member remains when the arena closes, it wins automatically. +- On `PointerUp`, the arena sweeps: first remaining member wins. +- `Team` groups cooperating recognizers (e.g., Slider click + drag). + +**Per-device thresholds:** +- Mouse: 1px slop distance (precise input) +- Touch: 18px slop distance (finger imprecision) + +**GestureAware interface** (opt-in, same pattern as `Focusable`): + +```go +type GestureAware interface { + GestureRecognizers() []Recognizer +} +``` + +All 20+ interactive widgets implement `GestureAware`. Widgets that do not implement it continue to receive events through the existing `Event(ctx, event.Event)` path. + +**Signals integration:** +Recognizers support opt-in reactive signals via functional options (e.g., `WithDraggingSignal` binds a `Signal[bool]` to drag state). + +### OS Clipboard + +The `widget.ClipboardProvider` interface enables clipboard access from widgets without direct platform imports: + +```go +type ClipboardProvider interface { + ClipboardRead() (string, error) + ClipboardWrite(text string) error +} +``` + +Registered by `desktop/` during initialization via `widget.RegisterClipboardProvider()`. Same DI pattern as `SoundPlayer`. + --- ## Button Widget @@ -998,7 +1065,7 @@ Key components: - `DrawStatsProvider` — observability (CachedWidgets, DirtyWidgets) - `DirtyTrackerProvider` — O(regions) `Intersects()` fast path in RepaintBoundary -See `docs/dev/architecture/ADR-004-INCREMENTAL-RENDERING.md` for full design. +See ADR-004 for full design. --- @@ -1351,11 +1418,13 @@ enabling Tab navigation and keyboard shortcut dispatch. `app.EventBridge` translates `gpucontext` events into `event.*` types and dispatches them to the Window. -**Event pipeline:** +**Event pipeline (ADR-049 unified pointer):** ``` gpucontext (native OS events) -> EventBridge (OnPointer, OnTextInput, OnKeyboard) -> Window.HandleEvent() + -> PointerEvent conversion (unified pointer pipeline) + -> GestureArena (hit-test → register GestureAware recognizers) -> HoverTracker (hit-test ScreenBounds, synthesize Enter/Leave) -> FocusManager.HandleKeyEvent() (Tab/Shift+Tab, shortcuts) -> Root Widget tree (depth-first dispatch) @@ -1463,13 +1532,13 @@ The `registry/` package provides a global registry for widget factories: | Dependency | Purpose | Version | |------------|---------|---------| -| `github.com/gogpu/gg` | 2D graphics + vector icons + unified draw queue | v0.50.11 | -| `github.com/gogpu/gpucontext` | Shared GPU interfaces (opaque struct tokens) | v0.24.0 | -| `github.com/gogpu/gogpu` | Application framework, windowing, Browser/WASM (examples only) | v0.48.4 | +| `github.com/gogpu/gg` | 2D graphics + vector icons + unified draw queue | v0.52.2 | +| `github.com/gogpu/gpucontext` | Shared GPU interfaces (opaque struct tokens) | v0.27.0 | +| `github.com/gogpu/gogpu` | Application framework, windowing, Browser/WASM (examples only) | v0.52.1 | | `github.com/coregx/signals` | Reactive state management | v0.1.1 | | `golang.org/x/image` | Font rendering infrastructure | v0.44.0 | -**Indirect:** gogpu/wgpu v0.30.34, gogpu/naga v0.18.0, gogpu/gputypes v0.5.1, go-text/typesetting v0.3.4, golang.org/x/text v0.40.0 +**Indirect:** gogpu/wgpu v0.31.2, gogpu/naga v0.18.0, gogpu/gputypes v0.5.2, go-text/typesetting v0.3.4, golang.org/x/text v0.40.0 Go version: **1.25.0** @@ -1553,4 +1622,4 @@ All types in `geometry/` are small structs passed by value. Operations return ne --- -*This document reflects the actual codebase as of July 16, 2026 (v0.1.45 — 26 interactive widgets, 4 design systems with 70 painters, per-widget layout caching ADR-032, Layer Tree compositor, damage-aware blit, unified draw queue ADR-051/052).* +*This document reflects the actual codebase as of August 13, 2026 (v0.1.54 — 27 interactive widgets, 4 design systems with 70 painters, gesture recognition ADR-049, Layer Tree compositor, damage-aware blit, unified draw queue ADR-051/052, OS clipboard).* From 08861590f435b5031a922d7da18813da72aa3085 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 13 Aug 2026 17:17:33 +0300 Subject: [PATCH 3/3] chore(deps): cascade update gg v0.52.3, gogpu v0.53.0, gpucontext v0.28.0, wgpu v0.31.4 --- CHANGELOG.md | 1 + go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af1ba52..1396c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All 20+ interactive widgets implement `gesture.GestureAware` — button, checkbox, radio, textfield, dropdown, slider, dialog, scrollview, tabview, listview, gridview, collapsible, popover, splitview, treeview, datatable, toolbar, menu, docking, chip, stripe, titlebar. - Event bridge: unified pointer pipeline (ADR-049 Phase 3) — pointer events flow through gesture arena before widget dispatch. +- **deps:** gg v0.52.2 → v0.52.3, gogpu v0.52.1 → v0.53.0, gpucontext v0.27.0 → v0.28.0, wgpu v0.31.2 → v0.31.4 ### Fixed diff --git a/go.mod b/go.mod index aca1938..fd5c582 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,12 @@ go 1.25.0 require ( github.com/coregx/signals v0.1.1 - github.com/gogpu/gg v0.52.2 - github.com/gogpu/gogpu v0.52.1 - github.com/gogpu/gpucontext v0.27.0 + github.com/gogpu/gg v0.52.3 + github.com/gogpu/gogpu v0.53.0 + github.com/gogpu/gpucontext v0.28.0 github.com/gogpu/gputypes v0.5.2 - github.com/gogpu/wgpu v0.31.2 - golang.org/x/image v0.44.0 + github.com/gogpu/wgpu v0.31.4 + golang.org/x/image v0.45.0 ) require ( @@ -17,5 +17,5 @@ require ( github.com/go-webgpu/webgpu v0.5.5 // indirect github.com/gogpu/naga v0.18.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 64b40a5..749cb97 100644 --- a/go.sum +++ b/go.sum @@ -4,21 +4,21 @@ github.com/go-webgpu/goffi v0.6.3 h1:p4gKGikHBAQ/8iUiew9MV4C5M1ZGIsk8QGFGuJjMj+A github.com/go-webgpu/goffi v0.6.3/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= github.com/go-webgpu/webgpu v0.5.5 h1:pIrXzRg0LRlNjNmR+ZNo/ERN88YaObzbCY5oYhir5SA= github.com/go-webgpu/webgpu v0.5.5/go.mod h1:vgIuNTa1UlZ4njCGY6Pmp/0c5T5o2Ml2fQ0znLc7B98= -github.com/gogpu/gg v0.52.2 h1:zW6uUnJH5iwD1auzTxNsOEIEpL/2vqRGcVLMLy4RDw8= -github.com/gogpu/gg v0.52.2/go.mod h1:DkSaPtl9pBAlsAbMjzan/czzITR7ObwQtv+gB5MGd4Q= -github.com/gogpu/gogpu v0.52.1 h1:7kUPtQY/WH8LUaqL8kh2w6y0ZSGlDvzj3hC7HzQd4Qs= -github.com/gogpu/gogpu v0.52.1/go.mod h1:ZH+X0Mc8y7M+H5gb+jTy5OPpR38ZKTgK8JW1USMcsF8= -github.com/gogpu/gpucontext v0.27.0 h1:iTEN2xcjcEivk6kIwX1mnAvEK2rGCjzGRlfnnn8Uphg= -github.com/gogpu/gpucontext v0.27.0/go.mod h1:OrT137boh5yPhqBEhF4UQKIOu1Jq74SLQzYa/m6JbNo= +github.com/gogpu/gg v0.52.3 h1:ID9lJOm0J1zlgpo/dPwgsGWBkXyXwZwlfKaCJOp4LBQ= +github.com/gogpu/gg v0.52.3/go.mod h1:2rjKl5sCJZEFmxq1sXEf+mYGEWYgCNKltg4aJEFGeCM= +github.com/gogpu/gogpu v0.53.0 h1:V+HMU+tJxw34LlfNzstUqXD9eXmyhrrBNmnrWxXR+o0= +github.com/gogpu/gogpu v0.53.0/go.mod h1:pUqsHA1Lo5qi4SNBYuJ1fyMqnwWMn3APHx6K63S3+mc= +github.com/gogpu/gpucontext v0.28.0 h1:W27+0PRnHLVRscDJ2L6r4B1JlaKjlV/EmL0Wxx8vGIc= +github.com/gogpu/gpucontext v0.28.0/go.mod h1:5rpKj+DpixgAFm8ArBXFXOZQRbeJSFncVMsqyyA9Rhc= github.com/gogpu/gputypes v0.5.2 h1:3sbKI0+XW36Ekd3d4QhkbpdY7gbgMVgp8Q4+ZNdGhtE= github.com/gogpu/gputypes v0.5.2/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw= github.com/gogpu/naga v0.18.0 h1:2y83HUcAnlwEZMuHOiYTMdNYM9J8rev40gkI4zJ96Uk= github.com/gogpu/naga v0.18.0/go.mod h1:15sQaHKkbqXcwTN+hHYGLsA0WBBnkmYzne/eF5p5WEg= -github.com/gogpu/wgpu v0.31.2 h1:AyduRrne97tnIxdvEf0MqB8p2p1n9jjhanQI1FdeS7M= -github.com/gogpu/wgpu v0.31.2/go.mod h1:S1QSiS+iAH7Gio4EdQVnH04An5cSQyogdwisit1+Q6Y= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +github.com/gogpu/wgpu v0.31.4 h1:xSwVJuew9RyJsA9cjdw2plzCGyFjCcpq5MstCM+S1N0= +github.com/gogpu/wgpu v0.31.4/go.mod h1:r8z3uZOyjl95KXN1eyMxFnwwPMZ0yuBFrLFi8v37QGY= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=