From ae1cfc7ab732b7ff7e9fe2618c6d04f134eaf808 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sat, 15 Feb 2025 22:07:22 +0000 Subject: [PATCH 01/21] Add a flag to make label selectable When set we add the hoverable panel to handle selection --- widget/label.go | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/widget/label.go b/widget/label.go index b0c34a0e17..eab84b9d91 100644 --- a/widget/label.go +++ b/widget/label.go @@ -3,6 +3,8 @@ package widget import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" ) @@ -23,8 +25,15 @@ type Label struct { // Since: 2.4 Importance Importance + // If set to true, Selectable indicates that this label should support select interaction + // to allow the text to be copied. + // + //Since: 2.6 + Selectable bool + provider *RichText binder basicBinder + hover *labelHover } // NewLabel creates a new label widget with the set text content @@ -69,7 +78,13 @@ func (l *Label) CreateRenderer() fyne.WidgetRenderer { l.ExtendBaseWidget(l) l.syncSegments() - return NewSimpleRenderer(l.provider) + l.hover = &labelHover{l: l} + if !l.Selectable { + l.hover.Hide() + } + return NewSimpleRenderer( + &fyne.Container{Layout: layout.NewStackLayout(), + Objects: []fyne.CanvasObject{l.provider, l.hover}}) } // MinSize returns the size that this label should not shrink below. @@ -84,6 +99,8 @@ func (l *Label) MinSize() fyne.Size { // // Implements: fyne.Widget func (l *Label) Refresh() { + l.hover.Hidden = !l.Selectable + l.hover.Refresh() if l.provider == nil { // not created until visible return } @@ -162,3 +179,17 @@ func (l *Label) updateFromData(data binding.DataItem) { } l.SetText(val) } + +type labelHover struct { + BaseWidget + + l *Label +} + +func (l *labelHover) Cursor() desktop.Cursor { + if l.l.Selectable { + return desktop.TextCursor + } + + return desktop.DefaultCursor +} From b95939971ea17ed7c3b7cab0e24c122b8a69d335 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sun, 16 Feb 2025 14:06:10 +0000 Subject: [PATCH 02/21] Copy/move entry selection handling to new selection.go And use that to enable selection on Label --- widget/entry.go | 369 ++++++++++------------------------ widget/entry_internal_test.go | 14 +- widget/label.go | 53 ++--- widget/selectable.go | 300 +++++++++++++++++++++++++++ 4 files changed, 432 insertions(+), 304 deletions(-) create mode 100644 widget/selectable.go diff --git a/widget/entry.go b/widget/entry.go index c9d60a4d08..392339f404 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -2,7 +2,6 @@ package widget import ( "image/color" - "math" "runtime" "strings" "time" @@ -79,17 +78,11 @@ type Entry struct { // the entry is unfocused) onFocusChanged func(bool) - // selectRow and selectColumn represent the selection start location - // The selection will span from selectRow/Column to CursorRow/Column -- note that the cursor - // position may occur before or after the select start position in the text. - selectRow, selectColumn int - // selectKeyDown indicates whether left shift or right shift is currently held down selectKeyDown bool - // selecting indicates whether the cursor has moved since it was at the selection start location - selecting bool - popUp *PopUpMenu + sel *selectable + popUp *PopUpMenu // TODO: Add OnSelectChanged // ActionItem is a small item which is displayed at the outer right of the entry (like a password revealer) @@ -174,6 +167,7 @@ func (e *Entry) CreateRenderer() fyne.WidgetRenderer { // initialise e.textProvider() e.placeholderProvider() + e.sel = &selectable{theme: th, provider: e.textProvider(), password: e.Password, style: e.TextStyle} box := canvas.NewRectangle(th.Color(theme.ColorNameInputBackground, v)) box.CornerRadius = th.Size(theme.SizeNameInputRadius) @@ -231,16 +225,16 @@ func (e *Entry) DoubleTapped(p *fyne.PointEvent) { e.setFieldsAndRefresh(func() { if !e.selectKeyDown { - e.selectRow = e.CursorRow - e.selectColumn = start + e.sel.selectRow = e.CursorRow + e.sel.selectColumn = start } // Always aim to maximise the selected region - if e.selectRow > e.CursorRow || (e.selectRow == e.CursorRow && e.selectColumn > e.CursorColumn) { + if e.sel.selectRow > e.CursorRow || (e.sel.selectRow == e.CursorRow && e.sel.selectColumn > e.CursorColumn) { e.CursorColumn = start } else { e.CursorColumn = end } - e.selecting = true + e.sel.selecting = true }) } @@ -252,12 +246,8 @@ func (e *Entry) isTripleTap(nowMilli int64) bool { // // Implements: fyne.Draggable func (e *Entry) DragEnd() { - if e.CursorColumn == e.selectColumn && e.CursorRow == e.selectRow { - e.selecting = false - } - shouldRefresh := !e.selecting - if shouldRefresh { - e.Refresh() + if e.CursorColumn == e.sel.selectColumn && e.CursorRow == e.sel.selectRow { + e.sel.selecting = false } } @@ -266,13 +256,9 @@ func (e *Entry) DragEnd() { // // Implements: fyne.Draggable func (e *Entry) Dragged(d *fyne.DragEvent) { - pos := d.Position.Subtract(e.scroll.Offset).Add(fyne.NewPos(0, e.Theme().Size(theme.SizeNameInputBorder))) - if !e.selecting { - startPos := pos.Subtract(d.Dragged) - e.selectRow, e.selectColumn = e.getRowCol(startPos) - e.selecting = true - } - e.updateMousePointer(pos, false) + d.Position = d.Position.Add(fyne.NewPos(0, e.Theme().Size(theme.SizeNameInputBorder))) + e.sel.Dragged(d) + e.updateMousePointer(d.Position, false) } // ExtendBaseWidget is used by an extending widget to make use of BaseWidget functionality. @@ -342,9 +328,9 @@ func (e *Entry) KeyDown(key *fyne.KeyEvent) { // Note: selection start is where the highlight started (if the user moves the selection up or left then // the selectRow/Column will not match SelectionStart) if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight { - if !e.selecting { - e.selectRow = e.CursorRow - e.selectColumn = e.CursorColumn + if !e.sel.selecting { + e.sel.selectRow = e.CursorRow + e.sel.selectColumn = e.CursorColumn } e.selectKeyDown = true } @@ -390,13 +376,13 @@ func (e *Entry) MouseDown(m *desktop.MouseEvent) { return } if e.selectKeyDown { - e.selecting = true + e.sel.selecting = true } - if e.selecting && !e.selectKeyDown && m.Button == desktop.MouseButtonPrimary { - e.selecting = false + if e.sel.selecting && !e.selectKeyDown && m.Button == desktop.MouseButtonPrimary { + e.sel.selecting = false } - e.updateMousePointer(m.Position, m.Button == desktop.MouseButtonSecondary) + e.updateMousePointer(m.Position.Add(e.scroll.Offset), m.Button == desktop.MouseButtonSecondary) if !e.Disabled() { e.requestFocus() @@ -409,9 +395,9 @@ func (e *Entry) MouseDown(m *desktop.MouseEvent) { // // Implements: desktop.Mouseable func (e *Entry) MouseUp(m *desktop.MouseEvent) { - start, _ := e.selection() - if start == -1 && e.selecting && !e.selectKeyDown { - e.selecting = false + start, _ := e.sel.selection() + if start == -1 && e.sel.selecting && !e.selectKeyDown { + e.sel.selecting = false } } @@ -430,28 +416,25 @@ func (e *Entry) Redo() { } e.updateText(newText, false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos) + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn e.Refresh() } func (e *Entry) Refresh() { e.minCache = fyne.Size{} + if e.sel != nil { + e.sel.style = e.TextStyle + e.sel.theme = e.Theme() + e.sel.Refresh() + } e.BaseWidget.Refresh() } // SelectedText returns the text currently selected in this Entry. // If there is no selection it will return the empty string. func (e *Entry) SelectedText() string { - if !e.selecting { - return "" - } - - start, stop := e.selection() - if start == stop { - return "" - } - r := ([]rune)(e.Text) - return string(r[start:stop]) + return e.sel.SelectedText() } // SetMinRowsVisible forces a multi-line entry to show `count` number of rows without scrolling. @@ -515,8 +498,8 @@ func (e *Entry) Append(text string) { // // Implements: fyne.Tappable func (e *Entry) Tapped(ev *fyne.PointEvent) { - if fyne.CurrentDevice().IsMobile() && e.selecting { - e.selecting = false + if fyne.CurrentDevice().IsMobile() && e.sel.selecting { + e.sel.selecting = false } } @@ -623,7 +606,7 @@ func (e *Entry) TypedKey(key *fyne.KeyEvent) { provider := e.textProvider() multiLine := e.MultiLine - if e.selectKeyDown || e.selecting { + if e.selectKeyDown || e.sel.selecting { if e.selectingKeyHandler(key) { e.Refresh() return @@ -640,6 +623,7 @@ func (e *Entry) TypedKey(key *fyne.KeyEvent) { pos := e.cursorTextPos() deletedText := provider.deleteFromTo(pos-1, pos) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos - 1) + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn e.undoStack.MergeOrAdd(&entryModifyAction{ Delete: true, Position: pos - 1, @@ -678,6 +662,7 @@ func (e *Entry) TypedKey(key *fyne.KeyEvent) { e.CursorRow = 0 } e.CursorColumn = 0 + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn case fyne.KeyPageDown: if e.MultiLine { e.CursorRow = provider.rows() - 1 @@ -685,14 +670,15 @@ func (e *Entry) TypedKey(key *fyne.KeyEvent) { } else { e.CursorColumn = provider.len() } + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn default: return } content := provider.String() changed := e.updateText(content, false) - if e.CursorRow == e.selectRow && e.CursorColumn == e.selectColumn { - e.selecting = false + if e.CursorRow == e.sel.selectRow && e.CursorColumn == e.sel.selectColumn { + e.sel.selecting = false } cb := e.OnChanged if changed { @@ -719,6 +705,7 @@ func (e *Entry) Undo() { } e.updateText(newText, false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos) + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn e.Refresh() } @@ -733,6 +720,7 @@ func (e *Entry) typedKeyUp(provider *RichText) { if e.CursorColumn > rowLength { e.CursorColumn = rowLength } + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn } func (e *Entry) typedKeyDown(provider *RichText) { @@ -748,6 +736,7 @@ func (e *Entry) typedKeyDown(provider *RichText) { if e.CursorColumn > rowLength { e.CursorColumn = rowLength } + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn } func (e *Entry) typedKeyLeft(provider *RichText) { @@ -757,6 +746,7 @@ func (e *Entry) typedKeyLeft(provider *RichText) { e.CursorRow-- e.CursorColumn = provider.rowLength(e.CursorRow) } + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn } func (e *Entry) typedKeyRight(provider *RichText) { @@ -771,6 +761,7 @@ func (e *Entry) typedKeyRight(provider *RichText) { } else if e.CursorColumn < provider.len() { e.CursorColumn++ } + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn } func (e *Entry) typedKeyHome() { @@ -839,7 +830,7 @@ func (e *Entry) TypedRune(r rune) { // if we've typed a character and we're selecting then replace the selection with the character cb := e.OnChanged - if e.selecting { + if e.sel.selecting { e.eraseSelection() } @@ -852,6 +843,7 @@ func (e *Entry) TypedRune(r rune) { content := provider.String() e.updateText(content, false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes)) + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn e.undoStack.MergeOrAdd(&entryModifyAction{ Position: pos, @@ -884,37 +876,21 @@ func (e *Entry) Unbind() { // copyToClipboard copies the current selection to a given clipboard. // This does nothing if it is a concealed entry. func (e *Entry) copyToClipboard(clipboard fyne.Clipboard) { - if !e.selecting || e.Password { + if !e.sel.selecting || e.Password { return } - clipboard.SetContent(e.SelectedText()) -} - -func (e *Entry) cursorColAt(text []rune, pos fyne.Position) int { - th := e.Theme() - textSize := th.Size(theme.SizeNameText) - innerPad := th.Size(theme.SizeNameInnerPadding) - - for i := 0; i < len(text); i++ { - str := string(text[0:i]) - wid := fyne.MeasureText(str, textSize, e.TextStyle).Width - charWid := fyne.MeasureText(string(text[i]), textSize, e.TextStyle).Width - if pos.X < innerPad+wid+(charWid/2) { - return i - } - } - return len(text) + clipboard.SetContent(e.sel.SelectedText()) } func (e *Entry) cursorTextPos() (pos int) { - return e.textPosFromRowCol(e.CursorRow, e.CursorColumn) + return e.sel.textPosFromRowCol(e.CursorRow, e.CursorColumn) } // cutToClipboard copies the current selection to a given clipboard and then removes the selected text. // This does nothing if it is a concealed entry. func (e *Entry) cutToClipboard(clipboard fyne.Clipboard) { - if !e.selecting || e.Password { + if !e.sel.selecting || e.Password { return } @@ -937,7 +913,7 @@ func (e *Entry) eraseSelection() bool { } provider := e.textProvider() - posA, posB := e.selection() + posA, posB := e.sel.selection() if posA == posB { return false @@ -945,8 +921,9 @@ func (e *Entry) eraseSelection() bool { erasedText := provider.deleteFromTo(posA, posB) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(posA) - e.selectRow, e.selectColumn = e.CursorRow, e.CursorColumn - e.selecting = false + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.sel.selectRow, e.sel.selectColumn = e.CursorRow, e.CursorColumn + e.sel.selecting = false e.undoStack.MergeOrAdd(&entryModifyAction{ Delete: true, @@ -965,31 +942,12 @@ func (e *Entry) eraseSelectionAndUpdate() { } } -func (e *Entry) getRowCol(p fyne.Position) (int, int) { - th := e.Theme() - textSize := th.Size(theme.SizeNameText) - - rowHeight := e.textProvider().charMinSize(e.Password, e.TextStyle, textSize).Height - row := int(math.Floor(float64(p.Y+e.scroll.Offset.Y-th.Size(theme.SizeNameLineSpacing)) / float64(rowHeight))) - col := 0 - if row < 0 { - row = 0 - } else if row >= e.textProvider().rows() { - row = e.textProvider().rows() - 1 - col = e.textProvider().rowLength(row) - } else { - col = e.cursorColAt(e.textProvider().row(row), p.Add(e.scroll.Offset)) - } - - return row, col -} - // pasteFromClipboard inserts text from the clipboard content, // starting from the cursor position. func (e *Entry) pasteFromClipboard(clipboard fyne.Clipboard) { text := clipboard.Content() if text == "" { - changed := e.selecting && e.eraseSelection() + changed := e.sel.selecting && e.eraseSelection() if changed { e.Refresh() @@ -1003,7 +961,7 @@ func (e *Entry) pasteFromClipboard(clipboard fyne.Clipboard) { text = strings.Replace(text, "\n", " ", -1) } - if e.selecting { + if e.sel.selecting { e.eraseSelection() } @@ -1019,6 +977,7 @@ func (e *Entry) pasteFromClipboard(clipboard fyne.Clipboard) { content := provider.String() e.updateText(content, false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes)) + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn cb := e.OnChanged e.validate() @@ -1102,18 +1061,19 @@ func (e *Entry) registerShortcut() { e.CursorColumn = end } } + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn }) } selectMoveWord := func(se fyne.Shortcut) { - if !e.selecting { - e.selectColumn = e.CursorColumn - e.selectRow = e.CursorRow - e.selecting = true + if !e.sel.selecting { + e.sel.selectColumn = e.CursorColumn + e.sel.selectRow = e.CursorRow + e.sel.selecting = true } moveWord(se) } unselectMoveWord := func(se fyne.Shortcut) { - e.selecting = false + e.sel.selecting = false moveWord(se) } @@ -1123,7 +1083,7 @@ func (e *Entry) registerShortcut() { // Cmd+left, Cmd+right shortcuts behave like Home and End keys on Mac OS shortcutHomeEnd := func(s fyne.Shortcut) { - e.selecting = false + e.sel.selecting = false if s.(*desktop.CustomShortcut).KeyName == fyne.KeyLeft { e.typedKeyHome() } else { @@ -1186,13 +1146,14 @@ func (e *Entry) selectAll() { return } e.setFieldsAndRefresh(func() { - e.selectRow = 0 - e.selectColumn = 0 + e.sel.selectRow = 0 + e.sel.selectColumn = 0 lastRow := e.textProvider().rows() - 1 e.CursorColumn = e.textProvider().rowLength(lastRow) e.CursorRow = lastRow - e.selecting = true + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.sel.selecting = true }) } @@ -1201,17 +1162,17 @@ func (e *Entry) selectAll() { // returns true if the keypress has been fully handled func (e *Entry) selectingKeyHandler(key *fyne.KeyEvent) bool { - if e.selectKeyDown && !e.selecting { + if e.selectKeyDown && !e.sel.selecting { switch key.Name { case fyne.KeyUp, fyne.KeyDown, fyne.KeyLeft, fyne.KeyRight, fyne.KeyEnd, fyne.KeyHome, fyne.KeyPageUp, fyne.KeyPageDown: - e.selecting = true + e.sel.selecting = true } } - if !e.selecting { + if !e.sel.selecting { return false } @@ -1240,19 +1201,21 @@ func (e *Entry) selectingKeyHandler(key *fyne.KeyEvent) bool { switch key.Name { case fyne.KeyLeft: // seek to the start of the selection -- return handled - selectStart, _ := e.selection() + selectStart, _ := e.sel.selection() e.CursorRow, e.CursorColumn = e.rowColFromTextPos(selectStart) - e.selecting = false + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.sel.selecting = false return true case fyne.KeyRight: // seek to the end of the selection -- return handled - _, selectEnd := e.selection() + _, selectEnd := e.sel.selection() e.CursorRow, e.CursorColumn = e.rowColFromTextPos(selectEnd) - e.selecting = false + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.sel.selecting = false return true case fyne.KeyUp, fyne.KeyDown, fyne.KeyEnd, fyne.KeyHome, fyne.KeyPageUp, fyne.KeyPageDown: // cursor movement without left or right shift -- clear selection and return unhandled - e.selecting = false + e.sel.selecting = false return false } } @@ -1260,44 +1223,6 @@ func (e *Entry) selectingKeyHandler(key *fyne.KeyEvent) bool { return false } -// selection returns the start and end text positions for the selected span of text -// Note: this functionality depends on the relationship between the selection start row/col and -// the current cursor row/column. -// eg: (whitespace for clarity, '_' denotes cursor) -// -// "T e s [t i]_n g" == 3, 5 -// "T e s_[t i] n g" == 3, 5 -// "T e_[s t i] n g" == 2, 5 -func (e *Entry) selection() (int, int) { - noSelection := !e.selecting || (e.CursorRow == e.selectRow && e.CursorColumn == e.selectColumn) - - if noSelection { - return -1, -1 - } - - // Find the selection start - rowA, colA := e.CursorRow, e.CursorColumn - rowB, colB := e.selectRow, e.selectColumn - // Reposition if the cursors row is more than select start row, or if the row is the same and - // the cursors col is more that the select start column - if rowA > e.selectRow || (rowA == e.selectRow && colA > e.selectColumn) { - rowA, colA = e.selectRow, e.selectColumn - rowB, colB = e.CursorRow, e.CursorColumn - } - - return e.textPosFromRowCol(rowA, colA), e.textPosFromRowCol(rowB, colB) -} - -// Obtains textual position from a given row and col -// expects a read or write lock to be held by the caller -func (e *Entry) textPosFromRowCol(row, col int) int { - b := e.textProvider().rowBoundary(row) - if b == nil { - return col - } - return b.begin + col -} - func (e *Entry) syncSegments() { colName := theme.ColorNameForeground wrap := e.textWrap() @@ -1361,7 +1286,8 @@ func (e *Entry) textWrap() fyne.TextWrap { func (e *Entry) updateCursorAndSelection() { e.CursorRow, e.CursorColumn = e.truncatePosition(e.CursorRow, e.CursorColumn) - e.selectRow, e.selectColumn = e.truncatePosition(e.selectRow, e.selectColumn) + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.sel.selectRow, e.sel.selectColumn = e.truncatePosition(e.sel.selectRow, e.sel.selectColumn) } func (e *Entry) updateFromData(data binding.DataItem) { @@ -1399,16 +1325,18 @@ func (e *Entry) truncatePosition(row, col int) (int, int) { } func (e *Entry) updateMousePointer(p fyne.Position, rightClick bool) { - row, col := e.getRowCol(p) + row, col := e.sel.getRowCol(p) - if !rightClick || !e.selecting { + if !rightClick || !e.sel.selecting { e.CursorRow = row e.CursorColumn = col + + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn } - if !e.selecting { - e.selectRow = row - e.selectColumn = col + if !e.sel.selecting { + e.sel.selectRow = row + e.sel.selectColumn = col } r := cache.Renderer(e.content) @@ -1499,13 +1427,14 @@ func (e *Entry) typedKeyReturn(provider *RichText, multiLine bool) { }) e.CursorColumn = 0 e.CursorRow++ + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn } // Selects the row where the CursorColumn is currently positioned func (e *Entry) selectCurrentRow() { provider := e.textProvider() - e.selectRow = e.CursorRow - e.selectColumn = 0 + e.sel.selectRow = e.CursorRow + e.sel.selectColumn = 0 if e.MultiLine { e.CursorColumn = provider.rowLength(e.CursorRow) } else { @@ -1598,8 +1527,8 @@ func (r *entryRenderer) Layout(size fyne.Size) { entrySize := size.Subtract(fyne.NewSize(r.trailingInset(), inputBorder*2)) entryPos := fyne.NewPos(0, inputBorder) - textPos := r.entry.textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn) - selectPos := r.entry.textPosFromRowCol(r.entry.selectRow, r.entry.selectColumn) + textPos := r.entry.sel.textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn) + selectPos := r.entry.sel.textPosFromRowCol(r.entry.sel.selectRow, r.entry.sel.selectColumn) if r.entry.Wrapping == fyne.TextWrapOff && r.entry.Scroll == widget.ScrollNone { r.entry.content.Resize(entrySize) r.entry.content.Move(entryPos) @@ -1608,13 +1537,14 @@ func (r *entryRenderer) Layout(size fyne.Size) { r.scroll.Move(entryPos) } - resizedTextPos := r.entry.textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn) + resizedTextPos := r.entry.sel.textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn) if textPos != resizedTextPos { r.entry.setFieldsAndRefresh(func() { r.entry.CursorRow, r.entry.CursorColumn = r.entry.rowColFromTextPos(textPos) + r.entry.sel.cursorRow, r.entry.sel.cursorRow = r.entry.CursorRow, r.entry.CursorColumn - if r.entry.selecting { - r.entry.selectRow, r.entry.selectColumn = r.entry.rowColFromTextPos(selectPos) + if r.entry.sel.selecting { + r.entry.sel.selectRow, r.entry.sel.selectColumn = r.entry.rowColFromTextPos(selectPos) } }) } @@ -1738,6 +1668,8 @@ func (r *entryRenderer) Refresh() { r.entry.validationStatus.Hide() } + r.entry.sel.Hidden = !r.entry.focused + cache.Renderer(r.entry.content).Refresh() canvas.Refresh(r.entry.super()) } @@ -1772,7 +1704,7 @@ func (e *entryContent) CreateRenderer() fyne.WidgetRenderer { } objects := []fyne.CanvasObject{placeholder, provider, e.entry.cursorAnim.cursor} - r := &entryContentRenderer{e.entry.cursorAnim.cursor, []fyne.CanvasObject{}, objects, + r := &entryContentRenderer{e.entry.cursorAnim.cursor, objects, provider, placeholder, e} r.updateScrollDirections() r.Layout(e.Size()) @@ -1800,9 +1732,8 @@ func (e *entryContent) Dragged(d *fyne.DragEvent) { var _ fyne.WidgetRenderer = (*entryContentRenderer)(nil) type entryContentRenderer struct { - cursor *canvas.Rectangle - selection []fyne.CanvasObject - objects []fyne.CanvasObject + cursor *canvas.Rectangle + objects []fyne.CanvasObject provider, placeholder *RichText content *entryContent @@ -1830,10 +1761,8 @@ func (r *entryContentRenderer) MinSize() fyne.Size { func (r *entryContentRenderer) Objects() []fyne.CanvasObject { // Objects are generated dynamically force selection rectangles to appear underneath the text - if r.content.entry.selecting { - objs := make([]fyne.CanvasObject, 0, len(r.selection)+len(r.objects)) - objs = append(objs, r.selection...) - return append(objs, r.objects...) + if r.content.entry.sel.selecting { + return append([]fyne.CanvasObject{r.content.entry.sel}, r.objects...) } return r.objects } @@ -1843,7 +1772,6 @@ func (r *entryContentRenderer) Refresh() { placeholder := r.content.entry.placeholderProvider() focused := r.content.entry.focused focusedAppearance := focused && !r.content.entry.Disabled() - selections := r.selection r.updateScrollDirections() if provider.len() == 0 { @@ -1867,102 +1795,9 @@ func (r *entryContentRenderer) Refresh() { } r.moveCursor() - selectionColor := th.Color(theme.ColorNameSelection, v) - for _, selection := range selections { - rect := selection.(*canvas.Rectangle) - rect.Hidden = !focused - rect.FillColor = selectionColor - } - canvas.Refresh(r.content) } -// This process builds a slice of rectangles: -// - one entry per row of text -// - ordered by row order as they occur in multiline text -// This process could be optimized in the scenario where the user is selecting upwards: -// If the upwards case instead produces an order-reversed slice then only the newest rectangle would -// require movement and resizing. The existing solution creates a new rectangle and then moves/resizes -// all rectangles to comply with the occurrence order as stated above. -func (r *entryContentRenderer) buildSelection() { - th := r.content.entry.Theme() - v := fyne.CurrentApp().Settings().ThemeVariant() - textSize := th.Size(theme.SizeNameText) - - cursorRow, cursorCol := r.content.entry.CursorRow, r.content.entry.CursorColumn - selectRow, selectCol := -1, -1 - if r.content.entry.selecting { - selectRow = r.content.entry.selectRow - selectCol = r.content.entry.selectColumn - } - - if selectRow == -1 || (cursorRow == selectRow && cursorCol == selectCol) { - r.selection = r.selection[:0] - - return - } - - provider := r.content.entry.textProvider() - innerPad := th.Size(theme.SizeNameInnerPadding) - // Convert column, row into x,y - getCoordinates := func(column int, row int) (float32, float32) { - sz := provider.lineSizeToColumn(column, row, textSize, innerPad) - return sz.Width, sz.Height*float32(row) - th.Size(theme.SizeNameInputBorder) + innerPad - } - - lineHeight := r.content.entry.text.charMinSize(r.content.entry.Password, r.content.entry.TextStyle, textSize).Height - - minmax := func(a, b int) (int, int) { - if a < b { - return a, b - } - return b, a - } - - // The remainder of the function calculates the set of boxes and add them to r.selection - - selectStartRow, selectEndRow := minmax(selectRow, cursorRow) - selectStartCol, selectEndCol := minmax(selectCol, cursorCol) - if selectRow < cursorRow { - selectStartCol, selectEndCol = selectCol, cursorCol - } - if selectRow > cursorRow { - selectStartCol, selectEndCol = cursorCol, selectCol - } - rowCount := selectEndRow - selectStartRow + 1 - - // trim r.selection to remove unwanted old rectangles - if len(r.selection) > rowCount { - r.selection = r.selection[:rowCount] - } - - // build a rectangle for each row and add it to r.selection - for i := 0; i < rowCount; i++ { - if len(r.selection) <= i { - box := canvas.NewRectangle(th.Color(theme.ColorNameSelection, v)) - r.selection = append(r.selection, box) - } - - // determine starting/ending columns for this rectangle - row := selectStartRow + i - startCol, endCol := selectStartCol, selectEndCol - if selectStartRow < row { - startCol = 0 - } - if selectEndRow > row { - endCol = provider.rowLength(row) - } - - // translate columns and row into draw coordinates - x1, y1 := getCoordinates(startCol, row) - x2, _ := getCoordinates(endCol, row) - - // resize and reposition each rectangle - r.selection[i].Resize(fyne.NewSize(x2-x1+1, lineHeight)) - r.selection[i].Move(fyne.NewPos(x1-1, y1)) - } -} - func (r *entryContentRenderer) ensureCursorVisible() { th := r.content.entry.Theme() lineSpace := th.Size(theme.SizeNameLineSpacing) @@ -2002,7 +1837,7 @@ func (r *entryContentRenderer) ensureCursorVisible() { func (r *entryContentRenderer) moveCursor() { // build r.selection[] if the user has made a selection - r.buildSelection() + r.content.entry.sel.Refresh() th := r.content.entry.Theme() textSize := th.Size(theme.SizeNameText) diff --git a/widget/entry_internal_test.go b/widget/entry_internal_test.go index 85024f2a71..daf746ae9f 100644 --- a/widget/entry_internal_test.go +++ b/widget/entry_internal_test.go @@ -147,11 +147,11 @@ func TestEntry_DragSelectEmpty(t *testing.T) { de = &fyne.DragEvent{PointEvent: *ev1, Dragged: fyne.NewDelta(1, 0)} entry.Dragged(de) - assert.True(t, entry.selecting) + assert.True(t, entry.sel.selecting) entry.DragEnd() assert.Equal(t, "", entry.SelectedText()) - assert.False(t, entry.selecting) + assert.False(t, entry.sel.selecting) // Test non-empty selection - drag from 'T' to 'g' (empty) ev1 = getClickPosition("", 0) @@ -160,11 +160,11 @@ func TestEntry_DragSelectEmpty(t *testing.T) { de = &fyne.DragEvent{PointEvent: *ev2, Dragged: fyne.NewDelta(1, 0)} entry.Dragged(de) - assert.True(t, entry.selecting) + assert.True(t, entry.sel.selecting) entry.DragEnd() assert.Equal(t, "Testing", entry.SelectedText()) - assert.True(t, entry.selecting) + assert.True(t, entry.sel.selecting) } func TestEntry_DragSelectWithScroll(t *testing.T) { @@ -258,6 +258,7 @@ func TestEntry_EraseSelection(t *testing.T) { e.SetText("Testing\nTesting\nTesting") e.CursorRow = 1 e.CursorColumn = 2 + e.sel.cursorRow, e.sel.cursorRow = e.CursorRow, e.CursorColumn var keyDown = func(key *fyne.KeyEvent) { e.KeyDown(key) e.TypedKey(key) @@ -275,7 +276,7 @@ func TestEntry_EraseSelection(t *testing.T) { e.eraseSelectionAndUpdate() e.updateText(e.textProvider().String(), false) assert.Equal(t, "Testing\nTeng\nTesting", e.Text) - a, b := e.selection() + a, b := e.sel.selection() assert.Equal(t, -1, a) assert.Equal(t, -1, b) } @@ -307,7 +308,7 @@ func TestEntry_MouseClickAndDragOutsideText(t *testing.T) { de := &fyne.DragEvent{PointEvent: *ev, Dragged: fyne.NewDelta(1, 0)} entry.Dragged(de) entry.MouseUp(me) - assert.False(t, entry.selecting) + assert.False(t, entry.sel.selecting) } func TestEntry_MouseDownOnSelect(t *testing.T) { @@ -429,6 +430,7 @@ func TestEntry_TabSelection(t *testing.T) { e.TextStyle.Monospace = true e.CursorRow = 1 + e.sel.cursorRow = 1 e.KeyDown(&fyne.KeyEvent{Name: desktop.KeyShiftLeft}) e.TypedKey(&fyne.KeyEvent{Name: fyne.KeyRight}) e.TypedKey(&fyne.KeyEvent{Name: fyne.KeyRight}) diff --git a/widget/label.go b/widget/label.go index eab84b9d91..2d143a98a7 100644 --- a/widget/label.go +++ b/widget/label.go @@ -3,7 +3,6 @@ package widget import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/data/binding" - "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" ) @@ -31,9 +30,9 @@ type Label struct { //Since: 2.6 Selectable bool - provider *RichText - binder basicBinder - hover *labelHover + provider *RichText + binder basicBinder + selection *selectable } // NewLabel creates a new label widget with the set text content @@ -78,13 +77,18 @@ func (l *Label) CreateRenderer() fyne.WidgetRenderer { l.ExtendBaseWidget(l) l.syncSegments() - l.hover = &labelHover{l: l} + l.selection = &selectable{} + l.selection.ExtendBaseWidget(l.selection) + l.selection.style = l.TextStyle + l.selection.theme = l.Theme() + l.selection.provider = l.provider + if !l.Selectable { - l.hover.Hide() + l.selection.Hide() } return NewSimpleRenderer( &fyne.Container{Layout: layout.NewStackLayout(), - Objects: []fyne.CanvasObject{l.provider, l.hover}}) + Objects: []fyne.CanvasObject{l.selection, l.provider}}) } // MinSize returns the size that this label should not shrink below. @@ -99,25 +103,26 @@ func (l *Label) MinSize() fyne.Size { // // Implements: fyne.Widget func (l *Label) Refresh() { - l.hover.Hidden = !l.Selectable - l.hover.Refresh() if l.provider == nil { // not created until visible return } l.syncSegments() l.provider.Refresh() l.BaseWidget.Refresh() + + l.selection.Hidden = !l.Selectable + l.selection.style = l.TextStyle + l.selection.theme = l.Theme() + l.selection.Refresh() } -// Resize sets a new size for the label. -// This should only be called if it is not in a container with a layout manager. +// SelectedText returns the text currently selected in this Label. +// If the label is not Selectable it will return an empty string. +// If there is no selection it will return the empty string. // -// Implements: fyne.Widget -func (l *Label) Resize(s fyne.Size) { - l.BaseWidget.Resize(s) - if l.provider != nil { - l.provider.Resize(s) - } +// Since: 2.6 +func (l *Label) SelectedText() string { + return l.selection.SelectedText() } // SetText sets the text of the label @@ -179,17 +184,3 @@ func (l *Label) updateFromData(data binding.DataItem) { } l.SetText(val) } - -type labelHover struct { - BaseWidget - - l *Label -} - -func (l *labelHover) Cursor() desktop.Cursor { - if l.l.Selectable { - return desktop.TextCursor - } - - return desktop.DefaultCursor -} diff --git a/widget/selectable.go b/widget/selectable.go new file mode 100644 index 0000000000..415f904ebe --- /dev/null +++ b/widget/selectable.go @@ -0,0 +1,300 @@ +package widget + +import ( + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" +) + +type selectable struct { + BaseWidget + cursorRow, cursorColumn int + + // selectRow and selectColumn represent the selection start location + // The selection will span from selectRow/Column to CursorRow/Column -- note that the cursor + // position may occur before or after the select start position in the text. + selectRow, selectColumn int + + selecting, password bool + style fyne.TextStyle + + provider *RichText + theme fyne.Theme + + // TODO maybe render? + selections []fyne.CanvasObject +} + +func (s *selectable) CreateRenderer() fyne.WidgetRenderer { + return &selectableRenderer{sel: s} +} + +func (s *selectable) Cursor() desktop.Cursor { + return desktop.TextCursor +} + +func (s *selectable) DragEnd() { + if s.cursorColumn == s.selectColumn && s.cursorRow == s.selectRow { + s.selecting = false + } + + //shouldRefresh := !s.selecting + //if shouldRefresh { + s.Refresh() + // } +} + +func (s *selectable) Dragged(d *fyne.DragEvent) { + if !s.selecting { + startPos := d.Position.Subtract(d.Dragged) + s.selectRow, s.selectColumn = s.getRowCol(startPos) + s.selecting = true + } + + s.updateMousePointer(d.Position) + s.Refresh() +} + +func (s *selectable) MouseDown(m *desktop.MouseEvent) { + //if e.isTripleTap(time.Now().UnixMilli()) { + // e.selectCurrentRow() + // return + //} + if s.selecting && m.Button == desktop.MouseButtonPrimary { + s.selecting = false + } + + if m.Button == desktop.MouseButtonPrimary { + s.updateMousePointer(m.Position) + } +} + +func (s *selectable) MouseUp(_ *desktop.MouseEvent) { + start, _ := s.selection() + if start == -1 && s.selecting { + s.selecting = false + } +} + +// SelectedText returns the text currently selected in this Entry. +// If there is no selection it will return the empty string. +func (s *selectable) SelectedText() string { + if !s.selecting { + return "" + } + + start, stop := s.selection() + if start == stop { + return "" + } + r := ([]rune)(s.provider.String()) + return string(r[start:stop]) +} + +func (s *selectable) cursorColAt(text []rune, pos fyne.Position) int { + th := s.theme + textSize := th.Size(theme.SizeNameText) + innerPad := th.Size(theme.SizeNameInnerPadding) + + for i := 0; i < len(text); i++ { + str := string(text[0:i]) + wid := fyne.MeasureText(str, textSize, fyne.TextStyle{}).Width // todo e.TextStyle + charWid := fyne.MeasureText(string(text[i]), textSize, fyne.TextStyle{}).Width // todo e.TextStyle + if pos.X < innerPad+wid+(charWid/2) { + return i + } + } + return len(text) +} + +func (s *selectable) getRowCol(p fyne.Position) (int, int) { + th := s.theme + textSize := th.Size(theme.SizeNameText) + innerPad := th.Size(theme.SizeNameInnerPadding) + + rowHeight := s.provider.charMinSize(false, fyne.TextStyle{}, textSize).Height // TODO (e.Password, e.TextStyle, textSize).Height + row := int(math.Floor(float64(p.Y-innerPad+th.Size(theme.SizeNameLineSpacing)) / float64(rowHeight))) + col := 0 + if row < 0 { + row = 0 + } else if row >= s.provider.rows() { + row = s.provider.rows() - 1 + col = s.provider.rowLength(row) + } else { + col = s.cursorColAt(s.provider.row(row), p) + } + + return row, col +} + +// selection returns the start and end text positions for the selected span of text +// Note: this functionality depends on the relationship between the selection start row/col and +// the current cursor row/column. +// eg: (whitespace for clarity, '_' denotes cursor) +// +// "T e s [t i]_n g" == 3, 5 +// "T e s_[t i] n g" == 3, 5 +// "T e_[s t i] n g" == 2, 5 +func (s *selectable) selection() (int, int) { + noSelection := !s.selecting || (s.cursorRow == s.selectRow && s.cursorColumn == s.selectColumn) + + if noSelection { + return -1, -1 + } + + // Find the selection start + rowA, colA := s.cursorRow, s.cursorColumn + rowB, colB := s.selectRow, s.selectColumn + // Reposition if the cursors row is more than select start row, or if the row is the same and + // the cursors col is more that the select start column + if rowA > s.selectRow || (rowA == s.selectRow && colA > s.selectColumn) { + rowA, colA = s.selectRow, s.selectColumn + rowB, colB = s.cursorRow, s.cursorColumn + } + + return s.textPosFromRowCol(rowA, colA), s.textPosFromRowCol(rowB, colB) +} + +// Obtains textual position from a given row and col +// expects a read or write lock to be held by the caller +func (s *selectable) textPosFromRowCol(row, col int) int { + b := s.provider.rowBoundary(row) + if b == nil { + return col + } + return b.begin + col +} + +func (s *selectable) updateMousePointer(p fyne.Position) { + row, col := s.getRowCol(p) + s.cursorRow, s.cursorColumn = row, col + + if !s.selecting { + s.selectRow = row + s.selectColumn = col + } +} + +type selectableRenderer struct { + sel *selectable +} + +func (r *selectableRenderer) Destroy() { +} + +func (r *selectableRenderer) Layout(fyne.Size) { +} + +func (r *selectableRenderer) MinSize() fyne.Size { + return fyne.Size{} +} + +func (r *selectableRenderer) Objects() []fyne.CanvasObject { + r.buildSelection() + + return r.sel.selections +} + +func (r *selectableRenderer) Refresh() { + r.buildSelection() + + selections := r.sel.selections + v := fyne.CurrentApp().Settings().ThemeVariant() + + selectionColor := r.sel.theme.Color(theme.ColorNameSelection, v) + for _, selection := range selections { + rect := selection.(*canvas.Rectangle) + rect.FillColor = selectionColor + } + + canvas.Refresh(r.sel) +} + +// This process builds a slice of rectangles: +// - one entry per row of text +// - ordered by row order as they occur in multiline text +// This process could be optimized in the scenario where the user is selecting upwards: +// If the upwards case instead produces an order-reversed slice then only the newest rectangle would +// require movement and resizing. The existing solution creates a new rectangle and then moves/resizes +// all rectangles to comply with the occurrence order as stated above. +func (r *selectableRenderer) buildSelection() { + th := r.sel.theme + v := fyne.CurrentApp().Settings().ThemeVariant() + textSize := th.Size(theme.SizeNameText) + + cursorRow, cursorCol := r.sel.cursorRow, r.sel.cursorColumn + selectRow, selectCol := -1, -1 + if r.sel.selecting { + selectRow = r.sel.selectRow + selectCol = r.sel.selectColumn + } + + if selectRow == -1 || (cursorRow == selectRow && cursorCol == selectCol) { + r.sel.selections = r.sel.selections[:0] + + return + } + + provider := r.sel.provider + innerPad := th.Size(theme.SizeNameInnerPadding) + // Convert column, row into x,y + getCoordinates := func(column int, row int) (float32, float32) { + sz := provider.lineSizeToColumn(column, row, textSize, innerPad) + return sz.Width, sz.Height*float32(row) - th.Size(theme.SizeNameInputBorder) + innerPad + } + + lineHeight := r.sel.provider.charMinSize(r.sel.password, r.sel.style, textSize).Height + + minmax := func(a, b int) (int, int) { + if a < b { + return a, b + } + return b, a + } + + // The remainder of the function calculates the set of boxes and add them to r.selection + + selectStartRow, selectEndRow := minmax(selectRow, cursorRow) + selectStartCol, selectEndCol := minmax(selectCol, cursorCol) + if selectRow < cursorRow { + selectStartCol, selectEndCol = selectCol, cursorCol + } + if selectRow > cursorRow { + selectStartCol, selectEndCol = cursorCol, selectCol + } + rowCount := selectEndRow - selectStartRow + 1 + + // trim r.selection to remove unwanted old rectangles + if len(r.sel.selections) > rowCount { + r.sel.selections = r.sel.selections[:rowCount] + } + + // build a rectangle for each row and add it to r.selection + for i := 0; i < rowCount; i++ { + if len(r.sel.selections) <= i { + box := canvas.NewRectangle(th.Color(theme.ColorNameSelection, v)) + r.sel.selections = append(r.sel.selections, box) + } + + // determine starting/ending columns for this rectangle + row := selectStartRow + i + startCol, endCol := selectStartCol, selectEndCol + if selectStartRow < row { + startCol = 0 + } + if selectEndRow > row { + endCol = provider.rowLength(row) + } + + // translate columns and row into draw coordinates + x1, y1 := getCoordinates(startCol, row) + x2, _ := getCoordinates(endCol, row) + + // resize and reposition each rectangle + r.sel.selections[i].Resize(fyne.NewSize(x2-x1+1, lineHeight)) + r.sel.selections[i].Move(fyne.NewPos(x1-1, y1)) + } +} From 937416ead838c4610609255cca2320a78fcb8aad Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sun, 16 Feb 2025 15:37:17 +0000 Subject: [PATCH 03/21] Show a menu to copy text out of selectable label --- widget/selectable.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/widget/selectable.go b/widget/selectable.go index 415f904ebe..036cf581aa 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -6,6 +6,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/theme" ) @@ -41,10 +42,10 @@ func (s *selectable) DragEnd() { s.selecting = false } - //shouldRefresh := !s.selecting - //if shouldRefresh { - s.Refresh() - // } + shouldRefresh := !s.selecting + if shouldRefresh { + s.Refresh() + } } func (s *selectable) Dragged(d *fyne.DragEvent) { @@ -72,7 +73,22 @@ func (s *selectable) MouseDown(m *desktop.MouseEvent) { } } -func (s *selectable) MouseUp(_ *desktop.MouseEvent) { +func (s *selectable) MouseUp(ev *desktop.MouseEvent) { + if ev.Button == desktop.MouseButtonSecondary { + c := fyne.CurrentApp().Driver().CanvasForObject(s) + if c == nil { + return + } + + m := fyne.NewMenu("", + fyne.NewMenuItem(lang.L("Copy"), func() { + fyne.CurrentApp().Clipboard().SetContent(s.SelectedText()) + })) + ShowPopUpMenuAtPosition(m, c, ev.AbsolutePosition) + + return + } + start, _ := s.selection() if start == -1 && s.selecting { s.selecting = false From 40f92847d1dada653e664af01107fcc72202d4ad Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sun, 16 Feb 2025 15:42:56 +0000 Subject: [PATCH 04/21] Fix label tests --- widget/label_extend_test.go | 3 ++- widget/label_test.go | 6 +++--- widget/testdata/label/default.xml | 8 +++++--- widget/testdata/label/truncate.xml | 8 +++++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/widget/label_extend_test.go b/widget/label_extend_test.go index b4836d8c66..36095fb502 100644 --- a/widget/label_extend_test.go +++ b/widget/label_extend_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/assert" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/internal/cache" ) @@ -22,7 +23,7 @@ func newExtendedLabel(text string) *extendedLabel { func TestLabel_Extended_SetText(t *testing.T) { label := newExtendedLabel("Start") - rich := cache.Renderer(label).Objects()[0].(*RichText) + rich := cache.Renderer(label).Objects()[0].(*fyne.Container).Objects[0].(*selectable).provider objs := cache.Renderer(rich).Objects() assert.Len(t, objs, 1) assert.Equal(t, "Start", objs[0].(*canvas.Text).Text) diff --git a/widget/label_test.go b/widget/label_test.go index 02c77810a2..959d757219 100644 --- a/widget/label_test.go +++ b/widget/label_test.go @@ -127,7 +127,7 @@ func TestText_MinSize_MultiLine(t *testing.T) { textOneLine := NewLabel("Break") min := textOneLine.MinSize() textMultiLine := NewLabel("Bre\nak") - rich := test.TempWidgetRenderer(t, textMultiLine).Objects()[0].(*RichText) + rich := test.TempWidgetRenderer(t, textMultiLine).Objects()[0].(*fyne.Container).Objects[0].(*selectable).provider min2 := textMultiLine.MinSize() assert.Less(t, min2.Width, min.Width) @@ -157,7 +157,7 @@ func TestText_MinSizeAdjustsWithContent(t *testing.T) { func TestLabel_ApplyTheme(t *testing.T) { text := NewLabel("Line 1") text.Hide() - rich := test.TempWidgetRenderer(t, text).Objects()[0].(*RichText) + rich := test.TempWidgetRenderer(t, text).Objects()[0].(*fyne.Container).Objects[0].(*selectable).provider render := test.TempWidgetRenderer(t, rich).(*textRenderer) assert.Equal(t, theme.Color(theme.ColorNameForeground), render.Objects()[0].(*canvas.Text).Color) @@ -243,6 +243,6 @@ func TestLabelImportance(t *testing.T) { } func labelTextRenderTexts(p fyne.Widget) []*canvas.Text { - rich := cache.Renderer(p).Objects()[0].(*RichText) + rich := cache.Renderer(p).Objects()[0].(*fyne.Container).Objects[0].(*selectable).provider return richTextRenderTexts(rich) } diff --git a/widget/testdata/label/default.xml b/widget/testdata/label/default.xml index 7dc4f812cf..cb10d02856 100644 --- a/widget/testdata/label/default.xml +++ b/widget/testdata/label/default.xml @@ -1,9 +1,11 @@ - - Hello - + + + Hello + + diff --git a/widget/testdata/label/truncate.xml b/widget/testdata/label/truncate.xml index 16b90c1c01..0140afda83 100644 --- a/widget/testdata/label/truncate.xml +++ b/widget/testdata/label/truncate.xml @@ -1,9 +1,11 @@ - - Hel - + + + Hel + + From 96622d34168d4ff0da8fe2614c60fe536595d8c0 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Fri, 21 Feb 2025 21:49:57 +0000 Subject: [PATCH 05/21] Entry updates for test fixes --- widget/entry.go | 74 +++++++++++++------ widget/selectable.go | 8 +- .../testdata/entry/select_add_selection.xml | 4 +- widget/testdata/entry/select_all_selected.xml | 8 +- .../select_multi_line_shift_pagedown.xml | 8 +- .../entry/select_multi_line_shift_pageup.xml | 4 +- widget/testdata/entry/select_select_left.xml | 4 +- widget/testdata/entry/select_selected.xml | 4 +- .../select_single_line_shift_pagedown.xml | 4 +- .../entry/select_single_line_shift_pageup.xml | 4 +- .../entry/selection_add_one_row_down.xml | 6 +- .../testdata/entry/selection_add_to_end.xml | 4 +- .../testdata/entry/selection_add_to_home.xml | 4 +- .../entry/selection_delete_and_add_down.xml | 6 +- .../entry/selection_delete_and_add_up.xml | 6 +- .../testdata/entry/selection_focus_gained.xml | 4 +- widget/testdata/entry/selection_initial.xml | 4 +- .../entry/selection_initial_reverse.xml | 4 +- .../entry/selection_remove_add_one_row_up.xml | 6 +- .../entry/selection_remove_one_row_up.xml | 4 +- 20 files changed, 115 insertions(+), 55 deletions(-) diff --git a/widget/entry.go b/widget/entry.go index 392339f404..d3bd56c58f 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -105,6 +105,8 @@ type Entry struct { func NewEntry() *Entry { e := &Entry{Wrapping: fyne.TextWrap(fyne.TextTruncateClip)} e.ExtendBaseWidget(e) + + e.syncSelectable() return e } @@ -167,7 +169,7 @@ func (e *Entry) CreateRenderer() fyne.WidgetRenderer { // initialise e.textProvider() e.placeholderProvider() - e.sel = &selectable{theme: th, provider: e.textProvider(), password: e.Password, style: e.TextStyle} + e.syncSelectable() box := canvas.NewRectangle(th.Color(theme.ColorNameInputBackground, v)) box.CornerRadius = th.Size(theme.SizeNameInputRadius) @@ -216,6 +218,8 @@ func (e *Entry) Cursor() desktop.Cursor { // // Implements: fyne.DoubleTappable func (e *Entry) DoubleTapped(p *fyne.PointEvent) { + e.requestFocus() + e.syncSelectable() e.doubleTappedAtUnixMillis = time.Now().UnixMilli() row := e.textProvider().row(e.CursorRow) start, end := getTextWhitespaceRegion(row, e.CursorColumn, false) @@ -234,6 +238,8 @@ func (e *Entry) DoubleTapped(p *fyne.PointEvent) { } else { e.CursorColumn = end } + + e.syncSelectable() e.sel.selecting = true }) } @@ -246,6 +252,8 @@ func (e *Entry) isTripleTap(nowMilli int64) bool { // // Implements: fyne.Draggable func (e *Entry) DragEnd() { + e.syncSelectable() + if e.CursorColumn == e.sel.selectColumn && e.CursorRow == e.sel.selectRow { e.sel.selecting = false } @@ -371,6 +379,9 @@ func (e *Entry) MinSize() fyne.Size { // // Implements: desktop.Mouseable func (e *Entry) MouseDown(m *desktop.MouseEvent) { + e.requestFocus() + e.syncSelectable() + if e.isTripleTap(time.Now().UnixMilli()) { e.selectCurrentRow() return @@ -395,6 +406,7 @@ func (e *Entry) MouseDown(m *desktop.MouseEvent) { // // Implements: desktop.Mouseable func (e *Entry) MouseUp(m *desktop.MouseEvent) { + e.syncSelectable() start, _ := e.sel.selection() if start == -1 && e.sel.selecting && !e.selectKeyDown { e.sel.selecting = false @@ -416,7 +428,7 @@ func (e *Entry) Redo() { } e.updateText(newText, false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos) - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() e.Refresh() } @@ -498,6 +510,7 @@ func (e *Entry) Append(text string) { // // Implements: fyne.Tappable func (e *Entry) Tapped(ev *fyne.PointEvent) { + if fyne.CurrentDevice().IsMobile() && e.sel.selecting { e.sel.selecting = false } @@ -623,7 +636,7 @@ func (e *Entry) TypedKey(key *fyne.KeyEvent) { pos := e.cursorTextPos() deletedText := provider.deleteFromTo(pos-1, pos) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos - 1) - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() e.undoStack.MergeOrAdd(&entryModifyAction{ Delete: true, Position: pos - 1, @@ -662,7 +675,7 @@ func (e *Entry) TypedKey(key *fyne.KeyEvent) { e.CursorRow = 0 } e.CursorColumn = 0 - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() case fyne.KeyPageDown: if e.MultiLine { e.CursorRow = provider.rows() - 1 @@ -670,7 +683,7 @@ func (e *Entry) TypedKey(key *fyne.KeyEvent) { } else { e.CursorColumn = provider.len() } - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() default: return } @@ -705,7 +718,7 @@ func (e *Entry) Undo() { } e.updateText(newText, false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos) - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() e.Refresh() } @@ -720,7 +733,7 @@ func (e *Entry) typedKeyUp(provider *RichText) { if e.CursorColumn > rowLength { e.CursorColumn = rowLength } - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() } func (e *Entry) typedKeyDown(provider *RichText) { @@ -736,7 +749,7 @@ func (e *Entry) typedKeyDown(provider *RichText) { if e.CursorColumn > rowLength { e.CursorColumn = rowLength } - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() } func (e *Entry) typedKeyLeft(provider *RichText) { @@ -746,7 +759,7 @@ func (e *Entry) typedKeyLeft(provider *RichText) { e.CursorRow-- e.CursorColumn = provider.rowLength(e.CursorRow) } - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() } func (e *Entry) typedKeyRight(provider *RichText) { @@ -761,7 +774,7 @@ func (e *Entry) typedKeyRight(provider *RichText) { } else if e.CursorColumn < provider.len() { e.CursorColumn++ } - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() } func (e *Entry) typedKeyHome() { @@ -824,6 +837,7 @@ func (e *Entry) TypedRune(r rune) { return } + e.syncSelectable() if e.popUp != nil { e.popUp.Hide() } @@ -843,7 +857,7 @@ func (e *Entry) TypedRune(r rune) { content := provider.String() e.updateText(content, false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes)) - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() e.undoStack.MergeOrAdd(&entryModifyAction{ Position: pos, @@ -884,7 +898,7 @@ func (e *Entry) copyToClipboard(clipboard fyne.Clipboard) { } func (e *Entry) cursorTextPos() (pos int) { - return e.sel.textPosFromRowCol(e.CursorRow, e.CursorColumn) + return textPosFromRowCol(e.CursorRow, e.CursorColumn, e.textProvider()) } // cutToClipboard copies the current selection to a given clipboard and then removes the selected text. @@ -921,7 +935,7 @@ func (e *Entry) eraseSelection() bool { erasedText := provider.deleteFromTo(posA, posB) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(posA) - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() e.sel.selectRow, e.sel.selectColumn = e.CursorRow, e.CursorColumn e.sel.selecting = false @@ -945,6 +959,7 @@ func (e *Entry) eraseSelectionAndUpdate() { // pasteFromClipboard inserts text from the clipboard content, // starting from the cursor position. func (e *Entry) pasteFromClipboard(clipboard fyne.Clipboard) { + e.syncSelectable() text := clipboard.Content() if text == "" { changed := e.sel.selecting && e.eraseSelection() @@ -977,7 +992,7 @@ func (e *Entry) pasteFromClipboard(clipboard fyne.Clipboard) { content := provider.String() e.updateText(content, false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes)) - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() cb := e.OnChanged e.validate() @@ -1061,7 +1076,7 @@ func (e *Entry) registerShortcut() { e.CursorColumn = end } } - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() }) } selectMoveWord := func(se fyne.Shortcut) { @@ -1152,7 +1167,7 @@ func (e *Entry) selectAll() { lastRow := e.textProvider().rows() - 1 e.CursorColumn = e.textProvider().rowLength(lastRow) e.CursorRow = lastRow - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() e.sel.selecting = true }) } @@ -1203,14 +1218,14 @@ func (e *Entry) selectingKeyHandler(key *fyne.KeyEvent) bool { // seek to the start of the selection -- return handled selectStart, _ := e.sel.selection() e.CursorRow, e.CursorColumn = e.rowColFromTextPos(selectStart) - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() e.sel.selecting = false return true case fyne.KeyRight: // seek to the end of the selection -- return handled _, selectEnd := e.sel.selection() e.CursorRow, e.CursorColumn = e.rowColFromTextPos(selectEnd) - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() e.sel.selecting = false return true case fyne.KeyUp, fyne.KeyDown, fyne.KeyEnd, fyne.KeyHome, fyne.KeyPageUp, fyne.KeyPageDown: @@ -1254,6 +1269,15 @@ func (e *Entry) syncSegments() { textSegment.Text = e.PlaceHolder } +func (e *Entry) syncSelectable() { + if e.sel == nil { + e.sel = &selectable{theme: e.Theme(), provider: e.textProvider(), password: e.Password, style: e.TextStyle} + e.sel.ExtendBaseWidget(e.sel) + } + + e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn +} + // textProvider returns the text handler for this entry func (e *Entry) textProvider() *RichText { if len(e.text.Segments) > 0 { @@ -1286,7 +1310,8 @@ func (e *Entry) textWrap() fyne.TextWrap { func (e *Entry) updateCursorAndSelection() { e.CursorRow, e.CursorColumn = e.truncatePosition(e.CursorRow, e.CursorColumn) - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + + e.syncSelectable() e.sel.selectRow, e.sel.selectColumn = e.truncatePosition(e.sel.selectRow, e.sel.selectColumn) } @@ -1331,7 +1356,7 @@ func (e *Entry) updateMousePointer(p fyne.Position, rightClick bool) { e.CursorRow = row e.CursorColumn = col - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() } if !e.sel.selecting { @@ -1427,7 +1452,7 @@ func (e *Entry) typedKeyReturn(provider *RichText, multiLine bool) { }) e.CursorColumn = 0 e.CursorRow++ - e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn + e.syncSelectable() } // Selects the row where the CursorColumn is currently positioned @@ -1527,8 +1552,9 @@ func (r *entryRenderer) Layout(size fyne.Size) { entrySize := size.Subtract(fyne.NewSize(r.trailingInset(), inputBorder*2)) entryPos := fyne.NewPos(0, inputBorder) - textPos := r.entry.sel.textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn) - selectPos := r.entry.sel.textPosFromRowCol(r.entry.sel.selectRow, r.entry.sel.selectColumn) + prov := r.entry.textProvider() + textPos := textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn, prov) + selectPos := textPosFromRowCol(r.entry.sel.selectRow, r.entry.sel.selectColumn, prov) if r.entry.Wrapping == fyne.TextWrapOff && r.entry.Scroll == widget.ScrollNone { r.entry.content.Resize(entrySize) r.entry.content.Move(entryPos) @@ -1537,7 +1563,7 @@ func (r *entryRenderer) Layout(size fyne.Size) { r.scroll.Move(entryPos) } - resizedTextPos := r.entry.sel.textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn) + resizedTextPos := textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn, prov) if textPos != resizedTextPos { r.entry.setFieldsAndRefresh(func() { r.entry.CursorRow, r.entry.CursorColumn = r.entry.rowColFromTextPos(textPos) diff --git a/widget/selectable.go b/widget/selectable.go index 036cf581aa..f868e90de8 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -171,13 +171,13 @@ func (s *selectable) selection() (int, int) { rowB, colB = s.cursorRow, s.cursorColumn } - return s.textPosFromRowCol(rowA, colA), s.textPosFromRowCol(rowB, colB) + return textPosFromRowCol(rowA, colA, s.provider), textPosFromRowCol(rowB, colB, s.provider) } // Obtains textual position from a given row and col // expects a read or write lock to be held by the caller -func (s *selectable) textPosFromRowCol(row, col int) int { - b := s.provider.rowBoundary(row) +func textPosFromRowCol(row, col int, prov *RichText) int { + b := prov.rowBoundary(row) if b == nil { return col } @@ -209,8 +209,6 @@ func (r *selectableRenderer) MinSize() fyne.Size { } func (r *selectableRenderer) Objects() []fyne.CanvasObject { - r.buildSelection() - return r.sel.selections } diff --git a/widget/testdata/entry/select_add_selection.xml b/widget/testdata/entry/select_add_selection.xml index b0324bcc2a..f8c6d59397 100644 --- a/widget/testdata/entry/select_add_selection.xml +++ b/widget/testdata/entry/select_add_selection.xml @@ -4,7 +4,9 @@ - + + + Testing diff --git a/widget/testdata/entry/select_all_selected.xml b/widget/testdata/entry/select_all_selected.xml index fd8313d980..c1ac1dbe08 100644 --- a/widget/testdata/entry/select_all_selected.xml +++ b/widget/testdata/entry/select_all_selected.xml @@ -5,9 +5,11 @@ - - - + + + + + First Row Second Row diff --git a/widget/testdata/entry/select_multi_line_shift_pagedown.xml b/widget/testdata/entry/select_multi_line_shift_pagedown.xml index afdcb2b55b..c4656dc34d 100644 --- a/widget/testdata/entry/select_multi_line_shift_pagedown.xml +++ b/widget/testdata/entry/select_multi_line_shift_pagedown.xml @@ -5,9 +5,11 @@ - - - + + + + + Testing Testing diff --git a/widget/testdata/entry/select_multi_line_shift_pageup.xml b/widget/testdata/entry/select_multi_line_shift_pageup.xml index 5874107738..e42eea73f8 100644 --- a/widget/testdata/entry/select_multi_line_shift_pageup.xml +++ b/widget/testdata/entry/select_multi_line_shift_pageup.xml @@ -5,7 +5,9 @@ - + + + Testing Testing diff --git a/widget/testdata/entry/select_select_left.xml b/widget/testdata/entry/select_select_left.xml index bd4066311b..3bcbf4aaa5 100644 --- a/widget/testdata/entry/select_select_left.xml +++ b/widget/testdata/entry/select_select_left.xml @@ -4,7 +4,9 @@ - + + + Testing diff --git a/widget/testdata/entry/select_selected.xml b/widget/testdata/entry/select_selected.xml index 728b2a5fba..66b5c8cc9f 100644 --- a/widget/testdata/entry/select_selected.xml +++ b/widget/testdata/entry/select_selected.xml @@ -4,7 +4,9 @@ - + + + Testing diff --git a/widget/testdata/entry/select_single_line_shift_pagedown.xml b/widget/testdata/entry/select_single_line_shift_pagedown.xml index 06b0504606..8c49e29061 100644 --- a/widget/testdata/entry/select_single_line_shift_pagedown.xml +++ b/widget/testdata/entry/select_single_line_shift_pagedown.xml @@ -4,7 +4,9 @@ - + + + Testing diff --git a/widget/testdata/entry/select_single_line_shift_pageup.xml b/widget/testdata/entry/select_single_line_shift_pageup.xml index 3881516b5e..ec53a4709b 100644 --- a/widget/testdata/entry/select_single_line_shift_pageup.xml +++ b/widget/testdata/entry/select_single_line_shift_pageup.xml @@ -4,7 +4,9 @@ - + + + Testing diff --git a/widget/testdata/entry/selection_add_one_row_down.xml b/widget/testdata/entry/selection_add_one_row_down.xml index fb5bb84322..4d24ab1f12 100644 --- a/widget/testdata/entry/selection_add_one_row_down.xml +++ b/widget/testdata/entry/selection_add_one_row_down.xml @@ -5,8 +5,10 @@ - - + + + + Testing Testing diff --git a/widget/testdata/entry/selection_add_to_end.xml b/widget/testdata/entry/selection_add_to_end.xml index 4372d76554..7c7f19ad1f 100644 --- a/widget/testdata/entry/selection_add_to_end.xml +++ b/widget/testdata/entry/selection_add_to_end.xml @@ -5,7 +5,9 @@ - + + + Testing Testing diff --git a/widget/testdata/entry/selection_add_to_home.xml b/widget/testdata/entry/selection_add_to_home.xml index 9946bb4f71..3402c34d86 100644 --- a/widget/testdata/entry/selection_add_to_home.xml +++ b/widget/testdata/entry/selection_add_to_home.xml @@ -5,7 +5,9 @@ - + + + Testing Testing diff --git a/widget/testdata/entry/selection_delete_and_add_down.xml b/widget/testdata/entry/selection_delete_and_add_down.xml index 9de85112ff..78eaa871db 100644 --- a/widget/testdata/entry/selection_delete_and_add_down.xml +++ b/widget/testdata/entry/selection_delete_and_add_down.xml @@ -5,8 +5,10 @@ - - + + + + Testing Teng diff --git a/widget/testdata/entry/selection_delete_and_add_up.xml b/widget/testdata/entry/selection_delete_and_add_up.xml index dd40f74d29..aaab9fa859 100644 --- a/widget/testdata/entry/selection_delete_and_add_up.xml +++ b/widget/testdata/entry/selection_delete_and_add_up.xml @@ -5,8 +5,10 @@ - - + + + + Testing Teng diff --git a/widget/testdata/entry/selection_focus_gained.xml b/widget/testdata/entry/selection_focus_gained.xml index 15a8af57f6..d22b9f41a6 100644 --- a/widget/testdata/entry/selection_focus_gained.xml +++ b/widget/testdata/entry/selection_focus_gained.xml @@ -5,7 +5,9 @@ - + + + Testing Testing diff --git a/widget/testdata/entry/selection_initial.xml b/widget/testdata/entry/selection_initial.xml index 15a8af57f6..d22b9f41a6 100644 --- a/widget/testdata/entry/selection_initial.xml +++ b/widget/testdata/entry/selection_initial.xml @@ -5,7 +5,9 @@ - + + + Testing Testing diff --git a/widget/testdata/entry/selection_initial_reverse.xml b/widget/testdata/entry/selection_initial_reverse.xml index 79723f5a5d..38eb36126e 100644 --- a/widget/testdata/entry/selection_initial_reverse.xml +++ b/widget/testdata/entry/selection_initial_reverse.xml @@ -5,7 +5,9 @@ - + + + Testing Testing diff --git a/widget/testdata/entry/selection_remove_add_one_row_up.xml b/widget/testdata/entry/selection_remove_add_one_row_up.xml index 447e5e4abb..4bb549162d 100644 --- a/widget/testdata/entry/selection_remove_add_one_row_up.xml +++ b/widget/testdata/entry/selection_remove_add_one_row_up.xml @@ -5,8 +5,10 @@ - - + + + + Testing Testing diff --git a/widget/testdata/entry/selection_remove_one_row_up.xml b/widget/testdata/entry/selection_remove_one_row_up.xml index 15a8af57f6..d22b9f41a6 100644 --- a/widget/testdata/entry/selection_remove_one_row_up.xml +++ b/widget/testdata/entry/selection_remove_one_row_up.xml @@ -5,7 +5,9 @@ - + + + Testing Testing From 838fb063dca759969d2412b0313829293e2d5728 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Fri, 21 Feb 2025 22:47:47 +0000 Subject: [PATCH 06/21] Tests pass but need to fix refresh on Selectable change --- widget/label.go | 5 +++-- widget/label_extend_test.go | 3 +-- widget/label_test.go | 6 +++--- widget/testdata/label/default.xml | 8 +++----- widget/testdata/label/truncate.xml | 8 +++----- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/widget/label.go b/widget/label.go index 2d143a98a7..345a2382bd 100644 --- a/widget/label.go +++ b/widget/label.go @@ -40,7 +40,7 @@ func NewLabel(text string) *Label { return NewLabelWithStyle(text, fyne.TextAlignLeading, fyne.TextStyle{}) } -// NewLabelWithData returns an Label widget connected to the specified data source. +// NewLabelWithData returns a Label widget connected to the specified data source. // // Since: 2.0 func NewLabelWithData(data binding.String) *Label { @@ -84,8 +84,9 @@ func (l *Label) CreateRenderer() fyne.WidgetRenderer { l.selection.provider = l.provider if !l.Selectable { - l.selection.Hide() + return NewSimpleRenderer(l.provider) } + return NewSimpleRenderer( &fyne.Container{Layout: layout.NewStackLayout(), Objects: []fyne.CanvasObject{l.selection, l.provider}}) diff --git a/widget/label_extend_test.go b/widget/label_extend_test.go index 36095fb502..b4836d8c66 100644 --- a/widget/label_extend_test.go +++ b/widget/label_extend_test.go @@ -5,7 +5,6 @@ import ( "github.com/stretchr/testify/assert" - "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/internal/cache" ) @@ -23,7 +22,7 @@ func newExtendedLabel(text string) *extendedLabel { func TestLabel_Extended_SetText(t *testing.T) { label := newExtendedLabel("Start") - rich := cache.Renderer(label).Objects()[0].(*fyne.Container).Objects[0].(*selectable).provider + rich := cache.Renderer(label).Objects()[0].(*RichText) objs := cache.Renderer(rich).Objects() assert.Len(t, objs, 1) assert.Equal(t, "Start", objs[0].(*canvas.Text).Text) diff --git a/widget/label_test.go b/widget/label_test.go index 959d757219..02c77810a2 100644 --- a/widget/label_test.go +++ b/widget/label_test.go @@ -127,7 +127,7 @@ func TestText_MinSize_MultiLine(t *testing.T) { textOneLine := NewLabel("Break") min := textOneLine.MinSize() textMultiLine := NewLabel("Bre\nak") - rich := test.TempWidgetRenderer(t, textMultiLine).Objects()[0].(*fyne.Container).Objects[0].(*selectable).provider + rich := test.TempWidgetRenderer(t, textMultiLine).Objects()[0].(*RichText) min2 := textMultiLine.MinSize() assert.Less(t, min2.Width, min.Width) @@ -157,7 +157,7 @@ func TestText_MinSizeAdjustsWithContent(t *testing.T) { func TestLabel_ApplyTheme(t *testing.T) { text := NewLabel("Line 1") text.Hide() - rich := test.TempWidgetRenderer(t, text).Objects()[0].(*fyne.Container).Objects[0].(*selectable).provider + rich := test.TempWidgetRenderer(t, text).Objects()[0].(*RichText) render := test.TempWidgetRenderer(t, rich).(*textRenderer) assert.Equal(t, theme.Color(theme.ColorNameForeground), render.Objects()[0].(*canvas.Text).Color) @@ -243,6 +243,6 @@ func TestLabelImportance(t *testing.T) { } func labelTextRenderTexts(p fyne.Widget) []*canvas.Text { - rich := cache.Renderer(p).Objects()[0].(*fyne.Container).Objects[0].(*selectable).provider + rich := cache.Renderer(p).Objects()[0].(*RichText) return richTextRenderTexts(rich) } diff --git a/widget/testdata/label/default.xml b/widget/testdata/label/default.xml index cb10d02856..7dc4f812cf 100644 --- a/widget/testdata/label/default.xml +++ b/widget/testdata/label/default.xml @@ -1,11 +1,9 @@ - - - Hello - - + + Hello + diff --git a/widget/testdata/label/truncate.xml b/widget/testdata/label/truncate.xml index 0140afda83..16b90c1c01 100644 --- a/widget/testdata/label/truncate.xml +++ b/widget/testdata/label/truncate.xml @@ -1,11 +1,9 @@ - - - Hel - - + + Hel + From 87f75aeb64c37a4be0030101db35665297df923f Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Fri, 21 Feb 2025 22:50:00 +0000 Subject: [PATCH 07/21] Correct usage of API in test --- widget/entry_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/widget/entry_test.go b/widget/entry_test.go index 0d7ba2c933..3237e327a6 100644 --- a/widget/entry_test.go +++ b/widget/entry_test.go @@ -410,6 +410,7 @@ func TestEntry_EmptySelection(t *testing.T) { // manually setting to empty selection typeKeys(entry, keyShiftLeftDown, fyne.KeyRight) entry.CursorColumn = 1 + entry.Refresh() assert.Equal(t, "", entry.SelectedText()) } From 8b646ed6dbd87d21ecfd90caa112fbe67e6b6e5f Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sat, 22 Feb 2025 10:39:10 +0000 Subject: [PATCH 08/21] Fix for mobile usage too --- widget/entry.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/widget/entry.go b/widget/entry.go index d3bd56c58f..fa4715bc08 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -217,8 +217,8 @@ func (e *Entry) Cursor() desktop.Cursor { // DoubleTapped is called when this entry has been double tapped so we should select text below the pointer // // Implements: fyne.DoubleTappable -func (e *Entry) DoubleTapped(p *fyne.PointEvent) { - e.requestFocus() +func (e *Entry) DoubleTapped(_ *fyne.PointEvent) { + e.focused = true e.syncSelectable() e.doubleTappedAtUnixMillis = time.Now().UnixMilli() row := e.textProvider().row(e.CursorRow) From 5d36e33cc9e54fdae31b05cb4763652440b3744f Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sun, 23 Feb 2025 18:09:09 +0000 Subject: [PATCH 09/21] Add label selection test --- widget/label_test.go | 18 ++++++++++++++++++ widget/selectable.go | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/widget/label_test.go b/widget/label_test.go index 02c77810a2..1593df2bf7 100644 --- a/widget/label_test.go +++ b/widget/label_test.go @@ -6,6 +6,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/internal/painter/software" "fyne.io/fyne/v2/test" @@ -198,6 +199,23 @@ func TestLabel_ChangeTruncate(t *testing.T) { test.AssertRendersToMarkup(t, "label/truncate.xml", c) } +func TestLabel_Select(t *testing.T) { + l := NewLabel("Hello") + l.Selectable = true + + assert.Empty(t, l.SelectedText()) + + sel := test.WidgetRenderer(l).Objects()[0].(*fyne.Container).Objects[0].(*selectable) + sel.MouseDown(&desktop.MouseEvent{Button: desktop.MouseButtonPrimary, + PointEvent: fyne.PointEvent{Position: fyne.NewPos(15, 10)}}) + sel.Dragged(&fyne.DragEvent{Dragged: fyne.Delta{DX: 15, DY: 0}, + PointEvent: fyne.PointEvent{Position: fyne.NewPos(30, 10)}}) + sel.DragEnd() + sel.MouseUp(&desktop.MouseEvent{Button: desktop.MouseButtonPrimary, + PointEvent: fyne.PointEvent{Position: fyne.NewPos(30, 10)}}) + assert.Equal(t, "el", l.SelectedText()) +} + func TestNewLabelWithData(t *testing.T) { str := binding.NewString() str.Set("Init") diff --git a/widget/selectable.go b/widget/selectable.go index f868e90de8..8c1b1e9c09 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -98,7 +98,7 @@ func (s *selectable) MouseUp(ev *desktop.MouseEvent) { // SelectedText returns the text currently selected in this Entry. // If there is no selection it will return the empty string. func (s *selectable) SelectedText() string { - if !s.selecting { + if s == nil || !s.selecting { return "" } From ed12776b8cb087bb2421668be4b071142d7986ed Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Mon, 24 Feb 2025 07:34:08 +0000 Subject: [PATCH 10/21] Adding copy shortcut --- test/app.go | 5 +++-- widget/label_test.go | 3 +++ widget/selectable.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/test/app.go b/test/app.go index 82c449860a..4c777f0987 100644 --- a/test/app.go +++ b/test/app.go @@ -27,6 +27,7 @@ type app struct { propertyLock sync.RWMutex storage fyne.Storage lifecycle intapp.Lifecycle + clip fyne.Clipboard cloud fyne.CloudProvider // user action variables @@ -64,7 +65,7 @@ func (a *app) Quit() { } func (a *app) Clipboard() fyne.Clipboard { - return NewClipboard() + return a.clip } func (a *app) UniqueID() string { @@ -166,7 +167,7 @@ func NewApp() fyne.App { settings := &testSettings{scale: 1.0, theme: Theme()} prefs := internal.NewInMemoryPreferences() store := &testStorage{} - test := &app{settings: settings, prefs: prefs, storage: store, driver: NewDriver().(*driver)} + test := &app{settings: settings, prefs: prefs, storage: store, driver: NewDriver().(*driver), clip: NewClipboard()} settings.app = test root, _ := store.docRootURI() store.Docs = &internal.Docs{RootDocURI: root} diff --git a/widget/label_test.go b/widget/label_test.go index 1593df2bf7..a6244dd552 100644 --- a/widget/label_test.go +++ b/widget/label_test.go @@ -214,6 +214,9 @@ func TestLabel_Select(t *testing.T) { sel.MouseUp(&desktop.MouseEvent{Button: desktop.MouseButtonPrimary, PointEvent: fyne.PointEvent{Position: fyne.NewPos(30, 10)}}) assert.Equal(t, "el", l.SelectedText()) + + sel.TypedShortcut(&fyne.ShortcutCopy{}) + assert.Equal(t, "el", fyne.CurrentApp().Clipboard().Content()) } func TestNewLabelWithData(t *testing.T) { diff --git a/widget/selectable.go b/widget/selectable.go index 8c1b1e9c09..5c5be2c8f0 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -59,11 +59,24 @@ func (s *selectable) Dragged(d *fyne.DragEvent) { s.Refresh() } +func (s *selectable) FocusGained() { + // no difference visually +} + +func (s *selectable) FocusLost() { + // no difference visually +} + func (s *selectable) MouseDown(m *desktop.MouseEvent) { //if e.isTripleTap(time.Now().UnixMilli()) { // e.selectCurrentRow() // return //} + if !fyne.CurrentDevice().IsMobile() { + if c := fyne.CurrentApp().Driver().CanvasForObject(s); c != nil { + c.Focus(s) // ready for copy shortcut + } + } if s.selecting && m.Button == desktop.MouseButtonPrimary { s.selecting = false } @@ -110,6 +123,21 @@ func (s *selectable) SelectedText() string { return string(r[start:stop]) } +func (s *selectable) TypedRune(rune) { + // read-only +} + +func (s *selectable) TypedKey(*fyne.KeyEvent) { + // read-only +} + +func (s *selectable) TypedShortcut(sh fyne.Shortcut) { + switch sh.(type) { + case *fyne.ShortcutCopy: + fyne.CurrentApp().Clipboard().SetContent(s.SelectedText()) + } +} + func (s *selectable) cursorColAt(text []rune, pos fyne.Position) int { th := s.theme textSize := th.Size(theme.SizeNameText) From 6aaf68d42c1c1b15cb0713a598e3bc71096f3640 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Mon, 24 Feb 2025 07:48:16 +0000 Subject: [PATCH 11/21] Adding the correct toggling of selectable in label --- widget/label.go | 51 +++++++++++++++++++++++++++++++++----------- widget/label_test.go | 8 ++++++- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/widget/label.go b/widget/label.go index 345a2382bd..644d133248 100644 --- a/widget/label.go +++ b/widget/label.go @@ -3,7 +3,6 @@ package widget import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/data/binding" - "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" ) @@ -83,13 +82,7 @@ func (l *Label) CreateRenderer() fyne.WidgetRenderer { l.selection.theme = l.Theme() l.selection.provider = l.provider - if !l.Selectable { - return NewSimpleRenderer(l.provider) - } - - return NewSimpleRenderer( - &fyne.Container{Layout: layout.NewStackLayout(), - Objects: []fyne.CanvasObject{l.selection, l.provider}}) + return &labelRenderer{l} } // MinSize returns the size that this label should not shrink below. @@ -110,11 +103,6 @@ func (l *Label) Refresh() { l.syncSegments() l.provider.Refresh() l.BaseWidget.Refresh() - - l.selection.Hidden = !l.Selectable - l.selection.style = l.TextStyle - l.selection.theme = l.Theme() - l.selection.Refresh() } // SelectedText returns the text currently selected in this Label. @@ -123,6 +111,10 @@ func (l *Label) Refresh() { // // Since: 2.6 func (l *Label) SelectedText() string { + if l.Selectable == false { + return "" + } + return l.selection.SelectedText() } @@ -185,3 +177,36 @@ func (l *Label) updateFromData(data binding.DataItem) { } l.SetText(val) } + +type labelRenderer struct { + l *Label +} + +func (r *labelRenderer) Destroy() { +} + +func (r *labelRenderer) Layout(s fyne.Size) { + r.l.selection.Resize(s) + r.l.provider.Resize(s) +} + +func (r *labelRenderer) MinSize() fyne.Size { + return r.l.provider.MinSize() +} + +func (r *labelRenderer) Objects() []fyne.CanvasObject { + if !r.l.Selectable { + return []fyne.CanvasObject{r.l.provider} + } + + return []fyne.CanvasObject{r.l.selection, r.l.provider} +} + +func (r *labelRenderer) Refresh() { + r.l.provider.Refresh() + + sel := r.l.selection + sel.style = r.l.TextStyle + sel.theme = r.l.Theme() + sel.Refresh() +} diff --git a/widget/label_test.go b/widget/label_test.go index a6244dd552..caa8e73806 100644 --- a/widget/label_test.go +++ b/widget/label_test.go @@ -204,8 +204,9 @@ func TestLabel_Select(t *testing.T) { l.Selectable = true assert.Empty(t, l.SelectedText()) + assert.Equal(t, 2, len(test.WidgetRenderer(l).Objects())) - sel := test.WidgetRenderer(l).Objects()[0].(*fyne.Container).Objects[0].(*selectable) + sel := test.WidgetRenderer(l).Objects()[0].(*selectable) sel.MouseDown(&desktop.MouseEvent{Button: desktop.MouseButtonPrimary, PointEvent: fyne.PointEvent{Position: fyne.NewPos(15, 10)}}) sel.Dragged(&fyne.DragEvent{Dragged: fyne.Delta{DX: 15, DY: 0}, @@ -217,6 +218,11 @@ func TestLabel_Select(t *testing.T) { sel.TypedShortcut(&fyne.ShortcutCopy{}) assert.Equal(t, "el", fyne.CurrentApp().Clipboard().Content()) + + l.Selectable = false + l.Refresh() + assert.Equal(t, 1, len(test.WidgetRenderer(l).Objects())) + assert.Empty(t, l.SelectedText()) } func TestNewLabelWithData(t *testing.T) { From 44973dad96c7d6a4818a2823d4d9a31779e40e84 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Mon, 24 Feb 2025 08:12:13 +0000 Subject: [PATCH 12/21] Double and triple tap for label selection --- widget/entry.go | 36 +++++++++--------------------- widget/entry_internal_test.go | 4 ++-- widget/label_test.go | 28 +++++++++++++++++++++++ widget/selectable.go | 42 +++++++++++++++++++++++++++++++---- 4 files changed, 78 insertions(+), 32 deletions(-) diff --git a/widget/entry.go b/widget/entry.go index fa4715bc08..bfbc1d3362 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -95,10 +95,6 @@ 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. @@ -220,7 +216,7 @@ func (e *Entry) Cursor() desktop.Cursor { func (e *Entry) DoubleTapped(_ *fyne.PointEvent) { e.focused = true e.syncSelectable() - e.doubleTappedAtUnixMillis = time.Now().UnixMilli() + e.sel.doubleTappedAtUnixMillis = time.Now().UnixMilli() row := e.textProvider().row(e.CursorRow) start, end := getTextWhitespaceRegion(row, e.CursorColumn, false) if start == -1 || end == -1 { @@ -244,10 +240,6 @@ func (e *Entry) DoubleTapped(_ *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 @@ -382,8 +374,10 @@ func (e *Entry) MouseDown(m *desktop.MouseEvent) { e.requestFocus() e.syncSelectable() - if e.isTripleTap(time.Now().UnixMilli()) { - e.selectCurrentRow() + if isTripleTap(e.sel.doubleTappedAtUnixMillis, time.Now().UnixMilli()) { + e.sel.selectCurrentRow() + e.CursorColumn = e.sel.cursorColumn + e.Refresh() return } if e.selectKeyDown { @@ -579,11 +573,14 @@ func (e *Entry) TappedSecondary(pe *fyne.PointEvent) { // Implements: mobile.Touchable func (e *Entry) TouchDown(ev *mobile.TouchEvent) { now := time.Now().UnixMilli() + e.syncSegments() if !e.Disabled() { e.requestFocus() } - if e.isTripleTap(now) { - e.selectCurrentRow() + if isTripleTap(e.sel.doubleTappedAtUnixMillis, now) { + e.sel.selectCurrentRow() + e.CursorColumn = e.sel.cursorColumn + e.Refresh() return } @@ -1455,19 +1452,6 @@ func (e *Entry) typedKeyReturn(provider *RichText, multiLine bool) { e.syncSelectable() } -// Selects the row where the CursorColumn is currently positioned -func (e *Entry) selectCurrentRow() { - provider := e.textProvider() - e.sel.selectRow = e.CursorRow - e.sel.selectColumn = 0 - if e.MultiLine { - e.CursorColumn = provider.rowLength(e.CursorRow) - } else { - e.CursorColumn = provider.len() - } - e.Refresh() -} - func (e *Entry) setFieldsAndRefresh(f func()) { f() diff --git a/widget/entry_internal_test.go b/widget/entry_internal_test.go index daf746ae9f..55f51eb22f 100644 --- a/widget/entry_internal_test.go +++ b/widget/entry_internal_test.go @@ -42,7 +42,7 @@ 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 + entry.sel.doubleTappedAtUnixMillis = 0 // make sure we don't register a triple tap next // select the whitespace after 'quick' ev = getClickPosition("The quick", 0) @@ -50,7 +50,7 @@ func TestEntry_DoubleTapped(t *testing.T) { entry.DoubleTapped(ev) assert.Equal(t, " ", entry.SelectedText()) - entry.doubleTappedAtUnixMillis = 0 + entry.sel.doubleTappedAtUnixMillis = 0 // select all whitespace after 'jumped' ev = getClickPosition("jumped ", 1) diff --git a/widget/label_test.go b/widget/label_test.go index caa8e73806..e7c616f75f 100644 --- a/widget/label_test.go +++ b/widget/label_test.go @@ -225,6 +225,34 @@ func TestLabel_Select(t *testing.T) { assert.Empty(t, l.SelectedText()) } +func TestLabel_SelectWord(t *testing.T) { + l := NewLabel("Hello") + l.Selectable = true + + assert.Empty(t, l.SelectedText()) + + sel := test.WidgetRenderer(l).Objects()[0].(*selectable) + sel.DoubleTapped(&fyne.PointEvent{Position: fyne.NewPos(15, 10)}) + assert.Equal(t, "Hello", l.SelectedText()) +} + +func TestLabel_SelectLine(t *testing.T) { + l := NewLabel("Longer line") + l.Selectable = true + + assert.Empty(t, l.SelectedText()) + + sel := test.WidgetRenderer(l).Objects()[0].(*selectable) + pointEvent := fyne.PointEvent{Position: fyne.NewPos(15, 10)} + tapEvent := &desktop.MouseEvent{Button: desktop.MouseButtonPrimary, + PointEvent: pointEvent} + sel.DoubleTapped(&pointEvent) + sel.MouseDown(tapEvent) + sel.MouseUp(tapEvent) + + assert.Equal(t, "Longer line", l.SelectedText()) +} + func TestNewLabelWithData(t *testing.T) { str := binding.NewString() str.Set("Init") diff --git a/widget/selectable.go b/widget/selectable.go index 5c5be2c8f0..d22f2b6b54 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -2,6 +2,7 @@ package widget import ( "math" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -27,6 +28,10 @@ type selectable struct { // TODO maybe render? selections []fyne.CanvasObject + + // 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 } func (s *selectable) CreateRenderer() fyne.WidgetRenderer { @@ -37,6 +42,22 @@ func (s *selectable) Cursor() desktop.Cursor { return desktop.TextCursor } +func (s *selectable) DoubleTapped(_ *fyne.PointEvent) { + s.doubleTappedAtUnixMillis = time.Now().UnixMilli() + row := s.provider.row(s.cursorRow) + start, end := getTextWhitespaceRegion(row, s.cursorColumn, false) + if start == -1 || end == -1 { + return + } + + s.selectRow = s.cursorRow + s.selectColumn = start + s.cursorColumn = end + + s.selecting = true + s.Refresh() +} + func (s *selectable) DragEnd() { if s.cursorColumn == s.selectColumn && s.cursorRow == s.selectRow { s.selecting = false @@ -68,10 +89,10 @@ func (s *selectable) FocusLost() { } func (s *selectable) MouseDown(m *desktop.MouseEvent) { - //if e.isTripleTap(time.Now().UnixMilli()) { - // e.selectCurrentRow() - // return - //} + if isTripleTap(s.doubleTappedAtUnixMillis, time.Now().UnixMilli()) { + s.selectCurrentRow() + return + } if !fyne.CurrentDevice().IsMobile() { if c := fyne.CurrentApp().Driver().CanvasForObject(s); c != nil { c.Focus(s) // ready for copy shortcut @@ -174,6 +195,15 @@ func (s *selectable) getRowCol(p fyne.Position) (int, int) { return row, col } +// Selects the row where the cursorColumn is currently positioned +func (s *selectable) selectCurrentRow() { + provider := s.provider + s.selectRow = s.cursorRow + s.selectColumn = 0 + s.cursorColumn = provider.rowLength(s.cursorRow) + s.Refresh() +} + // selection returns the start and end text positions for the selected span of text // Note: this functionality depends on the relationship between the selection start row/col and // the current cursor row/column. @@ -340,3 +370,7 @@ func (r *selectableRenderer) buildSelection() { r.sel.selections[i].Move(fyne.NewPos(x1-1, y1)) } } + +func isTripleTap(double, nowMilli int64) bool { + return nowMilli-double <= fyne.CurrentApp().Driver().DoubleTapDelay().Milliseconds() +} From 199ff635fc7b1ce8b5e4571f9c739ef3e70cf0ae Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Mon, 24 Feb 2025 17:28:54 +0100 Subject: [PATCH 13/21] Fix lint suggestion --- widget/label.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widget/label.go b/widget/label.go index 644d133248..3606100036 100644 --- a/widget/label.go +++ b/widget/label.go @@ -111,7 +111,7 @@ func (l *Label) Refresh() { // // Since: 2.6 func (l *Label) SelectedText() string { - if l.Selectable == false { + if !l.Selectable { return "" } From 41c20d63e45c15191e9c81a19fd6639f6b34d110 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Tue, 25 Feb 2025 16:15:55 +0100 Subject: [PATCH 14/21] Remove lines for code review --- widget/entry.go | 2 -- widget/selectable.go | 2 -- 2 files changed, 4 deletions(-) diff --git a/widget/entry.go b/widget/entry.go index bfbc1d3362..95430b6723 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -101,8 +101,6 @@ type Entry struct { func NewEntry() *Entry { e := &Entry{Wrapping: fyne.TextWrap(fyne.TextTruncateClip)} e.ExtendBaseWidget(e) - - e.syncSelectable() return e } diff --git a/widget/selectable.go b/widget/selectable.go index d22f2b6b54..1715e8d9e8 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -119,7 +119,6 @@ func (s *selectable) MouseUp(ev *desktop.MouseEvent) { fyne.CurrentApp().Clipboard().SetContent(s.SelectedText()) })) ShowPopUpMenuAtPosition(m, c, ev.AbsolutePosition) - return } @@ -306,7 +305,6 @@ func (r *selectableRenderer) buildSelection() { if selectRow == -1 || (cursorRow == selectRow && cursorCol == selectCol) { r.sel.selections = r.sel.selections[:0] - return } From f0db555356d01d6da1251a5c61fc48970495ece6 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Wed, 26 Feb 2025 09:01:38 +0100 Subject: [PATCH 15/21] Update nil check and early return --- widget/label.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/widget/label.go b/widget/label.go index 3606100036..6b58dc43c6 100644 --- a/widget/label.go +++ b/widget/label.go @@ -111,7 +111,7 @@ func (l *Label) Refresh() { // // Since: 2.6 func (l *Label) SelectedText() string { - if !l.Selectable { + if !l.Selectable || l.selection == nil { return "" } @@ -206,6 +206,10 @@ func (r *labelRenderer) Refresh() { r.l.provider.Refresh() sel := r.l.selection + if !r.l.Selectable || sel == nil { + return + } + sel.style = r.l.TextStyle sel.theme = r.l.Theme() sel.Refresh() From b7e700c34ff0b5b1e896b2430c4f00ba3d59e0e2 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Wed, 26 Feb 2025 09:39:50 +0100 Subject: [PATCH 16/21] Hide selections when we are not focussed. --- widget/selectable.go | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/widget/selectable.go b/widget/selectable.go index 1715e8d9e8..9f620e7eef 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -20,8 +20,8 @@ type selectable struct { // position may occur before or after the select start position in the text. selectRow, selectColumn int - selecting, password bool - style fyne.TextStyle + focussed, selecting, password bool + style fyne.TextStyle provider *RichText theme fyne.Theme @@ -81,11 +81,13 @@ func (s *selectable) Dragged(d *fyne.DragEvent) { } func (s *selectable) FocusGained() { - // no difference visually + s.focussed = true + s.Refresh() } func (s *selectable) FocusLost() { - // no difference visually + s.focussed = false + s.Refresh() } func (s *selectable) MouseDown(m *desktop.MouseEvent) { @@ -126,6 +128,7 @@ func (s *selectable) MouseUp(ev *desktop.MouseEvent) { if start == -1 && s.selecting { s.selecting = false } + s.Refresh() } // SelectedText returns the text currently selected in this Entry. @@ -143,6 +146,11 @@ func (s *selectable) SelectedText() string { return string(r[start:stop]) } +func (s *selectable) Tapped(*fyne.PointEvent) { + s.selecting = false + s.Refresh() +} + func (s *selectable) TypedRune(rune) { // read-only } @@ -271,7 +279,6 @@ func (r *selectableRenderer) Objects() []fyne.CanvasObject { func (r *selectableRenderer) Refresh() { r.buildSelection() - selections := r.sel.selections v := fyne.CurrentApp().Settings().ThemeVariant() @@ -279,6 +286,12 @@ func (r *selectableRenderer) Refresh() { for _, selection := range selections { rect := selection.(*canvas.Rectangle) rect.FillColor = selectionColor + + if r.sel.focussed { + rect.Show() + } else { + rect.Hide() + } } canvas.Refresh(r.sel) From 026c5036206d90f0a8937be7aa9cf9417496bff1 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Wed, 26 Feb 2025 14:35:56 +0100 Subject: [PATCH 17/21] Complete selectable work to be fully mobile compatible --- widget/entry.go | 6 +-- widget/selectable.go | 90 ++++++++++++++++++++++++++++++++------------ 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/widget/entry.go b/widget/entry.go index 95430b6723..e059c2fa42 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -255,7 +255,7 @@ func (e *Entry) DragEnd() { // Implements: fyne.Draggable func (e *Entry) Dragged(d *fyne.DragEvent) { d.Position = d.Position.Add(fyne.NewPos(0, e.Theme().Size(theme.SizeNameInputBorder))) - e.sel.Dragged(d) + e.sel.dragged(d, false) e.updateMousePointer(d.Position, false) } @@ -373,7 +373,7 @@ func (e *Entry) MouseDown(m *desktop.MouseEvent) { e.syncSelectable() if isTripleTap(e.sel.doubleTappedAtUnixMillis, time.Now().UnixMilli()) { - e.sel.selectCurrentRow() + e.sel.selectCurrentRow(false) e.CursorColumn = e.sel.cursorColumn e.Refresh() return @@ -576,7 +576,7 @@ func (e *Entry) TouchDown(ev *mobile.TouchEvent) { e.requestFocus() } if isTripleTap(e.sel.doubleTappedAtUnixMillis, now) { - e.sel.selectCurrentRow() + e.sel.selectCurrentRow(false) e.CursorColumn = e.sel.cursorColumn e.Refresh() return diff --git a/widget/selectable.go b/widget/selectable.go index 9f620e7eef..d99954d451 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -7,6 +7,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/driver/mobile" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/theme" ) @@ -20,8 +21,8 @@ type selectable struct { // position may occur before or after the select start position in the text. selectRow, selectColumn int - focussed, selecting, password bool - style fyne.TextStyle + focussed, selecting, selectEnded, password bool + style fyne.TextStyle provider *RichText theme fyne.Theme @@ -42,8 +43,9 @@ func (s *selectable) Cursor() desktop.Cursor { return desktop.TextCursor } -func (s *selectable) DoubleTapped(_ *fyne.PointEvent) { +func (s *selectable) DoubleTapped(p *fyne.PointEvent) { s.doubleTappedAtUnixMillis = time.Now().UnixMilli() + s.updateMousePointer(p.Position) row := s.provider.row(s.cursorRow) start, end := getTextWhitespaceRegion(row, s.cursorColumn, false) if start == -1 || end == -1 { @@ -55,6 +57,10 @@ func (s *selectable) DoubleTapped(_ *fyne.PointEvent) { s.cursorColumn = end s.selecting = true + if c := fyne.CurrentApp().Driver().CanvasForObject(s); c != nil { + c.Focus(s) + } + s.Refresh() } @@ -67,13 +73,25 @@ func (s *selectable) DragEnd() { if shouldRefresh { s.Refresh() } + s.selectEnded = true } func (s *selectable) Dragged(d *fyne.DragEvent) { - if !s.selecting { + s.dragged(d, true) +} + +func (s *selectable) dragged(d *fyne.DragEvent, focus bool) { + if !s.selecting || s.selectEnded { + s.selectEnded = false + s.updateMousePointer(d.Position) + startPos := d.Position.Subtract(d.Dragged) s.selectRow, s.selectColumn = s.getRowCol(startPos) s.selecting = true + + if c := fyne.CurrentApp().Driver().CanvasForObject(s); c != nil && focus { + c.Focus(s) + } } s.updateMousePointer(d.Position) @@ -92,40 +110,24 @@ func (s *selectable) FocusLost() { func (s *selectable) MouseDown(m *desktop.MouseEvent) { if isTripleTap(s.doubleTappedAtUnixMillis, time.Now().UnixMilli()) { - s.selectCurrentRow() + s.selectCurrentRow(false) return } - if !fyne.CurrentDevice().IsMobile() { - if c := fyne.CurrentApp().Driver().CanvasForObject(s); c != nil { - c.Focus(s) // ready for copy shortcut - } + if c := fyne.CurrentApp().Driver().CanvasForObject(s); c != nil { + c.Focus(s) // ready for copy shortcut } if s.selecting && m.Button == desktop.MouseButtonPrimary { s.selecting = false } - - if m.Button == desktop.MouseButtonPrimary { - s.updateMousePointer(m.Position) - } } func (s *selectable) MouseUp(ev *desktop.MouseEvent) { if ev.Button == desktop.MouseButtonSecondary { - c := fyne.CurrentApp().Driver().CanvasForObject(s) - if c == nil { - return - } - - m := fyne.NewMenu("", - fyne.NewMenuItem(lang.L("Copy"), func() { - fyne.CurrentApp().Clipboard().SetContent(s.SelectedText()) - })) - ShowPopUpMenuAtPosition(m, c, ev.AbsolutePosition) return } start, _ := s.selection() - if start == -1 && s.selecting { + if (start == -1 || (s.selectRow == s.cursorRow && s.selectColumn == s.cursorColumn)) && s.selecting { s.selecting = false } s.Refresh() @@ -147,10 +149,45 @@ func (s *selectable) SelectedText() string { } func (s *selectable) Tapped(*fyne.PointEvent) { + if !fyne.CurrentDevice().IsMobile() { + return + } + + if s.doubleTappedAtUnixMillis != 0 { + s.doubleTappedAtUnixMillis = 0 + return // was a triple (TappedDouble plus Tapped) + } s.selecting = false s.Refresh() } +func (s *selectable) TappedSecondary(ev *fyne.PointEvent) { + c := fyne.CurrentApp().Driver().CanvasForObject(s) + if c == nil { + return + } + + m := fyne.NewMenu("", + fyne.NewMenuItem(lang.L("Copy"), func() { + fyne.CurrentApp().Clipboard().SetContent(s.SelectedText()) + })) + ShowPopUpMenuAtPosition(m, c, ev.AbsolutePosition) +} + +func (s *selectable) TouchCancel(m *mobile.TouchEvent) { + s.TouchUp(m) +} + +func (s *selectable) TouchDown(m *mobile.TouchEvent) { + if isTripleTap(s.doubleTappedAtUnixMillis, time.Now().UnixMilli()) { + s.selectCurrentRow(true) + return + } +} + +func (s *selectable) TouchUp(*mobile.TouchEvent) { +} + func (s *selectable) TypedRune(rune) { // read-only } @@ -203,7 +240,10 @@ func (s *selectable) getRowCol(p fyne.Position) (int, int) { } // Selects the row where the cursorColumn is currently positioned -func (s *selectable) selectCurrentRow() { +func (s *selectable) selectCurrentRow(focus bool) { + if c := fyne.CurrentApp().Driver().CanvasForObject(s); c != nil && focus { + c.Focus(s) + } provider := s.provider s.selectRow = s.cursorRow s.selectColumn = 0 From c54bc2e587dee07374aa1d6db284db9e7cdbdeb2 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Wed, 26 Feb 2025 14:37:24 +0100 Subject: [PATCH 18/21] Tidy up remaining TODO --- widget/selectable.go | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/widget/selectable.go b/widget/selectable.go index d99954d451..e97c3f489f 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -27,9 +27,6 @@ type selectable struct { provider *RichText theme fyne.Theme - // TODO maybe render? - selections []fyne.CanvasObject - // 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 @@ -301,6 +298,8 @@ func (s *selectable) updateMousePointer(p fyne.Position) { type selectableRenderer struct { sel *selectable + + selections []fyne.CanvasObject } func (r *selectableRenderer) Destroy() { @@ -314,12 +313,12 @@ func (r *selectableRenderer) MinSize() fyne.Size { } func (r *selectableRenderer) Objects() []fyne.CanvasObject { - return r.sel.selections + return r.selections } func (r *selectableRenderer) Refresh() { r.buildSelection() - selections := r.sel.selections + selections := r.selections v := fyne.CurrentApp().Settings().ThemeVariant() selectionColor := r.sel.theme.Color(theme.ColorNameSelection, v) @@ -357,7 +356,7 @@ func (r *selectableRenderer) buildSelection() { } if selectRow == -1 || (cursorRow == selectRow && cursorCol == selectCol) { - r.sel.selections = r.sel.selections[:0] + r.selections = r.selections[:0] return } @@ -391,15 +390,15 @@ func (r *selectableRenderer) buildSelection() { rowCount := selectEndRow - selectStartRow + 1 // trim r.selection to remove unwanted old rectangles - if len(r.sel.selections) > rowCount { - r.sel.selections = r.sel.selections[:rowCount] + if len(r.selections) > rowCount { + r.selections = r.selections[:rowCount] } // build a rectangle for each row and add it to r.selection for i := 0; i < rowCount; i++ { - if len(r.sel.selections) <= i { + if len(r.selections) <= i { box := canvas.NewRectangle(th.Color(theme.ColorNameSelection, v)) - r.sel.selections = append(r.sel.selections, box) + r.selections = append(r.selections, box) } // determine starting/ending columns for this rectangle @@ -417,8 +416,8 @@ func (r *selectableRenderer) buildSelection() { x2, _ := getCoordinates(endCol, row) // resize and reposition each rectangle - r.sel.selections[i].Resize(fyne.NewSize(x2-x1+1, lineHeight)) - r.sel.selections[i].Move(fyne.NewPos(x1-1, y1)) + r.selections[i].Resize(fyne.NewSize(x2-x1+1, lineHeight)) + r.selections[i].Move(fyne.NewPos(x1-1, y1)) } } From fd590b233427d8047349d41671373df5df4514ed Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Wed, 26 Feb 2025 16:20:08 +0000 Subject: [PATCH 19/21] Critical line missed the commit... --- widget/entry.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/widget/entry.go b/widget/entry.go index e059c2fa42..41cdec16d7 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -2,6 +2,7 @@ package widget import ( "image/color" + "log" "runtime" "strings" "time" @@ -272,6 +273,8 @@ func (e *Entry) FocusGained() { e.setFieldsAndRefresh(func() { e.dirty = true e.focused = true + + log.Println("FOCUS") }) if e.onFocusChanged != nil { e.onFocusChanged(true) @@ -430,6 +433,7 @@ func (e *Entry) Refresh() { if e.sel != nil { e.sel.style = e.TextStyle e.sel.theme = e.Theme() + e.sel.focussed = e.focused e.sel.Refresh() } e.BaseWidget.Refresh() From dabdf5abcdb08dd56bca0051170cdf1886f14adc Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Thu, 27 Feb 2025 12:57:57 +0000 Subject: [PATCH 20/21] Remove debug --- widget/entry.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/widget/entry.go b/widget/entry.go index 41cdec16d7..948bbc47a0 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -273,8 +273,6 @@ func (e *Entry) FocusGained() { e.setFieldsAndRefresh(func() { e.dirty = true e.focused = true - - log.Println("FOCUS") }) if e.onFocusChanged != nil { e.onFocusChanged(true) From cab213e217bb7e89b361d33a6aab2f86c2af47bf Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Thu, 27 Feb 2025 14:00:03 +0000 Subject: [PATCH 21/21] Remove dead import --- widget/entry.go | 1 - 1 file changed, 1 deletion(-) diff --git a/widget/entry.go b/widget/entry.go index 948bbc47a0..9c27f24cc6 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -2,7 +2,6 @@ package widget import ( "image/color" - "log" "runtime" "strings" "time"