From 9966e922f9f47691a1fb770405879e1aaeaa9a65 Mon Sep 17 00:00:00 2001 From: CoyAce Date: Tue, 20 Jan 2026 23:31:47 +0800 Subject: [PATCH] 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 --- widget/editor_test.go | 53 ++++++++++++++++++++++++++++ widget/index.go | 81 ++++++++++++++++++++++++++++--------------- widget/text.go | 23 +++++++----- 3 files changed, 122 insertions(+), 35 deletions(-) diff --git a/widget/editor_test.go b/widget/editor_test.go index cc8d7aec..65deb613 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -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. func TestSelectMove(t *testing.T) { e := new(Editor) diff --git a/widget/index.go b/widget/index.go index 3d8fb3d2..8db6c0e9 100644 --- a/widget/index.go +++ b/widget/index.go @@ -22,6 +22,10 @@ type lineInfo struct { glyphs int } +func (l lineInfo) getLineEnd() fixed.Int26_6 { + return l.xOff + l.width +} + type glyphIndex struct { // glyphs holds the glyphs processed. glyphs []text.Glyph @@ -231,46 +235,61 @@ func (g *glyphIndex) Glyph(gl text.Glyph) { } func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) { - if len(g.positions) == 0 { + n := len(g.positions) + if n == 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] return pos.runes >= runeIdx }) - if i > 0 { - i-- + + notFound := i == n + if notFound { + return g.positions[n-1], n - 1 } - closest := g.positions[i] - closestI := i - for ; i < len(g.positions); i++ { - if g.positions[i].runes == runeIdx { - return g.positions[i], i - } - } - return closest, closestI + return g.positions[i], i } func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos { - if len(g.positions) == 0 { + n := len(g.positions) + if n == 0 { return combinedPos{} } - i := sort.Search(len(g.positions), func(i int) bool { + i := sort.Search(n, func(i int) bool { pos := g.positions[i] return pos.lineCol.line > lineCol.line || (pos.lineCol.line == lineCol.line && pos.lineCol.col >= lineCol.col) }) - if i > 0 { - i-- + notFound := i == n + if notFound { + return g.positions[n-1] } - prior := g.positions[i] - if i+1 >= len(g.positions) { + pos := g.positions[i] + foundInNextLine := pos.lineCol.line > lineCol.line + if foundInNextLine && i > 0 { + prior := g.positions[i-1] + prior.x = g.lines[lineCol.line].getLineEnd() return prior } - next := g.positions[i+1] - if next.lineCol != lineCol { - return prior + return pos +} + +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 { @@ -280,9 +299,9 @@ func dist(a, b fixed.Int26_6) fixed.Int26_6 { 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 { - return combinedPos{} + return combinedPos{}, false } i := sort.Search(len(g.positions), func(i int) bool { 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 // positions) the zero position. if i == len(g.positions) { - return g.positions[i-1] + return g.positions[i-1], false } first := g.positions[i] // 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) // If we are *really* close to the current position candidate, just choose it. if distance.Round() == 0 { - return g.positions[i] + return g.positions[i], false } if distance < closestDist { closestDist = distance 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 diff --git a/widget/text.go b/widget/text.go index 8ddc5b28..eaf7ecae 100644 --- a/widget/text.go +++ b/widget/text.go @@ -173,14 +173,17 @@ func (e *textView) closestToLineCol(line, col int) combinedPos { 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() 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. - 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. firstOption := e.moveByGraphemes(pos.runes, 0) distance := 1 @@ -194,9 +197,9 @@ func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) combinedPos { second := e.closestToRune(secondOption) secondDist := absFixed(second.x - x) if firstDist > secondDist { - return second + return second, false } else { - return first + return first, false } } @@ -214,8 +217,11 @@ func (e *textView) MoveLines(distance int, selAct selectionAction) { x := caretStart.x + e.caret.xoff // Seek to line. 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 + if atEndOfLine && pos.runes > 0 { + e.caret.start = pos.runes - 1 + } e.caret.xoff = x - pos.x e.updateSelection(selAct) } @@ -472,7 +478,8 @@ func (e *textView) scrollAbs(x, y int) { func (e *textView) MoveCoord(pos image.Point) { x := fixed.I(pos.X + e.scrollOff.X) 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 } @@ -610,7 +617,7 @@ func (e *textView) MovePages(pages int, selAct selectionAction) { caret := e.closestToRune(e.caret.start) x := caret.x + e.caret.xoff y := caret.y + pages*e.viewSize.Y - pos := e.closestToXYGraphemes(x, y) + pos, _ := e.closestToXYGraphemes(x, y) e.caret.start = pos.runes e.caret.xoff = x - pos.x e.updateSelection(selAct)