From 02dbb6b126dc06d426f0bc7bb6b1f9924f7645d6 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Mon, 28 Oct 2024 10:39:12 +0000 Subject: [PATCH] Add word selection on double-tap (#96) Implement word selection functionality triggered by a double-tap. Words are identified as sequences of alphanumeric characters, while spaces and punctuation are ignored. Update the selection logic to highlight the word under the cursor and clear any existing selection. Adjust tests to validate word selection and ensure accurate behavior when tapping on non-alphanumeric characters. --- position.go | 8 ++++++ select.go | 2 ++ select_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++++++++- term.go | 46 +++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/position.go b/position.go index ab8c18ec..39bc7bc1 100644 --- a/position.go +++ b/position.go @@ -20,3 +20,11 @@ func (t *Terminal) getTermPosition(pos fyne.Position) position { row := int(pos.Y/cell.Height) + 1 return position{col, row} } + +// getTextPosition converts a terminal position (row and col) to fyne coordinates. +func (t *Terminal) getTextPosition(pos position) fyne.Position { + cell := t.guessCellSize() + x := (pos.Col - 1) * int(cell.Width) // Convert column to pixel position (1-based to 0-based) + y := (pos.Row - 1) * int(cell.Height) // Convert row to pixel position (1-based to 0-based) + return fyne.NewPos(float32(x), float32(y)) +} diff --git a/select.go b/select.go index 47f023e1..4a39b117 100644 --- a/select.go +++ b/select.go @@ -52,6 +52,8 @@ func (t *Terminal) clearSelectedText() { t.Refresh() t.blockMode = false t.selecting = false + t.selStart = nil + t.selEnd = nil } // SelectedText gets the text that is currently selected. diff --git a/select_test.go b/select_test.go index aedd8d12..c7f7c807 100644 --- a/select_test.go +++ b/select_test.go @@ -3,8 +3,10 @@ package terminal import ( "testing" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/widget" widget2 "github.com/fyne-io/terminal/internal/widget" + "github.com/stretchr/testify/assert" ) func TestGetSelectedRange(t *testing.T) { @@ -75,7 +77,6 @@ func TestGetSelectedRange(t *testing.T) { } func TestGetTextRange(t *testing.T) { - // Prepare the text grid for the tests grid := widget2.NewTermGrid() grid.Rows = []widget.TextGridRow{ {Cells: []widget.TextGridCell{{Rune: 'A'}, {Rune: 'B'}, {Rune: 'C'}}}, @@ -109,3 +110,70 @@ func TestGetTextRange(t *testing.T) { }) } } + +func TestDoubleTapped(t *testing.T) { + grid := widget2.NewTermGrid() + grid.Rows = []widget.TextGridRow{ + {Cells: []widget.TextGridCell{ + {Rune: 'H'}, {Rune: 'e'}, {Rune: 'l'}, {Rune: 'l'}, {Rune: 'o'}, + {Rune: ' '}, {Rune: 'W'}, {Rune: 'o'}, {Rune: 'r'}, {Rune: 'l'}, + {Rune: 'd'}, {Rune: '!'}, + }}, + {Cells: []widget.TextGridCell{ + {Rune: 'T'}, {Rune: 'e'}, {Rune: 's'}, {Rune: 't'}, {Rune: 'i'}, + {Rune: 'n'}, {Rune: 'g'}, {Rune: ' '}, {Rune: '1'}, {Rune: '2'}, + {Rune: '3'}, {Rune: '.'}, + }}, + } + + term := &Terminal{ + content: grid, + } + term.Resize(fyne.NewSize(500, 500)) + + tests := map[string]struct { + clickPosition fyne.Position + expectedWord string + }{ + "Double tap on 'Hello'": { + clickPosition: term.getTextPosition(position{Row: 1, Col: 1}), + expectedWord: "Hello", + }, + "Double tap on 'World'": { + clickPosition: term.getTextPosition(position{Row: 1, Col: 7}), + expectedWord: "World", + }, + "Double tap on '123'": { + clickPosition: term.getTextPosition(position{Row: 2, Col: 9}), + expectedWord: "123", + }, + "Double tap on '!' should not select": { + clickPosition: term.getTextPosition(position{Row: 1, Col: 12}), + expectedWord: "", + }, + "Double tap on '.' should not select": { + clickPosition: term.getTextPosition(position{Row: 2, Col: 12}), + expectedWord: "", + }, + "Double tap on space between words": { + clickPosition: term.getTextPosition(position{Row: 1, Col: 6}), + expectedWord: "", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + term.clearSelectedText() + term.DoubleTapped(&fyne.PointEvent{ + Position: tc.clickPosition, + }) + + selectedWord := "" + if term.hasSelectedText() { + selectedWord = term.SelectedText() + } + + assert.Equal(t, tc.expectedWord, selectedWord) + }) + } +} diff --git a/term.go b/term.go index 8bfe5281..cae57614 100644 --- a/term.go +++ b/term.go @@ -9,6 +9,7 @@ import ( "runtime" "sync" "time" + "unicode" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -158,6 +159,51 @@ func (t *Terminal) MouseUp(ev *desktop.MouseEvent) { } } +// DoubleTapped handles the double tapped event. +func (t *Terminal) DoubleTapped(pe *fyne.PointEvent) { + pos := t.sanitizePosition(pe.Position) + termPos := t.getTermPosition(*pos) + row, col := termPos.Row, termPos.Col + + if t.hasSelectedText() { + t.clearSelectedText() + } + + if row < 1 || row > len(t.content.Rows) { + return + } + + rowContent := t.content.Rows[row-1].Cells + + if col < 0 || col >= len(rowContent) { + return // No valid character under the cursor, do nothing + } + + start, end := col-1, col-1 + + if !unicode.IsLetter(rowContent[start].Rune) && !unicode.IsDigit(rowContent[start].Rune) { + return + } + + for start > 0 && (unicode.IsLetter(rowContent[start-1].Rune) || unicode.IsDigit(rowContent[start-1].Rune)) { + start-- + } + if start < len(rowContent) && !unicode.IsLetter(rowContent[start].Rune) && !unicode.IsDigit(rowContent[start].Rune) { + start++ + } + for end < len(rowContent) && (unicode.IsLetter(rowContent[end].Rune) || unicode.IsDigit(rowContent[end].Rune)) { + end++ + } + if start == end { + return + } + + t.selStart = &position{Row: row, Col: start + 1} + t.selEnd = &position{Row: row, Col: end} + + t.highlightSelectedText() +} + // RemoveListener de-registers a Config channel and closes it func (t *Terminal) RemoveListener(listener chan Config) { t.listenerLock.Lock()