widget: fix text selection and selection area rendering issues

1. When selecting multiple lines of text, the rendered selection area does not include the last character of the first line.
2. When selecting a specific line (other than the last line) in multiline text, the last character of that line cannot be selected.

Signed-off-by: CoyAce <AkeyCoy@gmail.com>
This commit is contained in:
CoyAce
2026-01-20 23:31:47 +08:00
committed by Chris Waldon
parent 3af0ebb3a8
commit 9966e922f9
3 changed files with 122 additions and 35 deletions
+53
View File
@@ -1042,6 +1042,59 @@ func TestEditorSelectShortcuts(t *testing.T) {
} }
} }
func TestEditorSelect(t *testing.T) {
tFont := font.Font{}
tFontSize := unit.Sp(10)
tShaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
tEditor := &Editor{
SingleLine: false,
ReadOnly: true,
}
lines := "abc abc abc def def def ghi ghi ghi"
tEditor.SetText(lines)
tRouter := new(input.Router)
gtx := layout.Context{
Ops: new(op.Ops),
Locale: english,
Constraints: layout.Exact(image.Pt(50, 100)),
Source: tRouter.Source(),
}
gtx.Execute(key.FocusCmd{Tag: tEditor})
tEditor.Layout(gtx, tShaper, tFont, tFontSize, op.CallOp{}, op.CallOp{})
index := tEditor.text.index
firstLineGlyphs := index.lines[0].glyphs
firstLine := lines[:firstLineGlyphs]
yOffFirstLineCenter := index.lines[0].yOff / 2
tEditor.ClearSelection()
tEditor.text.MoveCoord(image.Pt(100, yOffFirstLineCenter))
if cStart, cEnd := tEditor.Selection(); cStart != len(firstLine) || cEnd != 0 {
t.Errorf("TestEditorSelect %d: initial selection", len(firstLine))
}
if got := tEditor.SelectedText(); got != firstLine {
t.Errorf("TestEditorSelect : Expected %q, got %q", firstLine, got)
}
yOffSecondLineCenter := (index.lines[1].yOff-index.lines[0].yOff)/2 + index.lines[0].yOff
tEditor.text.MoveCoord(image.Pt(100, yOffSecondLineCenter))
secondLineGlyphs := index.lines[1].glyphs
firstTwoLines := lines[:firstLineGlyphs+secondLineGlyphs]
if got := tEditor.SelectedText(); got != firstTwoLines {
t.Errorf("TestEditorSelect : Expected %q, got %q", firstTwoLines, got)
}
tEditor.Layout(gtx, tShaper, tFont, tFontSize, op.CallOp{}, op.CallOp{})
firstLineEnd := index.lines[0].width.Round()
firstRegionMaxWidth := tEditor.text.regions[0].Bounds.Max.X
if firstRegionMaxWidth != firstLineEnd {
t.Errorf("Selection paint should contain last character")
}
}
// Verify that an existing selection is dismissed when you press arrow keys. // Verify that an existing selection is dismissed when you press arrow keys.
func TestSelectMove(t *testing.T) { func TestSelectMove(t *testing.T) {
e := new(Editor) e := new(Editor)
+54 -27
View File
@@ -22,6 +22,10 @@ type lineInfo struct {
glyphs int glyphs int
} }
func (l lineInfo) getLineEnd() fixed.Int26_6 {
return l.xOff + l.width
}
type glyphIndex struct { type glyphIndex struct {
// glyphs holds the glyphs processed. // glyphs holds the glyphs processed.
glyphs []text.Glyph glyphs []text.Glyph
@@ -231,46 +235,61 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
} }
func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) { func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) {
if len(g.positions) == 0 { n := len(g.positions)
if n == 0 {
return combinedPos{}, 0 return combinedPos{}, 0
} }
i := sort.Search(len(g.positions), func(i int) bool { i := sort.Search(n, func(i int) bool {
pos := g.positions[i] pos := g.positions[i]
return pos.runes >= runeIdx return pos.runes >= runeIdx
}) })
if i > 0 {
i-- notFound := i == n
if notFound {
return g.positions[n-1], n - 1
} }
closest := g.positions[i] return g.positions[i], i
closestI := i
for ; i < len(g.positions); i++ {
if g.positions[i].runes == runeIdx {
return g.positions[i], i
}
}
return closest, closestI
} }
func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos { func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos {
if len(g.positions) == 0 { n := len(g.positions)
if n == 0 {
return combinedPos{} return combinedPos{}
} }
i := sort.Search(len(g.positions), func(i int) bool { i := sort.Search(n, func(i int) bool {
pos := g.positions[i] pos := g.positions[i]
return pos.lineCol.line > lineCol.line || (pos.lineCol.line == lineCol.line && pos.lineCol.col >= lineCol.col) return pos.lineCol.line > lineCol.line || (pos.lineCol.line == lineCol.line && pos.lineCol.col >= lineCol.col)
}) })
if i > 0 { notFound := i == n
i-- if notFound {
return g.positions[n-1]
} }
prior := g.positions[i] pos := g.positions[i]
if i+1 >= len(g.positions) { foundInNextLine := pos.lineCol.line > lineCol.line
if foundInNextLine && i > 0 {
prior := g.positions[i-1]
prior.x = g.lines[lineCol.line].getLineEnd()
return prior return prior
} }
next := g.positions[i+1] return pos
if next.lineCol != lineCol { }
return prior
func (g *glyphIndex) atStartOfLine(pos combinedPos) bool {
if pos.runes == 0 {
return true
} }
return next prevRuneIndex := pos.runes - 1
lineOfPrevRune := g.positions[prevRuneIndex].lineCol.line
return lineOfPrevRune < pos.lineCol.line
}
func (g *glyphIndex) atEndOfLine(pos combinedPos) bool {
if pos.runes == g.positions[len(g.positions)-1].runes {
return true
}
next := pos.runes + 1
hasNext := next < len(g.positions)
return hasNext && g.positions[next].lineCol.line > pos.lineCol.line
} }
func dist(a, b fixed.Int26_6) fixed.Int26_6 { func dist(a, b fixed.Int26_6) fixed.Int26_6 {
@@ -280,9 +299,9 @@ func dist(a, b fixed.Int26_6) fixed.Int26_6 {
return b - a return b - a
} }
func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos { func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) (pos combinedPos, atEndOfLine bool) {
if len(g.positions) == 0 { if len(g.positions) == 0 {
return combinedPos{} return combinedPos{}, false
} }
i := sort.Search(len(g.positions), func(i int) bool { i := sort.Search(len(g.positions), func(i int) bool {
pos := g.positions[i] pos := g.positions[i]
@@ -292,7 +311,7 @@ func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos {
// short. Return either the last position or (if there are no // short. Return either the last position or (if there are no
// positions) the zero position. // positions) the zero position.
if i == len(g.positions) { if i == len(g.positions) {
return g.positions[i-1] return g.positions[i-1], false
} }
first := g.positions[i] first := g.positions[i]
// Find the best X coordinate. // Find the best X coordinate.
@@ -308,14 +327,22 @@ func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos {
distance := dist(candidate.x, x) distance := dist(candidate.x, x)
// If we are *really* close to the current position candidate, just choose it. // If we are *really* close to the current position candidate, just choose it.
if distance.Round() == 0 { if distance.Round() == 0 {
return g.positions[i] return g.positions[i], false
} }
if distance < closestDist { if distance < closestDist {
closestDist = distance closestDist = distance
closest = i closest = i
} }
} }
return g.positions[closest] next := closest + 1
hasNext := next < len(g.positions)
if hasNext && g.atEndOfLine(g.positions[closest]) {
distance := dist(g.lines[line].getLineEnd(), x)
if distance < closestDist {
return g.positions[next], true
}
}
return g.positions[closest], false
} }
// makeRegion creates a text-aligned rectangle from start to end. The vertical // makeRegion creates a text-aligned rectangle from start to end. The vertical
+15 -8
View File
@@ -173,14 +173,17 @@ func (e *textView) closestToLineCol(line, col int) combinedPos {
return e.index.closestToLineCol(screenPos{line: line, col: col}) return e.index.closestToLineCol(screenPos{line: line, col: col})
} }
func (e *textView) closestToXY(x fixed.Int26_6, y int) combinedPos { func (e *textView) closestToXY(x fixed.Int26_6, y int) (combinedPos, bool) {
e.makeValid() e.makeValid()
return e.index.closestToXY(x, y) return e.index.closestToXY(x, y)
} }
func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) combinedPos { func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) (combinedPos, bool) {
// Find the closest existing rune position to the provided coordinates. // Find the closest existing rune position to the provided coordinates.
pos := e.closestToXY(x, y) pos, atEndOfLine := e.closestToXY(x, y)
if atEndOfLine {
return pos, true
}
// Resolve cluster boundaries on either side of the rune position. // Resolve cluster boundaries on either side of the rune position.
firstOption := e.moveByGraphemes(pos.runes, 0) firstOption := e.moveByGraphemes(pos.runes, 0)
distance := 1 distance := 1
@@ -194,9 +197,9 @@ func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) combinedPos {
second := e.closestToRune(secondOption) second := e.closestToRune(secondOption)
secondDist := absFixed(second.x - x) secondDist := absFixed(second.x - x)
if firstDist > secondDist { if firstDist > secondDist {
return second return second, false
} else { } else {
return first return first, false
} }
} }
@@ -214,8 +217,11 @@ func (e *textView) MoveLines(distance int, selAct selectionAction) {
x := caretStart.x + e.caret.xoff x := caretStart.x + e.caret.xoff
// Seek to line. // Seek to line.
pos := e.closestToLineCol(caretStart.lineCol.line+distance, 0) pos := e.closestToLineCol(caretStart.lineCol.line+distance, 0)
pos = e.closestToXYGraphemes(x, pos.y) pos, atEndOfLine := e.closestToXYGraphemes(x, pos.y)
e.caret.start = pos.runes e.caret.start = pos.runes
if atEndOfLine && pos.runes > 0 {
e.caret.start = pos.runes - 1
}
e.caret.xoff = x - pos.x e.caret.xoff = x - pos.x
e.updateSelection(selAct) e.updateSelection(selAct)
} }
@@ -472,7 +478,8 @@ func (e *textView) scrollAbs(x, y int) {
func (e *textView) MoveCoord(pos image.Point) { func (e *textView) MoveCoord(pos image.Point) {
x := fixed.I(pos.X + e.scrollOff.X) x := fixed.I(pos.X + e.scrollOff.X)
y := pos.Y + e.scrollOff.Y y := pos.Y + e.scrollOff.Y
e.caret.start = e.closestToXYGraphemes(x, y).runes p, _ := e.closestToXYGraphemes(x, y)
e.caret.start = p.runes
e.caret.xoff = 0 e.caret.xoff = 0
} }
@@ -610,7 +617,7 @@ func (e *textView) MovePages(pages int, selAct selectionAction) {
caret := e.closestToRune(e.caret.start) caret := e.closestToRune(e.caret.start)
x := caret.x + e.caret.xoff x := caret.x + e.caret.xoff
y := caret.y + pages*e.viewSize.Y y := caret.y + pages*e.viewSize.Y
pos := e.closestToXYGraphemes(x, y) pos, _ := e.closestToXYGraphemes(x, y)
e.caret.start = pos.runes e.caret.start = pos.runes
e.caret.xoff = x - pos.x e.caret.xoff = x - pos.x
e.updateSelection(selAct) e.updateSelection(selAct)