mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35:40 +00:00
34273940a0
- Allow dragging to be on both horizontal and vertical axes at once. - Split Editor.caret.pos into caret.start and caret.stop. caret.start is the old caret.pos, and is both the position of the caret, and also the start of selected text. caret.end is the end of the selected text. Start can be after end, e.g. after after Shift-DownArrow. - Update caret.end after a mouse drag, and various shifted keys (Shift-UpArrow, Shift-DownArrow, etc). - Change Shortcut-C to copy only the selected text, not the whole editor text. - Add Shortcut-X to copy and delete selected text, and Shortcut-A to select all text. - The various Insert/Delete/etc functions now overwrite or delete the selection, as appropriate. - Change MoveCaret to accept a distance for selection end, as well. Change SetCaret to accept a selection end offset. - Add SelectionLen to get the selection length, Selection to get selection offsets, SelectedText to get the selected text, and ClearSelection to clear the selection. - Add a rudimentary selection unit test, and extend the deleteWord unit test with some text selection cases. - Add SelectionColor to material.EditorStyle, which defaults to Theme.Palette.ContrastBg. Signed-off-by: Larry Clapp <larry@theclapp.org>
425 lines
8.9 KiB
Go
425 lines
8.9 KiB
Go
// 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 (
|
|
"math"
|
|
"runtime"
|
|
"time"
|
|
|
|
"gioui.org/f32"
|
|
"gioui.org/internal/fling"
|
|
"gioui.org/io/event"
|
|
"gioui.org/io/key"
|
|
"gioui.org/io/pointer"
|
|
"gioui.org/op"
|
|
"gioui.org/unit"
|
|
)
|
|
|
|
// The duration is somewhat arbitrary.
|
|
const doubleClickDuration = 200 * 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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) {
|
|
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++
|
|
} else {
|
|
c.clicks = 1
|
|
}
|
|
c.clickedAt = e.Time
|
|
events = append(events, ClickEvent{Type: TypeClick, Position: e.Position, Source: e.Source, Modifiers: e.Modifiers, 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.ButtonLeft {
|
|
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})
|
|
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) {
|
|
oph := pointer.InputOp{
|
|
Tag: s,
|
|
Grab: s.grab,
|
|
Types: pointer.Press | pointer.Drag | pointer.Release | pointer.Scroll,
|
|
}
|
|
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:
|
|
if e.Priority < pointer.Foremost {
|
|
continue
|
|
}
|
|
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.ButtonLeft || 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")
|
|
}
|
|
}
|