diff --git a/driver.go b/driver.go index 8737f69148..2cc7cecd2f 100644 --- a/driver.go +++ b/driver.go @@ -1,5 +1,7 @@ package fyne +import "time" + // Driver defines an abstract concept of a Fyne render driver. // Any implementation must provide at least these methods. type Driver interface { @@ -29,4 +31,10 @@ type Driver interface { StartAnimation(*Animation) // StopAnimation stops an animation and unregisters from this driver. StopAnimation(*Animation) + + // DoubleTapDelay returns the maximum duration where a second tap after a first one + // will be considered a DoubleTap instead of two distinct Tap events. + // + // Since: 2.5 + DoubleTapDelay() time.Duration } diff --git a/internal/driver/glfw/driver.go b/internal/driver/glfw/driver.go index 241b5e09e1..6a44a1c45d 100644 --- a/internal/driver/glfw/driver.go +++ b/internal/driver/glfw/driver.go @@ -9,6 +9,7 @@ import ( "runtime" "sync" "sync/atomic" + "time" "github.com/fyne-io/image/ico" @@ -36,6 +37,8 @@ var _ fyne.Driver = (*gLDriver)(nil) // A workaround on Apple M1/M2, just use 1 thread until fixed upstream. const drawOnMainThread bool = runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" +const doubleTapDelay = 300 * time.Millisecond + type gLDriver struct { windowLock sync.RWMutex windows []fyne.Window @@ -168,6 +171,10 @@ func (d *gLDriver) Run() { d.runGL() } +func (d *gLDriver) DoubleTapDelay() time.Duration { + return doubleTapDelay +} + // NewGLDriver sets up a new Driver instance implemented using the GLFW Go library and OpenGL bindings. func NewGLDriver() *gLDriver { repository.Register("file", intRepo.NewFileRepository()) diff --git a/internal/driver/glfw/window.go b/internal/driver/glfw/window.go index eadcb6e6ad..427528fbbd 100644 --- a/internal/driver/glfw/window.go +++ b/internal/driver/glfw/window.go @@ -17,8 +17,7 @@ import ( ) const ( - doubleClickDelay = 300 // ms (maximum interval between clicks for double click detection) - dragMoveThreshold = 2 // how far can we move before it is a drag + dragMoveThreshold = 2 // how far can we move before it is a drag windowIconSize = 256 ) @@ -646,7 +645,7 @@ func (w *window) mouseClickedHandleTapDoubleTap(co fyne.CanvasObject, ev *fyne.P func (w *window) waitForDoubleTap(co fyne.CanvasObject, ev *fyne.PointEvent) { var ctx context.Context w.mouseLock.Lock() - ctx, w.mouseCancelFunc = context.WithDeadline(context.TODO(), time.Now().Add(time.Millisecond*doubleClickDelay)) + ctx, w.mouseCancelFunc = context.WithDeadline(context.TODO(), time.Now().Add(doubleTapDelay)) defer w.mouseCancelFunc() w.mouseLock.Unlock() diff --git a/internal/driver/mobile/canvas.go b/internal/driver/mobile/canvas.go index 35c20f58bc..57973ad37c 100644 --- a/internal/driver/mobile/canvas.go +++ b/internal/driver/mobile/canvas.go @@ -16,10 +16,6 @@ import ( "fyne.io/fyne/v2/widget" ) -const ( - doubleClickDelay = 500 // ms (maximum interval between clicks for double click detection) -) - var _ fyne.Canvas = (*mobileCanvas)(nil) type mobileCanvas struct { @@ -377,7 +373,7 @@ 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)) + ctx, c.touchCancelFunc = context.WithDeadline(context.TODO(), time.Now().Add(tapDoubleDelay)) defer c.touchCancelFunc() <-ctx.Done() if c.touchTapCount == 2 && c.touchLastTapped == co { diff --git a/internal/driver/mobile/canvas_test.go b/internal/driver/mobile/canvas_test.go index 63c35d8e84..68468ea6ae 100644 --- a/internal/driver/mobile/canvas_test.go +++ b/internal/driver/mobile/canvas_test.go @@ -298,7 +298,7 @@ func TestCanvas_Focusable(t *testing.T) { c.tapUp(pos, 0, func(wid fyne.Tappable, ev *fyne.PointEvent) { wid.Tapped(ev) }, nil, nil, nil) - time.Sleep(time.Millisecond * (doubleClickDelay + 150)) + time.Sleep(tapDoubleDelay + 150*time.Millisecond) assert.Equal(t, 1, content.focusedTimes) assert.Equal(t, 0, content.unfocusedTimes) @@ -306,7 +306,7 @@ func TestCanvas_Focusable(t *testing.T) { c.tapUp(pos, 1, func(wid fyne.Tappable, ev *fyne.PointEvent) { wid.Tapped(ev) }, nil, nil, nil) - time.Sleep(time.Millisecond * (doubleClickDelay + 150)) + time.Sleep(tapDoubleDelay + 150*time.Millisecond) assert.Equal(t, 1, content.focusedTimes) assert.Equal(t, 0, content.unfocusedTimes) diff --git a/internal/driver/mobile/driver.go b/internal/driver/mobile/driver.go index dcb9a028dd..4173cd3ad2 100644 --- a/internal/driver/mobile/driver.go +++ b/internal/driver/mobile/driver.go @@ -32,6 +32,7 @@ import ( const ( tapMoveThreshold = 4.0 // how far can we move before it is a drag tapSecondaryDelay = 300 * time.Millisecond // how long before secondary tap + tapDoubleDelay = 500 * time.Millisecond // max duration between taps for a DoubleTap event ) // Configuration is the system information about the current device @@ -550,6 +551,10 @@ func (d *mobileDriver) SetOnConfigurationChanged(f func(*Configuration)) { d.onConfigChanged = f } +func (d *mobileDriver) DoubleTapDelay() time.Duration { + return tapDoubleDelay +} + // NewGoMobileDriver sets up a new Driver instance implemented using the Go // Mobile extension and OpenGL bindings. func NewGoMobileDriver() fyne.Driver { diff --git a/test/testdriver.go b/test/testdriver.go index 146e44e1ff..6bc2a2269a 100644 --- a/test/testdriver.go +++ b/test/testdriver.go @@ -3,6 +3,7 @@ package test import ( "image" "sync" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/internal/driver" @@ -129,3 +130,7 @@ func (d *testDriver) removeWindow(w *testWindow) { d.windows = append(d.windows[:i], d.windows[i+1:]...) d.windowsMutex.Unlock() } + +func (d *testDriver) DoubleTapDelay() time.Duration { + return 300 * time.Millisecond +} diff --git a/widget/entry.go b/widget/entry.go index e33e532b36..e943a8fe52 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -101,6 +101,10 @@ type Entry struct { // undoStack stores the data necessary for undo/redo functionality // See entryUndoStack for implementation details. undoStack entryUndoStack + + // doubleTappedAtUnixMillis stores the time the entry was last DoubleTapped + // used for deciding whether the next MouseDown/TouchDown is a triple-tap or not + doubleTappedAtUnixMillis int64 } // NewEntry creates a new single line entry widget. @@ -229,6 +233,7 @@ func (e *Entry) Disabled() bool { // // Implements: fyne.DoubleTappable func (e *Entry) DoubleTapped(p *fyne.PointEvent) { + e.doubleTappedAtUnixMillis = time.Now().UnixMilli() row := e.textProvider().row(e.CursorRow) start, end := getTextWhitespaceRegion(row, e.CursorColumn, false) if start == -1 || end == -1 { @@ -250,6 +255,10 @@ func (e *Entry) DoubleTapped(p *fyne.PointEvent) { }) } +func (e *Entry) isTripleTap(nowMilli int64) bool { + return nowMilli-e.doubleTappedAtUnixMillis <= fyne.CurrentApp().Driver().DoubleTapDelay().Milliseconds() +} + // DragEnd is called at end of a drag event. // // Implements: fyne.Draggable @@ -407,6 +416,10 @@ func (e *Entry) MinSize() fyne.Size { // // Implements: desktop.Mouseable func (e *Entry) MouseDown(m *desktop.MouseEvent) { + if e.isTripleTap(time.Now().UnixMilli()) { + e.selectCurrentRow() + return + } e.propertyLock.Lock() if e.selectKeyDown { e.selecting = true @@ -606,9 +619,14 @@ func (e *Entry) TappedSecondary(pe *fyne.PointEvent) { // // Implements: mobile.Touchable func (e *Entry) TouchDown(ev *mobile.TouchEvent) { + now := time.Now().UnixMilli() if !e.Disabled() { e.requestFocus() } + if e.isTripleTap(now) { + e.selectCurrentRow() + return + } e.updateMousePointer(ev.Position, false) } @@ -1502,6 +1520,22 @@ func (e *Entry) typedKeyReturn(provider *RichText, multiLine bool) { e.propertyLock.Unlock() } +// Selects the row where the CursorColumn is currently positioned +// Do not call while holding the proeprtyLock +func (e *Entry) selectCurrentRow() { + provider := e.textProvider() + e.propertyLock.Lock() + e.selectRow = e.CursorRow + e.selectColumn = 0 + if e.MultiLine { + e.CursorColumn = provider.rowLength(e.CursorRow) + } else { + e.CursorColumn = provider.len() + } + e.propertyLock.Unlock() + e.Refresh() +} + var _ fyne.WidgetRenderer = (*entryRenderer)(nil) type entryRenderer struct { diff --git a/widget/entry_internal_test.go b/widget/entry_internal_test.go index db56c05fc4..50fc43268d 100644 --- a/widget/entry_internal_test.go +++ b/widget/entry_internal_test.go @@ -42,12 +42,16 @@ func TestEntry_DoubleTapped(t *testing.T) { entry.DoubleTapped(ev) assert.Equal(t, "quick", entry.SelectedText()) + entry.doubleTappedAtUnixMillis = 0 // make sure we don't register a triple tap next + // select the whitespace after 'quick' ev = getClickPosition("The quick", 0) clickPrimary(entry, ev) entry.DoubleTapped(ev) assert.Equal(t, " ", entry.SelectedText()) + entry.doubleTappedAtUnixMillis = 0 + // select all whitespace after 'jumped' ev = getClickPosition("jumped ", 1) clickPrimary(entry, ev) diff --git a/widget/entry_test.go b/widget/entry_test.go index aacddc24ce..0c9eb5d3ab 100644 --- a/widget/entry_test.go +++ b/widget/entry_test.go @@ -1298,6 +1298,17 @@ func TestEntry_SelectSnapUp(t *testing.T) { assert.Equal(t, "", e.SelectedText()) } +func TestEntry_Select_TripleTap(t *testing.T) { + e, _ := setupSelection(t, false) + e.MultiLine = true + assert.Equal(t, 1, e.CursorRow) + assert.Equal(t, "sti", e.SelectedText()) + test.DoubleTap(e) + time.Sleep(50 * time.Millisecond) + e.MouseDown(&desktop.MouseEvent{PointEvent: fyne.PointEvent{Position: fyne.NewPos(1, 1)}}) + assert.Equal(t, "Testing", e.SelectedText()) +} + func TestEntry_SelectedText(t *testing.T) { e, window := setupImageTest(t, false) defer teardownImageTest(window)