// SPDX-License-Identifier: Unlicense OR MIT /* Package gesture implements common pointer gestures. Gestures accept low level pointer Events from an event Queue and detect higher level actions such as clicks and scrolling. */ package gesture import ( "image" "math" "runtime" "time" "github.com/p9c/p9/pkg/gel/gio/f32" "github.com/p9c/p9/pkg/gel/gio/io/event" "github.com/p9c/p9/pkg/gel/gio/io/key" "github.com/p9c/p9/pkg/gel/gio/io/pointer" "github.com/p9c/p9/pkg/gel/gio/op" "github.com/p9c/p9/pkg/gel/gio/unit" "github.com/p9c/p9/pkg/gel/gio/internal/fling" ) // The duration is somewhat arbitrary. const doubleClickDuration = 300 * time.Millisecond // Click detects click gestures in the form // of ClickEvents. type Click struct { // clickedAt is the timestamp at which // the last click occurred. clickedAt time.Duration // clicks is incremented if successive clicks // are performed within a fixed duration. clicks int // pressed tracks whether the pointer is pressed. pressed bool // entered tracks whether the pointer is inside the gesture. entered bool // pid is the pointer.ID. pid pointer.ID Button pointer.Buttons } type ClickState uint8 // ClickEvent represent a click action, either a // TypePress for the beginning of a click or a // TypeClick for a completed click. type ClickEvent struct { Type ClickType Position f32.Point Source pointer.Source Modifiers key.Modifiers // NumClicks records successive clicks occurring // within a short duration of each other. NumClicks int Button pointer.Buttons } type ClickType uint8 // Drag detects drag gestures in the form of pointer.Drag events. type Drag struct { dragging bool pid pointer.ID start f32.Point grab bool } // Scroll detects scroll gestures and reduces them to // scroll distances. Scroll recognizes mouse wheel // movements as well as drag and fling touch gestures. type Scroll struct { dragging bool axis Axis estimator fling.Extrapolation flinger fling.Animation pid pointer.ID grab bool last int // Leftover scroll. scroll float32 } type ScrollState uint8 type Axis uint8 const ( Horizontal Axis = iota Vertical Both ) const ( // TypePress is reported for the first pointer // press. TypePress ClickType = iota // TypeClick is reported when a click action // is complete. TypeClick // TypeCancel is reported when the gesture is // cancelled. TypeCancel ) const ( // StateIdle is the default scroll state. StateIdle ScrollState = iota // StateDrag is reported during drag gestures. StateDragging // StateFlinging is reported when a fling is // in progress. StateFlinging ) var touchSlop = unit.Dp(3) // Add the handler to the operation list to receive click events. func (c *Click) Add(ops *op.Ops) { op := pointer.InputOp{ Tag: c, Types: pointer.Press | pointer.Release | pointer.Enter | pointer.Leave, } op.Add(ops) } // Hovered returns whether a pointer is inside the area. func (c *Click) Hovered() bool { return c.entered } // Pressed returns whether a pointer is pressing. func (c *Click) Pressed() bool { return c.pressed } // Events returns the next click event, if any. func (c *Click) Events(q event.Queue) []ClickEvent { var events []ClickEvent for _, evt := range q.Events(c) { // I.S(evt) e, ok := evt.(pointer.Event) if !ok { continue } switch e.Type { case pointer.Release: if !c.pressed || c.pid != e.PointerID { break } c.pressed = false if c.entered { if e.Time-c.clickedAt < doubleClickDuration || (c.clicks == 2 && e.Time-c.clickedAt < doubleClickDuration*2) { c.clicks++ } else { c.clicks = 1 } c.clickedAt = e.Time events = append(events, ClickEvent{ Type: TypeClick, Position: e.Position, Source: e.Source, Modifiers: e.Modifiers, Button: e.Buttons, NumClicks: c.clicks, }) } else { events = append(events, ClickEvent{Type: TypeCancel}) } case pointer.Cancel: wasPressed := c.pressed c.pressed = false c.entered = false if wasPressed { events = append(events, ClickEvent{Type: TypeCancel}) } case pointer.Press: if c.pressed { break } // if e.Source == pointer.Mouse && e.Buttons != pointer.ButtonPrimary { // break // } if !c.entered { c.pid = e.PointerID } if c.pid != e.PointerID { break } c.pressed = true events = append(events, ClickEvent{ Type: TypePress, Position: e.Position, Source: e.Source, Modifiers: e.Modifiers, Button: e.Buttons, }) case pointer.Leave: if !c.pressed { c.pid = e.PointerID } if c.pid == e.PointerID { c.entered = false } case pointer.Enter: if !c.pressed { c.pid = e.PointerID } if c.pid == e.PointerID { c.entered = true } } } return events } func (ClickEvent) ImplementsEvent() {} // Add the handler to the operation list to receive scroll events. func (s *Scroll) Add(ops *op.Ops, bounds image.Rectangle) { oph := pointer.InputOp{ Tag: s, Grab: s.grab, Types: pointer.Press | pointer.Drag | pointer.Release | pointer.Scroll, ScrollBounds: bounds, } oph.Add(ops) if s.flinger.Active() { op.InvalidateOp{}.Add(ops) } } // Stop any remaining fling movement. func (s *Scroll) Stop() { s.flinger = fling.Animation{} } // Scroll detects the scrolling distance from the available events and // ongoing fling gestures. func (s *Scroll) Scroll(cfg unit.Metric, q event.Queue, t time.Time, axis Axis) int { if s.axis != axis { s.axis = axis return 0 } total := 0 for _, evt := range q.Events(s) { e, ok := evt.(pointer.Event) if !ok { continue } switch e.Type { case pointer.Press: if s.dragging { break } // Only scroll on touch drags, or on Android where mice // drags also scroll by convention. if e.Source != pointer.Touch && runtime.GOOS != "android" { break } s.Stop() s.estimator = fling.Extrapolation{} v := s.val(e.Position) s.last = int(math.Round(float64(v))) s.estimator.Sample(e.Time, v) s.dragging = true s.pid = e.PointerID case pointer.Release: if s.pid != e.PointerID { break } fling := s.estimator.Estimate() if slop, d := float32(cfg.Px(touchSlop)), fling.Distance; d < -slop || d > slop { s.flinger.Start(cfg, t, fling.Velocity) } fallthrough case pointer.Cancel: s.dragging = false s.grab = false case pointer.Scroll: switch s.axis { case Horizontal: s.scroll += e.Scroll.X case Vertical: s.scroll += e.Scroll.Y } iscroll := int(s.scroll) s.scroll -= float32(iscroll) total += iscroll case pointer.Drag: if !s.dragging || s.pid != e.PointerID { continue } val := s.val(e.Position) s.estimator.Sample(e.Time, val) v := int(math.Round(float64(val))) dist := s.last - v if e.Priority < pointer.Grabbed { slop := cfg.Px(touchSlop) if dist := dist; dist >= slop || -slop >= dist { s.grab = true } } else { s.last = v total += dist } } } total += s.flinger.Tick(t) return total } func (s *Scroll) val(p f32.Point) float32 { if s.axis == Horizontal { return p.X } else { return p.Y } } // State reports the scroll state. func (s *Scroll) State() ScrollState { switch { case s.flinger.Active(): return StateFlinging case s.dragging: return StateDragging default: return StateIdle } } // Add the handler to the operation list to receive drag events. func (d *Drag) Add(ops *op.Ops) { op := pointer.InputOp{ Tag: d, Grab: d.grab, Types: pointer.Press | pointer.Drag | pointer.Release, } op.Add(ops) } // Events returns the next drag events, if any. func (d *Drag) Events(cfg unit.Metric, q event.Queue, axis Axis) []pointer.Event { var events []pointer.Event for _, e := range q.Events(d) { e, ok := e.(pointer.Event) if !ok { continue } switch e.Type { case pointer.Press: if !(e.Buttons == pointer.ButtonPrimary || e.Source == pointer.Touch) { continue } if d.dragging { continue } d.dragging = true d.pid = e.PointerID d.start = e.Position case pointer.Drag: if !d.dragging || e.PointerID != d.pid { continue } switch axis { case Horizontal: e.Position.Y = d.start.Y case Vertical: e.Position.X = d.start.X case Both: // Do nothing } if e.Priority < pointer.Grabbed { diff := e.Position.Sub(d.start) slop := cfg.Px(touchSlop) if diff.X*diff.X+diff.Y*diff.Y > float32(slop*slop) { d.grab = true } } case pointer.Release, pointer.Cancel: if !d.dragging || e.PointerID != d.pid { continue } d.dragging = false d.grab = false } events = append(events, e) } return events } // Dragging reports whether it's currently in use. func (d *Drag) Dragging() bool { return d.dragging } func (a Axis) String() string { switch a { case Horizontal: return "Horizontal" case Vertical: return "Vertical" default: panic("invalid Axis") } } func (ct ClickType) String() string { switch ct { case TypePress: return "TypePress" case TypeClick: return "TypeClick" case TypeCancel: return "TypeCancel" default: panic("invalid ClickType") } } func (s ScrollState) String() string { switch s { case StateIdle: return "StateIdle" case StateDragging: return "StateDragging" case StateFlinging: return "StateFlinging" default: panic("unreachable") } }