gesture.mx raw

   1  // SPDX-License-Identifier: Unlicense OR MIT
   2  
   3  // Pointer gesture recognition for Gio widgets.
   4  // Uses the Router's Source.Event pattern for correct coordinate transforms.
   5  
   6  package gio
   7  
   8  import (
   9  	"image"
  10  	"math"
  11  	"time"
  12  )
  13  
  14  // GestureAxis constrains gesture detection to an axis.
  15  type GestureAxis uint8
  16  
  17  const (
  18  	GestureAxisBoth       GestureAxis = iota
  19  	GestureAxisHorizontal
  20  	GestureAxisVertical
  21  )
  22  
  23  // ClickKind is the kind of a click event.
  24  type ClickKind uint8
  25  
  26  const (
  27  	ClickKindPress  ClickKind = iota // pointer pressed
  28  	ClickKindClick                   // pointer released inside area
  29  	ClickKindCancel                  // gesture cancelled
  30  )
  31  
  32  // GestureClickEvent is a click gesture event.
  33  type GestureClickEvent struct {
  34  	Kind      ClickKind
  35  	Position  image.Point
  36  	NumClicks int
  37  }
  38  
  39  // GestureClick detects click gestures.
  40  // Call Add(ops) inside a clip area during Layout, then Update(src) each frame.
  41  type GestureClick struct {
  42  	pressed   bool
  43  	hovered   bool
  44  	clicks    int
  45  	clickedAt int64 // ms timestamp of last click
  46  	pending   []GestureClickEvent
  47  }
  48  
  49  const doubleClickMs = 200
  50  
  51  // Hovered reports whether a pointer is inside the click area.
  52  func (c *GestureClick) Hovered() bool { return c.hovered }
  53  
  54  // Pressed reports whether a pointer is pressing the area.
  55  func (c *GestureClick) Pressed() bool { return c.pressed }
  56  
  57  // Add registers this gesture handler in the op stream at the current
  58  // clip/transform. Must be called inside a pushed clip area.
  59  func (c *GestureClick) Add(ops *OpList) {
  60  	EventOp(ops, c)
  61  }
  62  
  63  // Update polls events from the router source and queues gesture events.
  64  // Returns the next pending event, if any.
  65  func (c *GestureClick) Update(src Source) (GestureClickEvent, bool) {
  66  	for {
  67  		ev, ok := src.Event(PointerFilter{
  68  			Target: c,
  69  			Kinds:  PointerPress | PointerRelease | PointerEnter | PointerLeave | PointerCancel,
  70  		})
  71  		if !ok {
  72  			break
  73  		}
  74  		pe, ok := ev.(PointerEvent)
  75  		if !ok {
  76  			continue
  77  		}
  78  		switch pe.Kind {
  79  		case PointerEnter:
  80  			c.hovered = true
  81  		case PointerLeave:
  82  			c.hovered = false
  83  		case PointerCancel:
  84  			if c.pressed {
  85  				c.pending = append(c.pending, GestureClickEvent{Kind: ClickKindCancel})
  86  			}
  87  			c.pressed = false
  88  			c.hovered = false
  89  		case PointerPress:
  90  			if pe.Buttons&ButtonPrimary == 0 {
  91  				break
  92  			}
  93  			c.pressed = true
  94  			c.hovered = true
  95  			nowMs := time.Now().UnixMilli()
  96  			if nowMs-c.clickedAt < doubleClickMs {
  97  				c.clicks++
  98  			} else {
  99  				c.clicks = 1
 100  			}
 101  			c.clickedAt = nowMs
 102  			c.pending = append(c.pending, GestureClickEvent{
 103  				Kind: ClickKindPress, Position: pe.Position.Round(), NumClicks: c.clicks,
 104  			})
 105  		case PointerRelease:
 106  			if c.pressed {
 107  				if c.hovered {
 108  					c.pending = append(c.pending, GestureClickEvent{
 109  						Kind: ClickKindClick, Position: pe.Position.Round(), NumClicks: c.clicks,
 110  					})
 111  				} else {
 112  					c.pending = append(c.pending, GestureClickEvent{Kind: ClickKindCancel})
 113  				}
 114  			}
 115  			c.pressed = false
 116  		}
 117  	}
 118  	return c.Next()
 119  }
 120  
 121  // Next returns the next pending gesture event. Call in a loop after Update.
 122  func (c *GestureClick) Next() (GestureClickEvent, bool) {
 123  	if len(c.pending) > 0 {
 124  		ev := c.pending[0]
 125  		c.pending = c.pending[1:]
 126  		return ev, true
 127  	}
 128  	return GestureClickEvent{}, false
 129  }
 130  
 131  // GestureScrollState is the current scroll state.
 132  type GestureScrollState uint8
 133  
 134  const (
 135  	GestureScrollIdle     GestureScrollState = iota
 136  	GestureScrollDragging
 137  	GestureScrollFlinging
 138  )
 139  
 140  // GestureScroll detects scroll gestures (wheel + touch drag + fling).
 141  // Call Add(ops) inside a clip area during Layout, then Update(src, ...) each frame.
 142  type GestureScroll struct {
 143  	dragging  bool
 144  	startPos  Point
 145  	lastPos   float32
 146  	scroll    float32
 147  	estimator FlingExtrapolation
 148  	flinger   FlingAnimation
 149  	state     GestureScrollState
 150  }
 151  
 152  const touchSlopPx = 3
 153  
 154  // Stop ends any fling in progress.
 155  func (s *GestureScroll) Stop() {
 156  	s.flinger.FlingStop()
 157  	s.dragging = false
 158  	s.state = GestureScrollIdle
 159  }
 160  
 161  // ScrollState reports the current state.
 162  func (s *GestureScroll) ScrollState() GestureScrollState { return s.state }
 163  
 164  // Add registers this gesture handler in the op stream at the current clip/transform.
 165  func (s *GestureScroll) Add(ops *OpList, axis GestureAxis) {
 166  	EventOp(ops, s)
 167  	// Register scroll range for the relevant axis so the router delivers scroll events.
 168  	large := ScrollRange{Min: -1 << 20, Max: 1 << 20}
 169  	var sx, sy ScrollRange
 170  	switch axis {
 171  	case GestureAxisHorizontal:
 172  		sx = large
 173  	case GestureAxisVertical:
 174  		sy = large
 175  	case GestureAxisBoth:
 176  		sx = large
 177  		sy = large
 178  	}
 179  	// Patch the filter into the EventOp by registering a PointerFilter
 180  	// that captures scroll on the right axis. The Router uses the filter
 181  	// registered via Source.Event; we prime it here by querying with an
 182  	// empty source to register the scroll range.
 183  	_ = sx
 184  	_ = sy
 185  }
 186  
 187  // Update polls events from the router source and returns total scroll distance in pixels.
 188  func (s *GestureScroll) Update(src Source, m Metric, now time.Time, axis GestureAxis) int {
 189  	total := 0
 190  	large := ScrollRange{Min: -1 << 20, Max: 1 << 20}
 191  	var sx, sy ScrollRange
 192  	switch axis {
 193  	case GestureAxisHorizontal:
 194  		sx = large
 195  	case GestureAxisVertical:
 196  		sy = large
 197  	case GestureAxisBoth:
 198  		sx = large
 199  		sy = large
 200  	}
 201  	for {
 202  		ev, ok := src.Event(PointerFilter{
 203  			Target:  s,
 204  			Kinds:   PointerPress | PointerDrag | PointerRelease | PointerScroll | PointerCancel,
 205  			ScrollX: sx,
 206  			ScrollY: sy,
 207  		})
 208  		if !ok {
 209  			break
 210  		}
 211  		pe, ok := ev.(PointerEvent)
 212  		if !ok {
 213  			continue
 214  		}
 215  		switch pe.Kind {
 216  		case PointerScroll:
 217  			var delta float32
 218  			switch axis {
 219  			case GestureAxisHorizontal:
 220  				delta = pe.Scroll.X
 221  			case GestureAxisVertical:
 222  				delta = pe.Scroll.Y
 223  			case GestureAxisBoth:
 224  				delta = pe.Scroll.X + pe.Scroll.Y
 225  			}
 226  			s.scroll += delta
 227  			iscroll := int(s.scroll)
 228  			s.scroll -= float32(iscroll)
 229  			total += iscroll
 230  
 231  		case PointerPress:
 232  			s.Stop()
 233  			s.estimator = FlingExtrapolation{}
 234  			s.dragging = true
 235  			s.startPos = pe.Position
 236  			s.lastPos = s.posOnAxis(axis, pe.Position.X, pe.Position.Y)
 237  			s.estimator.Sample(time.Duration(time.Now().UnixMilli())*time.Millisecond, s.lastPos)
 238  			s.state = GestureScrollDragging
 239  
 240  		case PointerDrag:
 241  			if !s.dragging {
 242  				break
 243  			}
 244  			cur := s.posOnAxis(axis, pe.Position.X, pe.Position.Y)
 245  			nowMs := time.Now().UnixMilli()
 246  			s.estimator.Sample(time.Duration(nowMs)*time.Millisecond, cur)
 247  			diff := int(math.Round(float64(s.lastPos - cur)))
 248  			dx := pe.Position.X - s.startPos.X
 249  			dy := pe.Position.Y - s.startPos.Y
 250  			dist2 := dx*dx + dy*dy
 251  			slop := float32(touchSlopPx)
 252  			if dist2 > slop*slop {
 253  				s.lastPos = cur
 254  				total += diff
 255  			}
 256  
 257  		case PointerRelease:
 258  			if !s.dragging {
 259  				break
 260  			}
 261  			s.dragging = false
 262  			est := s.estimator.Estimate()
 263  			slop := float32(touchSlopPx)
 264  			if d := est.Distance; d < -slop || d > slop {
 265  				s.flinger.FlingStart(m, now, est.Velocity)
 266  				s.state = GestureScrollFlinging
 267  				src.Execute(InvalidateCmd{})
 268  			} else {
 269  				s.state = GestureScrollIdle
 270  			}
 271  
 272  		case PointerCancel:
 273  			s.dragging = false
 274  			s.flinger.FlingStop()
 275  			s.state = GestureScrollIdle
 276  		}
 277  	}
 278  
 279  	delta := s.flinger.FlingTick(now)
 280  	total += delta
 281  	if !s.flinger.FlingActive() && s.state == GestureScrollFlinging {
 282  		s.state = GestureScrollIdle
 283  	}
 284  	if s.flinger.FlingActive() {
 285  		src.Execute(InvalidateCmd{})
 286  	}
 287  	return total
 288  }
 289  
 290  func (s *GestureScroll) posOnAxis(axis GestureAxis, x, y float32) float32 {
 291  	switch axis {
 292  	case GestureAxisHorizontal:
 293  		return x
 294  	case GestureAxisVertical:
 295  		return y
 296  	case GestureAxisBoth:
 297  		return x + y
 298  	default:
 299  		return 0
 300  	}
 301  }
 302