415 lines
11 KiB
Go
415 lines
11 KiB
Go
package widget
|
|
|
|
import (
|
|
"math"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
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
|
|
|
|
focussed, selecting, selectEnded, password bool
|
|
sizeName fyne.ThemeSizeName
|
|
style fyne.TextStyle
|
|
|
|
provider *RichText
|
|
theme fyne.Theme
|
|
focus fyne.Focusable
|
|
|
|
// 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 {
|
|
return &selectableRenderer{sel: s}
|
|
}
|
|
|
|
func (s *selectable) Cursor() desktop.Cursor {
|
|
return desktop.TextCursor
|
|
}
|
|
|
|
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 {
|
|
return
|
|
}
|
|
|
|
s.selectRow = s.cursorRow
|
|
s.selectColumn = start
|
|
s.cursorColumn = end
|
|
|
|
s.selecting = true
|
|
s.grabFocus()
|
|
s.Refresh()
|
|
}
|
|
|
|
func (s *selectable) DragEnd() {
|
|
if s.cursorColumn == s.selectColumn && s.cursorRow == s.selectRow {
|
|
s.selecting = false
|
|
}
|
|
|
|
shouldRefresh := !s.selecting
|
|
if shouldRefresh {
|
|
s.Refresh()
|
|
}
|
|
s.selectEnded = true
|
|
}
|
|
|
|
func (s *selectable) Dragged(d *fyne.DragEvent) {
|
|
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
|
|
|
|
s.grabFocus()
|
|
}
|
|
|
|
s.updateMousePointer(d.Position)
|
|
s.Refresh()
|
|
}
|
|
|
|
func (s *selectable) MouseDown(m *desktop.MouseEvent) {
|
|
if isTripleTap(s.doubleTappedAtUnixMillis, time.Now().UnixMilli()) {
|
|
s.selectCurrentRow(false)
|
|
return
|
|
}
|
|
s.grabFocus()
|
|
if s.selecting && m.Button == desktop.MouseButtonPrimary {
|
|
s.selecting = false
|
|
}
|
|
}
|
|
|
|
func (s *selectable) MouseUp(ev *desktop.MouseEvent) {
|
|
if ev.Button == desktop.MouseButtonSecondary {
|
|
return
|
|
}
|
|
|
|
start, _ := s.selection()
|
|
if (start == -1 || (s.selectRow == s.cursorRow && s.selectColumn == s.cursorColumn)) && s.selecting {
|
|
s.selecting = false
|
|
}
|
|
s.Refresh()
|
|
}
|
|
|
|
// 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 == nil || !s.selecting {
|
|
return ""
|
|
}
|
|
|
|
start, stop := s.selection()
|
|
if start == stop {
|
|
return ""
|
|
}
|
|
r := ([]rune)(s.provider.String())
|
|
return string(r[start:stop])
|
|
}
|
|
|
|
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.focus.(fyne.CanvasObject))
|
|
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) 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(s.getSizeName())
|
|
innerPad := th.Size(theme.SizeNameInnerPadding)
|
|
|
|
for i := 0; i < len(text); i++ {
|
|
str := string(text[0:i])
|
|
wid := fyne.MeasureText(str, textSize, s.style).Width
|
|
charWid := fyne.MeasureText(string(text[i]), textSize, s.style).Width
|
|
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(s.getSizeName())
|
|
innerPad := th.Size(theme.SizeNameInnerPadding)
|
|
|
|
rowHeight := s.provider.charMinSize(false, s.style, textSize).Height // TODO handle Password
|
|
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
|
|
}
|
|
|
|
// Selects the row where the cursorColumn is currently positioned
|
|
func (s *selectable) selectCurrentRow(focus bool) {
|
|
s.grabFocus()
|
|
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.
|
|
// 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 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 textPosFromRowCol(row, col int, prov *RichText) int {
|
|
b := prov.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
|
|
}
|
|
}
|
|
|
|
func (s *selectable) getSizeName() fyne.ThemeSizeName {
|
|
if s.sizeName != "" {
|
|
return s.sizeName
|
|
}
|
|
return theme.SizeNameText
|
|
}
|
|
|
|
type selectableRenderer struct {
|
|
sel *selectable
|
|
|
|
selections []fyne.CanvasObject
|
|
}
|
|
|
|
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 {
|
|
return r.selections
|
|
}
|
|
|
|
func (r *selectableRenderer) Refresh() {
|
|
r.buildSelection()
|
|
selections := r.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
|
|
|
|
if r.sel.focussed {
|
|
rect.Show()
|
|
} else {
|
|
rect.Hide()
|
|
}
|
|
}
|
|
|
|
canvas.Refresh(r.sel.impl)
|
|
}
|
|
|
|
// 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(r.sel.getSizeName())
|
|
|
|
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.selections = r.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.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.selections) <= i {
|
|
box := canvas.NewRectangle(th.Color(theme.ColorNameSelection, v))
|
|
r.selections = append(r.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.selections[i].Resize(fyne.NewSize(x2-x1+1, lineHeight))
|
|
r.selections[i].Move(fyne.NewPos(x1-1, y1))
|
|
}
|
|
}
|
|
|
|
func (s *selectable) grabFocus() {
|
|
if c := fyne.CurrentApp().Driver().CanvasForObject(s.focus.(fyne.CanvasObject)); c != nil {
|
|
c.Focus(s.focus)
|
|
}
|
|
}
|
|
|
|
func isTripleTap(double, nowMilli int64) bool {
|
|
return nowMilli-double <= fyne.CurrentApp().Driver().DoubleTapDelay().Milliseconds()
|
|
}
|