mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35:40 +00:00
dc6fbf07f0
This commit adds exported methods to both LabelState and Editor allowing callers to locate the text regions representing a range of runes. This can be used to build interactive subregions of text, like (for instance) hyperlinks. Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
404 lines
12 KiB
Go
404 lines
12 KiB
Go
// SPDX-License-Identifier: Unlicense OR MIT
|
|
|
|
package widget
|
|
|
|
import (
|
|
"image"
|
|
"math"
|
|
"sort"
|
|
|
|
"gioui.org/text"
|
|
"golang.org/x/image/math/fixed"
|
|
)
|
|
|
|
type lineInfo struct {
|
|
xOff fixed.Int26_6
|
|
yOff int
|
|
width fixed.Int26_6
|
|
ascent, descent fixed.Int26_6
|
|
glyphs int
|
|
}
|
|
|
|
type glyphIndex struct {
|
|
glyphs []text.Glyph
|
|
// positions contain all possible caret positions, sorted by rune index.
|
|
positions []combinedPos
|
|
// lines contains metadata about the size and position of each line of
|
|
// text.
|
|
lines []lineInfo
|
|
|
|
// currentLineMin and currentLineMax track the dimensions of the line
|
|
// that is being indexed.
|
|
currentLineMin, currentLineMax fixed.Int26_6
|
|
// currentLineGlyphs tracks how many glyphs are contained within the
|
|
// line that is being indexed.
|
|
currentLineGlyphs int
|
|
// pos tracks attributes of the next valid cursor position within the indexed
|
|
// text.
|
|
pos combinedPos
|
|
// prog tracks the current glyph text progression to detect bidi changes.
|
|
prog text.Flags
|
|
// clusterAdvance accumulates the advances of glyphs in a glyph cluster.
|
|
clusterAdvance fixed.Int26_6
|
|
// skipPrior controls whether a text position is inserted "before" the
|
|
// next glyph. Usually this should not happen, but the boundaries of
|
|
// lines and bidi runs require it.
|
|
skipPrior bool
|
|
}
|
|
|
|
// screenPos represents a character position in text line and column numbers,
|
|
// not pixels.
|
|
type screenPos struct {
|
|
// col is the column, measured in runes.
|
|
// FIXME: we only ever use col for start or end of lines.
|
|
// We don't need accurate accounting, so can we get rid of it?
|
|
col int
|
|
line int
|
|
}
|
|
|
|
// combinedPos is a point in the editor.
|
|
type combinedPos struct {
|
|
// runes is the offset in runes.
|
|
runes int
|
|
|
|
lineCol screenPos
|
|
|
|
// Pixel coordinates
|
|
x fixed.Int26_6
|
|
y int
|
|
|
|
ascent, descent fixed.Int26_6
|
|
|
|
// runIndex tracks which run this position is within, counted each time
|
|
// the index processes an end of run marker.
|
|
runIndex int
|
|
// towardOrigin tracks whether this glyph's run is progressing toward the
|
|
// origin or away from it.
|
|
towardOrigin bool
|
|
}
|
|
|
|
// incrementPosition returns the next position after pos (if any). Pos _must_ be
|
|
// an unmodified position acquired from one of the closest* methods. If eof is
|
|
// true, there was no next position.
|
|
func (g *glyphIndex) incrementPosition(pos combinedPos) (next combinedPos, eof bool) {
|
|
candidate, index := g.closestToRune(pos.runes)
|
|
for candidate != pos && index+1 < len(g.positions) {
|
|
index++
|
|
candidate = g.positions[index]
|
|
}
|
|
if index+1 < len(g.positions) {
|
|
return g.positions[index+1], false
|
|
}
|
|
return candidate, true
|
|
|
|
}
|
|
|
|
// Glyph indexes the provided glyph, generating text cursor positions for it.
|
|
func (g *glyphIndex) Glyph(gl text.Glyph) {
|
|
g.glyphs = append(g.glyphs, gl)
|
|
g.currentLineGlyphs++
|
|
if len(g.positions) == 0 {
|
|
// First-iteration setup.
|
|
g.currentLineMin = math.MaxInt32
|
|
g.currentLineMax = 0
|
|
}
|
|
if gl.X < g.currentLineMin {
|
|
g.currentLineMin = gl.X
|
|
}
|
|
if end := gl.X + gl.Advance; end > g.currentLineMax {
|
|
g.currentLineMax = end
|
|
}
|
|
if !g.skipPrior || gl.Flags&text.FlagTowardOrigin != g.prog || gl.Flags&text.FlagParagraphStart != 0 {
|
|
// Set the new text progression based on that of the first glyph.
|
|
g.prog = gl.Flags & text.FlagTowardOrigin
|
|
g.pos.towardOrigin = g.prog == text.FlagTowardOrigin
|
|
// Create the text position prior to the first glyph.
|
|
pos := g.pos
|
|
pos.x = gl.X
|
|
pos.y = int(gl.Y)
|
|
pos.ascent = gl.Ascent
|
|
pos.descent = gl.Descent
|
|
if pos.towardOrigin {
|
|
pos.x += gl.Advance
|
|
}
|
|
g.pos = pos
|
|
g.positions = append(g.positions, pos)
|
|
g.skipPrior = true
|
|
}
|
|
needsNewLine := gl.Flags&text.FlagLineBreak != 0
|
|
needsNewRun := gl.Flags&text.FlagRunBreak != 0
|
|
breaksParagraph := gl.Flags&text.FlagParagraphBreak != 0
|
|
|
|
// We should insert new positions if the glyph we're processing terminates
|
|
// a glyph cluster.
|
|
insertPositionAfter := gl.Flags&text.FlagClusterBreak != 0 && !breaksParagraph && gl.Runes > 0
|
|
if breaksParagraph {
|
|
// Paragraph breaking clusters shouldn't have positions generated for both
|
|
// sides of them. They're always zero-width, so doing so would
|
|
// create two visually identical cursor positions. Just reset
|
|
// cluster state, increment by their runes, and move on to the
|
|
// next glyph.
|
|
g.clusterAdvance = 0
|
|
g.pos.runes += int(gl.Runes)
|
|
}
|
|
// Always track the cumulative advance added by the glyph, even if it
|
|
// doesn't terminate a cluster itself.
|
|
g.clusterAdvance += gl.Advance
|
|
if insertPositionAfter {
|
|
// Construct the text position _after_ gl.
|
|
pos := g.pos
|
|
pos.y = int(gl.Y)
|
|
pos.ascent = gl.Ascent
|
|
pos.descent = gl.Descent
|
|
width := g.clusterAdvance
|
|
perRune := width / fixed.Int26_6(gl.Runes)
|
|
adjust := fixed.Int26_6(0)
|
|
if pos.towardOrigin {
|
|
// If RTL, subtract increments from the width of the cluster
|
|
// instead of adding.
|
|
adjust = width
|
|
perRune = -perRune
|
|
}
|
|
for i := 1; i <= int(gl.Runes); i++ {
|
|
pos.x = gl.X + adjust + perRune*fixed.Int26_6(i)
|
|
pos.runes++
|
|
pos.lineCol.col++
|
|
g.positions = append(g.positions, pos)
|
|
}
|
|
g.pos = pos
|
|
g.clusterAdvance = 0
|
|
}
|
|
if needsNewRun {
|
|
g.pos.runIndex++
|
|
}
|
|
if needsNewLine {
|
|
g.lines = append(g.lines, lineInfo{
|
|
xOff: g.currentLineMin,
|
|
yOff: int(gl.Y),
|
|
width: g.currentLineMax - g.currentLineMin,
|
|
ascent: g.positions[len(g.positions)-1].ascent,
|
|
descent: g.positions[len(g.positions)-1].descent,
|
|
glyphs: g.currentLineGlyphs,
|
|
})
|
|
g.pos.lineCol.line++
|
|
g.pos.lineCol.col = 0
|
|
g.pos.runIndex = 0
|
|
g.currentLineMin = math.MaxInt32
|
|
g.currentLineMax = 0
|
|
g.currentLineGlyphs = 0
|
|
g.skipPrior = false
|
|
}
|
|
}
|
|
|
|
func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) {
|
|
if len(g.positions) == 0 {
|
|
return combinedPos{}, 0
|
|
}
|
|
i := sort.Search(len(g.positions), func(i int) bool {
|
|
pos := g.positions[i]
|
|
return pos.runes >= runeIdx
|
|
})
|
|
if i > 0 {
|
|
i--
|
|
}
|
|
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
|
|
}
|
|
|
|
func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos {
|
|
if len(g.positions) == 0 {
|
|
return combinedPos{}
|
|
}
|
|
i := sort.Search(len(g.positions), 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--
|
|
}
|
|
prior := g.positions[i]
|
|
if i+1 >= len(g.positions) {
|
|
return prior
|
|
}
|
|
next := g.positions[i+1]
|
|
if next.lineCol != lineCol {
|
|
return prior
|
|
}
|
|
return next
|
|
}
|
|
|
|
func dist(a, b fixed.Int26_6) fixed.Int26_6 {
|
|
if a > b {
|
|
return a - b
|
|
}
|
|
return b - a
|
|
}
|
|
|
|
func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos {
|
|
if len(g.positions) == 0 {
|
|
return combinedPos{}
|
|
}
|
|
i := sort.Search(len(g.positions), func(i int) bool {
|
|
pos := g.positions[i]
|
|
return pos.y+pos.descent.Round() >= y
|
|
})
|
|
// If no position was greater than the provided Y, the text is too
|
|
// 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]
|
|
}
|
|
first := g.positions[i]
|
|
// Find the best X coordinate.
|
|
closest := i
|
|
closestDist := dist(first.x, x)
|
|
line := first.lineCol.line
|
|
// NOTE(whereswaldon): there isn't a simple way to accelerate this. Bidi text means that the x coordinates
|
|
// for positions have no fixed relationship. In the future, we can consider sorting the positions
|
|
// on a line by their x coordinate and caching that. It'll be a one-time O(nlogn) per line, but
|
|
// subsequent uses of this function for that line become O(logn). Right now it's always O(n).
|
|
for i := i + 1; i < len(g.positions) && g.positions[i].lineCol.line == line; i++ {
|
|
candidate := g.positions[i]
|
|
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]
|
|
}
|
|
if distance < closestDist {
|
|
closestDist = distance
|
|
closest = i
|
|
}
|
|
}
|
|
return g.positions[closest]
|
|
}
|
|
|
|
// makeRegion creates a text-aligned rectangle from start to end. The vertical
|
|
// dimensions of the rectangle are derived from the provided line's ascent and
|
|
// descent, and the y offset of the line's baseline is provided as y.
|
|
func makeRegion(line lineInfo, y int, start, end fixed.Int26_6) Region {
|
|
if start > end {
|
|
start, end = end, start
|
|
}
|
|
dotStart := image.Pt(start.Round(), y)
|
|
dotEnd := image.Pt(end.Round(), y)
|
|
return Region{
|
|
Bounds: image.Rectangle{
|
|
Min: dotStart.Sub(image.Point{Y: line.ascent.Ceil()}),
|
|
Max: dotEnd.Add(image.Point{Y: line.descent.Floor()}),
|
|
},
|
|
Baseline: line.descent.Floor(),
|
|
}
|
|
}
|
|
|
|
// Region describes the position and baseline of an area of interest within
|
|
// shaped text.
|
|
type Region struct {
|
|
// Bounds is the coordinates of the bounding box relative to the containing
|
|
// widget.
|
|
Bounds image.Rectangle
|
|
// Baseline is the quantity of vertical pixels between the baseline and
|
|
// the bottom of bounds.
|
|
Baseline int
|
|
}
|
|
|
|
// region is identical to Region except that its coordinates are in document
|
|
// space instead of a widget coordinate space.
|
|
type region = Region
|
|
|
|
// locate returns highlight regions covering the glyphs that represent the runes in
|
|
// [startRune,endRune). If the rects parameter is non-nil, locate will use it to
|
|
// return results instead of allocating, provided that there is enough capacity.
|
|
// The returned regions have their Bounds specified relative to the provided
|
|
// viewport.
|
|
func (g *glyphIndex) locate(viewport image.Rectangle, startRune, endRune int, rects []Region) []Region {
|
|
if startRune > endRune {
|
|
startRune, endRune = endRune, startRune
|
|
}
|
|
rects = rects[:0]
|
|
caretStart, _ := g.closestToRune(startRune)
|
|
caretEnd, _ := g.closestToRune(endRune)
|
|
|
|
for lineIdx := caretStart.lineCol.line; lineIdx < len(g.lines); lineIdx++ {
|
|
if lineIdx > caretEnd.lineCol.line {
|
|
break
|
|
}
|
|
pos := g.closestToLineCol(screenPos{line: lineIdx})
|
|
if int(pos.y)+pos.descent.Ceil() < viewport.Min.Y {
|
|
continue
|
|
}
|
|
if int(pos.y)-pos.ascent.Ceil() > viewport.Max.Y {
|
|
break
|
|
}
|
|
line := g.lines[lineIdx]
|
|
if lineIdx > caretStart.lineCol.line && lineIdx < caretEnd.lineCol.line {
|
|
startX := line.xOff
|
|
endX := startX + line.width
|
|
// The entire line is selected.
|
|
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
|
continue
|
|
}
|
|
selectionStart := caretStart
|
|
selectionEnd := caretEnd
|
|
if lineIdx != caretStart.lineCol.line {
|
|
// This line does not contain the beginning of the selection.
|
|
selectionStart = g.closestToLineCol(screenPos{line: lineIdx})
|
|
}
|
|
if lineIdx != caretEnd.lineCol.line {
|
|
// This line does not contain the end of the selection.
|
|
selectionEnd = g.closestToLineCol(screenPos{line: lineIdx, col: math.MaxInt})
|
|
}
|
|
|
|
var (
|
|
startX, endX fixed.Int26_6
|
|
eof bool
|
|
)
|
|
lineLoop:
|
|
for !eof {
|
|
startX = selectionStart.x
|
|
if selectionStart.runIndex == selectionEnd.runIndex {
|
|
// Commit selection.
|
|
endX = selectionEnd.x
|
|
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
|
break
|
|
} else {
|
|
currentDirection := selectionStart.towardOrigin
|
|
previous := selectionStart
|
|
runLoop:
|
|
for !eof {
|
|
// Increment the start position until the next logical run.
|
|
for startRun := selectionStart.runIndex; selectionStart.runIndex == startRun; {
|
|
previous = selectionStart
|
|
selectionStart, eof = g.incrementPosition(selectionStart)
|
|
if eof {
|
|
endX = selectionStart.x
|
|
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
|
break runLoop
|
|
}
|
|
}
|
|
if selectionStart.towardOrigin != currentDirection {
|
|
endX = previous.x
|
|
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
|
break
|
|
}
|
|
if selectionStart.runIndex == selectionEnd.runIndex {
|
|
// Commit selection.
|
|
endX = selectionEnd.x
|
|
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
|
break lineLoop
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for i := range rects {
|
|
rects[i].Bounds = rects[i].Bounds.Sub(viewport.Min)
|
|
}
|
|
return rects
|
|
}
|