// SPDX-License-Identifier: Unlicense OR MIT // Pointer gesture recognition for Gio widgets. // Uses the Router's Source.Event pattern for correct coordinate transforms. package gio import ( "image" "math" "time" ) // GestureAxis constrains gesture detection to an axis. type GestureAxis uint8 const ( GestureAxisBoth GestureAxis = iota GestureAxisHorizontal GestureAxisVertical ) // ClickKind is the kind of a click event. type ClickKind uint8 const ( ClickKindPress ClickKind = iota // pointer pressed ClickKindClick // pointer released inside area ClickKindCancel // gesture cancelled ) // GestureClickEvent is a click gesture event. type GestureClickEvent struct { Kind ClickKind Position image.Point NumClicks int } // GestureClick detects click gestures. // Call Add(ops) inside a clip area during Layout, then Update(src) each frame. type GestureClick struct { pressed bool hovered bool clicks int clickedAt int64 // ms timestamp of last click pending []GestureClickEvent } const doubleClickMs = 200 // Hovered reports whether a pointer is inside the click area. func (c *GestureClick) Hovered() bool { return c.hovered } // Pressed reports whether a pointer is pressing the area. func (c *GestureClick) Pressed() bool { return c.pressed } // Add registers this gesture handler in the op stream at the current // clip/transform. Must be called inside a pushed clip area. func (c *GestureClick) Add(ops *OpList) { EventOp(ops, c) } // Update polls events from the router source and queues gesture events. // Returns the next pending event, if any. func (c *GestureClick) Update(src Source) (GestureClickEvent, bool) { for { ev, ok := src.Event(PointerFilter{ Target: c, Kinds: PointerPress | PointerRelease | PointerEnter | PointerLeave | PointerCancel, }) if !ok { break } pe, ok := ev.(PointerEvent) if !ok { continue } switch pe.Kind { case PointerEnter: c.hovered = true case PointerLeave: c.hovered = false case PointerCancel: if c.pressed { c.pending = append(c.pending, GestureClickEvent{Kind: ClickKindCancel}) } c.pressed = false c.hovered = false case PointerPress: if pe.Buttons&ButtonPrimary == 0 { break } c.pressed = true c.hovered = true nowMs := time.Now().UnixMilli() if nowMs-c.clickedAt < doubleClickMs { c.clicks++ } else { c.clicks = 1 } c.clickedAt = nowMs c.pending = append(c.pending, GestureClickEvent{ Kind: ClickKindPress, Position: pe.Position.Round(), NumClicks: c.clicks, }) case PointerRelease: if c.pressed { if c.hovered { c.pending = append(c.pending, GestureClickEvent{ Kind: ClickKindClick, Position: pe.Position.Round(), NumClicks: c.clicks, }) } else { c.pending = append(c.pending, GestureClickEvent{Kind: ClickKindCancel}) } } c.pressed = false } } return c.Next() } // Next returns the next pending gesture event. Call in a loop after Update. func (c *GestureClick) Next() (GestureClickEvent, bool) { if len(c.pending) > 0 { ev := c.pending[0] c.pending = c.pending[1:] return ev, true } return GestureClickEvent{}, false } // GestureScrollState is the current scroll state. type GestureScrollState uint8 const ( GestureScrollIdle GestureScrollState = iota GestureScrollDragging GestureScrollFlinging ) // GestureScroll detects scroll gestures (wheel + touch drag + fling). // Call Add(ops) inside a clip area during Layout, then Update(src, ...) each frame. type GestureScroll struct { dragging bool startPos Point lastPos float32 scroll float32 estimator FlingExtrapolation flinger FlingAnimation state GestureScrollState } const touchSlopPx = 3 // Stop ends any fling in progress. func (s *GestureScroll) Stop() { s.flinger.FlingStop() s.dragging = false s.state = GestureScrollIdle } // ScrollState reports the current state. func (s *GestureScroll) ScrollState() GestureScrollState { return s.state } // Add registers this gesture handler in the op stream at the current clip/transform. func (s *GestureScroll) Add(ops *OpList, axis GestureAxis) { EventOp(ops, s) // Register scroll range for the relevant axis so the router delivers scroll events. large := ScrollRange{Min: -1 << 20, Max: 1 << 20} var sx, sy ScrollRange switch axis { case GestureAxisHorizontal: sx = large case GestureAxisVertical: sy = large case GestureAxisBoth: sx = large sy = large } // Patch the filter into the EventOp by registering a PointerFilter // that captures scroll on the right axis. The Router uses the filter // registered via Source.Event; we prime it here by querying with an // empty source to register the scroll range. _ = sx _ = sy } // Update polls events from the router source and returns total scroll distance in pixels. func (s *GestureScroll) Update(src Source, m Metric, now time.Time, axis GestureAxis) int { total := 0 large := ScrollRange{Min: -1 << 20, Max: 1 << 20} var sx, sy ScrollRange switch axis { case GestureAxisHorizontal: sx = large case GestureAxisVertical: sy = large case GestureAxisBoth: sx = large sy = large } for { ev, ok := src.Event(PointerFilter{ Target: s, Kinds: PointerPress | PointerDrag | PointerRelease | PointerScroll | PointerCancel, ScrollX: sx, ScrollY: sy, }) if !ok { break } pe, ok := ev.(PointerEvent) if !ok { continue } switch pe.Kind { case PointerScroll: var delta float32 switch axis { case GestureAxisHorizontal: delta = pe.Scroll.X case GestureAxisVertical: delta = pe.Scroll.Y case GestureAxisBoth: delta = pe.Scroll.X + pe.Scroll.Y } s.scroll += delta iscroll := int(s.scroll) s.scroll -= float32(iscroll) total += iscroll case PointerPress: s.Stop() s.estimator = FlingExtrapolation{} s.dragging = true s.startPos = pe.Position s.lastPos = s.posOnAxis(axis, pe.Position.X, pe.Position.Y) s.estimator.Sample(time.Duration(time.Now().UnixMilli())*time.Millisecond, s.lastPos) s.state = GestureScrollDragging case PointerDrag: if !s.dragging { break } cur := s.posOnAxis(axis, pe.Position.X, pe.Position.Y) nowMs := time.Now().UnixMilli() s.estimator.Sample(time.Duration(nowMs)*time.Millisecond, cur) diff := int(math.Round(float64(s.lastPos - cur))) dx := pe.Position.X - s.startPos.X dy := pe.Position.Y - s.startPos.Y dist2 := dx*dx + dy*dy slop := float32(touchSlopPx) if dist2 > slop*slop { s.lastPos = cur total += diff } case PointerRelease: if !s.dragging { break } s.dragging = false est := s.estimator.Estimate() slop := float32(touchSlopPx) if d := est.Distance; d < -slop || d > slop { s.flinger.FlingStart(m, now, est.Velocity) s.state = GestureScrollFlinging src.Execute(InvalidateCmd{}) } else { s.state = GestureScrollIdle } case PointerCancel: s.dragging = false s.flinger.FlingStop() s.state = GestureScrollIdle } } delta := s.flinger.FlingTick(now) total += delta if !s.flinger.FlingActive() && s.state == GestureScrollFlinging { s.state = GestureScrollIdle } if s.flinger.FlingActive() { src.Execute(InvalidateCmd{}) } return total } func (s *GestureScroll) posOnAxis(axis GestureAxis, x, y float32) float32 { switch axis { case GestureAxisHorizontal: return x case GestureAxisVertical: return y case GestureAxisBoth: return x + y default: return 0 } }