Files
gio/widget/index.go
T
Chris Waldon 959f5889a1 go.*,text,widget{,/material}: implement text truncators
This commit adds support for the idea of a text "Truncator", a string
that is shown at the end of truncated text to indicate that it has been
shortened because it would not fit within the requested number of lines.

When specifying a maximum number of lines, a truncator symbol is always
used. If the user does not provide one, the rune `…` is used. This
requirement results in a better user experience and significantly simpler
code, as we can rely upon the presence of one or more truncator glyphs in
the output glyph stream when truncation has occurred.

When interacting with truncated text, the truncator glyphs all act as
a single, indivisible unit. They can be selected or not, and if selected
they act as the entire contents of the truncated portion of the text.
This means that copying all of a truncated label will copy the entire
label text content, with the truncator symbol not appearing at all.

Concretely, the exposed text API now accepts a Truncator string in
text.Parameters, and there is a new glyph flag FlagTruncator which indicates
that the glyph is part of the truncator run. The truncator run will only
have a single FlagClusterBreak (even if the run would usually have many),
and the glyph with both FlagClusterBreak and FlagTruncator will have the
quantity of truncated runes in its Runes field. This necessitated increasing
the size of the Runes field from a byte to an int, as it's theoretically possible
for quite a lot of text to be truncated.

This commit necessarily bumps our go-text/typesetting dependency to the version
exposing truncation in the exported API.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-03-28 09:25:28 -06:00

503 lines
14 KiB
Go

// SPDX-License-Identifier: Unlicense OR MIT
package widget
import (
"bufio"
"image"
"io"
"math"
"sort"
"gioui.org/text"
"github.com/go-text/typesetting/segmenter"
"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 holds the glyphs processed.
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
// truncated indicates that the text was truncated by the shaper.
truncated bool
}
// reset prepares the index for reuse.
func (g *glyphIndex) reset() {
g.glyphs = g.glyphs[:0]
g.positions = g.positions[:0]
g.lines = g.lines[:0]
g.currentLineMin = 0
g.currentLineMax = 0
g.currentLineGlyphs = 0
g.pos = combinedPos{}
g.prog = 0
g.clusterAdvance = 0
g.skipPrior = false
g.truncated = false
}
// 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
positionCount := int(gl.Runes)
runesPerPosition := 1
if gl.Flags&text.FlagTruncator != 0 {
// Treat the truncator as a single unit that is either selected or not.
positionCount = 1
runesPerPosition = int(gl.Runes)
g.truncated = true
}
perRune := width / fixed.Int26_6(positionCount)
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 <= positionCount; i++ {
pos.x = gl.X + adjust + perRune*fixed.Int26_6(i)
pos.runes += runesPerPosition
pos.lineCol.col += runesPerPosition
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
}
// graphemeReader segments paragraphs of text into grapheme clusters.
type graphemeReader struct {
segmenter.Segmenter
graphemes []int
paragraph []rune
source io.ReaderAt
cursor int64
reader *bufio.Reader
runeOffset int
}
// SetSource configures the reader to pull from source.
func (p *graphemeReader) SetSource(source io.ReaderAt) {
p.source = source
p.cursor = 0
p.reader = bufio.NewReader(p)
p.runeOffset = 0
}
// Read exists to satisfy io.Reader. It should not be directly invoked.
func (p *graphemeReader) Read(b []byte) (int, error) {
n, err := p.source.ReadAt(b, p.cursor)
p.cursor += int64(n)
return n, err
}
// next decodes one paragraph of rune data.
func (p *graphemeReader) next() ([]rune, bool) {
p.paragraph = p.paragraph[:0]
var err error
var r rune
for err == nil {
r, _, err = p.reader.ReadRune()
if err != nil {
break
}
p.paragraph = append(p.paragraph, r)
if r == '\n' {
break
}
}
return p.paragraph, err == nil
}
// Graphemes will return the next paragraph's grapheme cluster boundaries,
// if any. If it returns an empty slice, there is no more data (all paragraphs
// have been segmented).
func (p *graphemeReader) Graphemes() []int {
var more bool
p.graphemes = p.graphemes[:0]
p.paragraph, more = p.next()
if len(p.paragraph) == 0 && !more {
return nil
}
p.Segmenter.Init(p.paragraph)
iter := p.Segmenter.GraphemeIterator()
if iter.Next() {
graph := iter.Grapheme()
p.graphemes = append(p.graphemes,
p.runeOffset+graph.Offset,
p.runeOffset+graph.Offset+len(graph.Text),
)
}
for iter.Next() {
graph := iter.Grapheme()
p.graphemes = append(p.graphemes, p.runeOffset+graph.Offset+len(graph.Text))
}
p.runeOffset += len(p.paragraph)
return p.graphemes
}