diff --git a/internal/driver/glfw/window.go b/internal/driver/glfw/window.go index ec7f0c89d1..e80941acc9 100644 --- a/internal/driver/glfw/window.go +++ b/internal/driver/glfw/window.go @@ -3,6 +3,7 @@ package glfw import "C" import ( "bytes" + "context" "image" _ "image/png" // for the icon "runtime" @@ -21,7 +22,7 @@ import ( const ( scrollSpeed = 10 - doubleClickDelay = 500 // ms (maximum interval between clicks for double click detection) + doubleClickDelay = 300 // ms (maximum interval between clicks for double click detection) ) var ( @@ -73,6 +74,8 @@ type window struct { mouseClickTime time.Time mouseLastClick fyne.CanvasObject mousePressed fyne.CanvasObject + mouseClickCount int + mouseCancelFunc context.CancelFunc onClosed func() onCloseIntercepted func() @@ -640,7 +643,7 @@ func (w *window) mouseOut() { func (w *window) mouseClicked(_ *glfw.Window, btn glfw.MouseButton, action glfw.Action, mods glfw.ModifierKey) { co, pos, layer := w.findObjectAtPositionMatching(w.canvas, w.mousePos, func(object fyne.CanvasObject) bool { switch object.(type) { - case fyne.Tappable, fyne.SecondaryTappable, fyne.Focusable, fyne.Draggable, desktop.Mouseable, desktop.Hoverable: + case fyne.Tappable, fyne.SecondaryTappable, fyne.DoubleTappable, fyne.Focusable, fyne.Draggable, desktop.Mouseable, desktop.Hoverable: return true } @@ -656,7 +659,6 @@ func (w *window) mouseClicked(_ *glfw.Window, btn glfw.MouseButton, action glfw. co, _ = w.mouseDragged.(fyne.CanvasObject) ev.Position = w.mousePos.Subtract(w.mouseDraggedOffset).Subtract(co.Position()) } - button, modifiers := convertMouseButton(btn, mods) if wid, ok := co.(desktop.Mouseable); ok { mev := new(desktop.MouseEvent) @@ -685,55 +687,77 @@ func (w *window) mouseClicked(_ *glfw.Window, btn glfw.MouseButton, action glfw. w.mouseButton = 0 } - // Check for double click/tap - doubleTapped := false - if action == glfw.Release && button == desktop.LeftMouseButton { - now := time.Now() - // we can safely subtract the first "zero" time as it'll be much larger than doubleClickDelay - if now.Sub(w.mouseClickTime).Nanoseconds()/1e6 <= doubleClickDelay && w.mouseLastClick == co { - if wid, ok := co.(fyne.DoubleTappable); ok { - doubleTapped = true - w.queueEvent(func() { wid.DoubleTapped(ev) }) - } + if wid, ok := co.(fyne.Draggable); ok { + if action == glfw.Press { + w.mouseDragPos = w.mousePos + w.mouseDragged = wid + w.mouseDraggedOffset = w.mousePos.Subtract(co.Position()).Subtract(ev.Position) } - w.mouseClickTime = now - w.mouseLastClick = co } - + if action == glfw.Release && w.mouseDragged != nil { + if w.mouseDragStarted { + w.queueEvent(w.mouseDragged.DragEnd) + w.mouseDragStarted = false + } + if w.objIsDragged(w.mouseOver) && !w.objIsDragged(coMouse) { + w.mouseOut() + } + w.mouseDragged = nil + } _, tap := co.(fyne.Tappable) _, altTap := co.(fyne.SecondaryTappable) - // Prevent Tapped from triggering if DoubleTapped has been sent - if (tap || altTap) && !doubleTapped { + if tap || altTap { if action == glfw.Press { w.mousePressed = co } else if action == glfw.Release { if co == w.mousePressed { if button == desktop.RightMouseButton && altTap { w.queueEvent(func() { co.(fyne.SecondaryTappable).TappedSecondary(ev) }) - } else if button == desktop.LeftMouseButton && tap { - w.queueEvent(func() { co.(fyne.Tappable).Tapped(ev) }) } } - w.mousePressed = nil } } - if wid, ok := co.(fyne.Draggable); ok { - if action == glfw.Press { - w.mouseDragPos = w.mousePos - w.mouseDragged = wid - w.mouseDraggedOffset = w.mousePos.Subtract(co.Position()).Subtract(ev.Position) + + // Check for double click/tap on left mouse button + if action == glfw.Release && button == desktop.LeftMouseButton { + _, doubleTap := co.(fyne.DoubleTappable) + if doubleTap { + w.mouseClickCount++ + w.mouseLastClick = co + if w.mouseCancelFunc != nil { + w.mouseCancelFunc() + return + } + go w.waitForDoubleTap(co, ev) + } else { + if wid, ok := co.(fyne.Tappable); ok && co == w.mousePressed { + w.queueEvent(func() { wid.Tapped(ev) }) + } + w.mousePressed = nil } } - if action == glfw.Release && w.mouseDragged != nil { - if w.mouseDragStarted { - w.queueEvent(w.mouseDragged.DragEnd) - w.mouseDragStarted = false +} + +func (w *window) waitForDoubleTap(co fyne.CanvasObject, ev *fyne.PointEvent) { + var ctx context.Context + ctx, w.mouseCancelFunc = context.WithDeadline(context.TODO(), time.Now().Add(time.Millisecond*doubleClickDelay)) + defer w.mouseCancelFunc() + + <-ctx.Done() + if w.mouseClickCount == 2 && w.mouseLastClick == co { + if wid, ok := co.(fyne.DoubleTappable); ok { + w.queueEvent(func() { wid.DoubleTapped(ev) }) } - if w.objIsDragged(w.mouseOver) && !w.objIsDragged(coMouse) { - w.mouseOut() + } else if co == w.mousePressed { + if wid, ok := co.(fyne.Tappable); ok { + w.queueEvent(func() { wid.Tapped(ev) }) } - w.mouseDragged = nil } + w.mouseClickCount = 0 + w.mousePressed = nil + w.mouseCancelFunc = nil + w.mouseLastClick = nil + return } func (w *window) mouseScrolled(viewport *glfw.Window, xoff float64, yoff float64) { diff --git a/internal/driver/glfw/window_test.go b/internal/driver/glfw/window_test.go index 1d352588cb..840b7ca256 100644 --- a/internal/driver/glfw/window_test.go +++ b/internal/driver/glfw/window_test.go @@ -465,11 +465,13 @@ func TestWindow_TappedSecondary_OnPrimaryOnlyTarget(t *testing.T) { w.mouseClicked(w.viewport, glfw.MouseButton2, glfw.Press, 0) w.mouseClicked(w.viewport, glfw.MouseButton2, glfw.Release, 0) w.waitForEvents() + assert.False(t, tapped) w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Press, 0) w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Release, 0) w.waitForEvents() + assert.True(t, tapped) } @@ -497,6 +499,7 @@ func TestWindow_TappedIgnoresScrollerClip(t *testing.T) { w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Release, 0) w.waitForEvents() + assert.False(t, tapped, "Tapped button that was clipped") w.mousePos = fyne.NewPos(10, 120) @@ -504,6 +507,7 @@ func TestWindow_TappedIgnoresScrollerClip(t *testing.T) { w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Release, 0) w.waitForEvents() + assert.True(t, tapped, "Tapped button that was clipped") } @@ -519,6 +523,7 @@ func TestWindow_TappedIgnoredWhenMovedOffOfTappable(t *testing.T) { w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Release, 0) w.waitForEvents() + assert.Equal(t, 1, tapped, "Button 1 should be tapped") tapped = 0 @@ -527,15 +532,51 @@ func TestWindow_TappedIgnoredWhenMovedOffOfTappable(t *testing.T) { w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Release, 0) w.waitForEvents() + assert.Equal(t, 0, tapped, "button was tapped without mouse press & release on it %d", tapped) w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Press, 0) w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Release, 0) w.waitForEvents() + assert.Equal(t, 2, tapped, "Button 2 should be tapped") } +func TestWindow_TappedAndDoubleTapped(t *testing.T) { + w := createWindow("Test").(*window) + tapped := 0 + but := newDoubleTappableButton() + but.OnTapped = func() { + tapped = 1 + } + but.onDoubleTap = func() { + tapped = 2 + } + w.SetContent(fyne.NewContainerWithLayout(layout.NewBorderLayout(nil, nil, nil, nil), but)) + + w.mouseMoved(w.viewport, 15, 25) + w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Press, 0) + w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Release, 0) + + w.waitForEvents() + time.Sleep(500 * time.Millisecond) + + assert.Equal(t, 1, tapped, "Single tap should have fired") + tapped = 0 + + w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Press, 0) + w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Release, 0) + w.waitForEvents() + w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Press, 0) + w.mouseClicked(w.viewport, glfw.MouseButton1, glfw.Release, 0) + + w.waitForEvents() + time.Sleep(500 * time.Millisecond) + + assert.Equal(t, 2, tapped, "Double tap should have fired") +} + func TestWindow_MouseEventContainsModifierKeys(t *testing.T) { w := createWindow("Test").(*window) m := &mouseableObject{Rectangle: canvas.NewRectangle(color.White)} @@ -1088,3 +1129,20 @@ func pop(s []interface{}) (interface{}, []interface{}) { } return s[0], s[1:] } + +type doubleTappableButton struct { + widget.Button + + onDoubleTap func() +} + +func (t *doubleTappableButton) DoubleTapped(_ *fyne.PointEvent) { + t.onDoubleTap() +} + +func newDoubleTappableButton() *doubleTappableButton { + but := &doubleTappableButton{} + but.ExtendBaseWidget(but) + + return but +} diff --git a/internal/driver/gomobile/canvas.go b/internal/driver/gomobile/canvas.go index 039486cfc8..205c097383 100644 --- a/internal/driver/gomobile/canvas.go +++ b/internal/driver/gomobile/canvas.go @@ -1,6 +1,7 @@ package gomobile import ( + "context" "image" "math" "time" @@ -38,8 +39,16 @@ type mobileCanvas struct { dragging fyne.Draggable refreshQueue chan fyne.CanvasObject minSizeCache map[fyne.CanvasObject]fyne.Size + + touchTapCount int + touchCancelFunc context.CancelFunc + touchLastTapped fyne.CanvasObject } +const ( + doubleClickDelay = 500 // ms (maximum interval between clicks for double click detection) +) + func (c *mobileCanvas) Content() fyne.CanvasObject { return c.content } @@ -396,6 +405,7 @@ func (c *mobileCanvas) tapMove(pos fyne.Position, tapID int, func (c *mobileCanvas) tapUp(pos fyne.Position, tapID int, tapCallback func(fyne.Tappable, *fyne.PointEvent), tapAltCallback func(fyne.SecondaryTappable, *fyne.PointEvent), + doubleTapCallback func(fyne.DoubleTappable, *fyne.PointEvent), dragCallback func(fyne.Draggable, *fyne.DragEvent)) { if c.dragging != nil { c.dragging.DragEnd() @@ -422,6 +432,8 @@ func (c *mobileCanvas) tapUp(pos fyne.Position, tapID int, return true } else if _, ok := object.(fyne.Focusable); ok { return true + } else if _, ok := object.(fyne.DoubleTappable); ok { + return true } return false @@ -441,8 +453,19 @@ func (c *mobileCanvas) tapUp(pos fyne.Position, tapID int, // TODO move event queue to common code w.queueEvent(func() { wid.Tapped(ev) }) if duration < tapSecondaryDelay { - if wid, ok := co.(fyne.Tappable); ok { - tapCallback(wid, ev) + _, doubleTap := co.(fyne.DoubleTappable) + if doubleTap { + c.touchTapCount++ + c.touchLastTapped = co + if c.touchCancelFunc != nil { + c.touchCancelFunc() + return + } + go c.waitForDoubleTap(co, ev, tapCallback, doubleTapCallback) + } else { + if wid, ok := co.(fyne.Tappable); ok { + tapCallback(wid, ev) + } } } else { if wid, ok := co.(fyne.SecondaryTappable); ok { @@ -451,6 +474,26 @@ func (c *mobileCanvas) tapUp(pos fyne.Position, tapID int, } } +func (c *mobileCanvas) waitForDoubleTap(co fyne.CanvasObject, ev *fyne.PointEvent, tapCallback func(fyne.Tappable, *fyne.PointEvent), doubleTapCallback func(fyne.DoubleTappable, *fyne.PointEvent)) { + var ctx context.Context + ctx, c.touchCancelFunc = context.WithDeadline(context.TODO(), time.Now().Add(time.Millisecond*doubleClickDelay)) + defer c.touchCancelFunc() + <-ctx.Done() + if c.touchTapCount == 2 && c.touchLastTapped == co { + if wid, ok := co.(fyne.DoubleTappable); ok { + doubleTapCallback(wid, ev) + } + } else { + if wid, ok := co.(fyne.Tappable); ok { + tapCallback(wid, ev) + } + } + c.touchTapCount = 0 + c.touchCancelFunc = nil + c.touchLastTapped = nil + return +} + func (c *mobileCanvas) setupThemeListener() { listener := make(chan fyne.Settings) fyne.CurrentApp().Settings().AddChangeListener(listener) diff --git a/internal/driver/gomobile/canvas_test.go b/internal/driver/gomobile/canvas_test.go index c6aee339ed..3ffe6a5527 100644 --- a/internal/driver/gomobile/canvas_test.go +++ b/internal/driver/gomobile/canvas_test.go @@ -78,6 +78,8 @@ func TestCanvas_Tapped(t *testing.T) { }, func(wid fyne.SecondaryTappable, ev *fyne.PointEvent) { altTapped = true wid.TappedSecondary(ev) + }, func(wid fyne.DoubleTappable, ev *fyne.PointEvent) { + wid.DoubleTapped(ev) }, func(wid fyne.Draggable, ev *fyne.DragEvent) { }) @@ -106,6 +108,8 @@ func TestCanvas_Tapped_Multi(t *testing.T) { c.tapUp(tapPos, 1, func(wid fyne.Tappable, ev *fyne.PointEvent) { // different tapID wid.Tapped(ev) }, func(wid fyne.SecondaryTappable, ev *fyne.PointEvent) { + }, func(wid fyne.DoubleTappable, ev *fyne.PointEvent) { + wid.DoubleTapped(ev) }, func(wid fyne.Draggable, ev *fyne.DragEvent) { }) @@ -133,6 +137,8 @@ func TestCanvas_TappedSecondary(t *testing.T) { altTappedObj = wid pointEvent = ev wid.TappedSecondary(ev) + }, func(wid fyne.DoubleTappable, ev *fyne.PointEvent) { + wid.DoubleTapped(ev) }, func(wid fyne.Draggable, ev *fyne.DragEvent) { }) @@ -184,6 +190,7 @@ func TestCanvas_Tappable(t *testing.T) { c.tapUp(fyne.NewPos(15, 15), 0, func(wid fyne.Tappable, ev *fyne.PointEvent) { }, func(wid fyne.SecondaryTappable, ev *fyne.PointEvent) { + }, func(wid fyne.DoubleTappable, ev *fyne.PointEvent) { }, func(wid fyne.Draggable, ev *fyne.DragEvent) { }) assert.True(t, content.up) @@ -195,6 +202,51 @@ func TestCanvas_Tappable(t *testing.T) { assert.True(t, content.cancel) } +func TestWindow_TappedAndDoubleTapped(t *testing.T) { + tapped := 0 + but := newDoubleTappableButton() + but.OnTapped = func() { + tapped = 1 + } + but.onDoubleTap = func() { + tapped = 2 + } + + c := NewCanvas().(*mobileCanvas) + c.SetContent(fyne.NewContainerWithLayout(layout.NewMaxLayout(), but)) + c.resize(fyne.NewSize(36, 24)) + + c.tapDown(fyne.NewPos(15, 15), 0) + c.tapUp(fyne.NewPos(15, 15), 0, func(wid fyne.Tappable, ev *fyne.PointEvent) { + wid.Tapped(ev) + }, func(wid fyne.SecondaryTappable, ev *fyne.PointEvent) { + }, func(wid fyne.DoubleTappable, ev *fyne.PointEvent) { + wid.DoubleTapped(ev) + }, func(wid fyne.Draggable, ev *fyne.DragEvent) { + }) + time.Sleep(700 * time.Millisecond) + assert.Equal(t, tapped, 1) + + c.tapDown(fyne.NewPos(15, 15), 0) + c.tapUp(fyne.NewPos(15, 15), 0, func(wid fyne.Tappable, ev *fyne.PointEvent) { + wid.Tapped(ev) + }, func(wid fyne.SecondaryTappable, ev *fyne.PointEvent) { + }, func(wid fyne.DoubleTappable, ev *fyne.PointEvent) { + wid.DoubleTapped(ev) + }, func(wid fyne.Draggable, ev *fyne.DragEvent) { + }) + c.tapDown(fyne.NewPos(15, 15), 0) + c.tapUp(fyne.NewPos(15, 15), 0, func(wid fyne.Tappable, ev *fyne.PointEvent) { + wid.Tapped(ev) + }, func(wid fyne.SecondaryTappable, ev *fyne.PointEvent) { + }, func(wid fyne.DoubleTappable, ev *fyne.PointEvent) { + wid.DoubleTapped(ev) + }, func(wid fyne.Draggable, ev *fyne.DragEvent) { + }) + time.Sleep(700 * time.Millisecond) + assert.Equal(t, tapped, 1) +} + func TestCanvas_Focusable(t *testing.T) { content := newFocusableEntry() c := NewCanvas().(*mobileCanvas) @@ -277,3 +329,20 @@ func (f *focusableEntry) FocusLost() { f.unfocusedTimes++ f.Entry.FocusLost() } + +type doubleTappableButton struct { + widget.Button + + onDoubleTap func() +} + +func (t *doubleTappableButton) DoubleTapped(_ *fyne.PointEvent) { + t.onDoubleTap() +} + +func newDoubleTappableButton() *doubleTappableButton { + but := &doubleTappableButton{} + but.ExtendBaseWidget(but) + + return but +} diff --git a/internal/driver/gomobile/driver.go b/internal/driver/gomobile/driver.go index 5e9c43448a..c7c7b1dc28 100644 --- a/internal/driver/gomobile/driver.go +++ b/internal/driver/gomobile/driver.go @@ -240,6 +240,8 @@ func (d *mobileDriver) tapUpCanvas(canvas *mobileCanvas, x, y float32, tapID tou go wid.Tapped(ev) }, func(wid fyne.SecondaryTappable, ev *fyne.PointEvent) { go wid.TappedSecondary(ev) + }, func(wid fyne.DoubleTappable, ev *fyne.PointEvent) { + go wid.DoubleTapped(ev) }, func(wid fyne.Draggable, ev *fyne.DragEvent) { go wid.DragEnd() }) diff --git a/internal/driver/gomobile/menu_test.go b/internal/driver/gomobile/menu_test.go index df3007dbce..7733250950 100644 --- a/internal/driver/gomobile/menu_test.go +++ b/internal/driver/gomobile/menu_test.go @@ -25,7 +25,7 @@ func TestMobileCanvas_DismissBar(t *testing.T) { assert.NotNil(t, c.menu) // simulate tap as the test util does not know about our menu... c.tapDown(fyne.NewPos(80, 20), 1) - c.tapUp(fyne.NewPos(80, 20), 1, nil, nil, nil) + c.tapUp(fyne.NewPos(80, 20), 1, nil, nil, nil, nil) assert.Nil(t, c.menu) } diff --git a/test/test.go b/test/test.go index e372de639e..e52db1f4da 100644 --- a/test/test.go +++ b/test/test.go @@ -140,6 +140,13 @@ func Scroll(c fyne.Canvas, pos fyne.Position, deltaX, deltaY int) { o.(fyne.Scrollable).Scrolled(e) } +// DoubleTap simulates a double left mouse click on the specified object. +func DoubleTap(obj fyne.DoubleTappable) { + ev, c := prepareTap(obj, fyne.NewPos(1, 1)) + handleFocusOnTap(c, obj) + obj.DoubleTapped(ev) +} + // Tap simulates a left mouse click on the specified object. func Tap(obj fyne.Tappable) { TapAt(obj, fyne.NewPos(1, 1))