font/{gofont,opentype},text,widget{,/material}: [API] add font fallback and bidi support

This commit restructures the entire text shaping stack to enable lines of shaped text to
have non-homogeneous properties like which font face they belong to and which direction
a segment of text is going.

The text package now provides a concrete type text.Shaper which can be used to convert
strings into sequences of renderable text.Glyphs. At a high level, the API is used
like this:

    // Prepare some fonts.
    var collection []text.FontFace
    // Make a shaper with those fonts loaded.
    shaper := text.NewShaper(collection)
    // Shape a string.
    shaper.LayoutString(text.Parameters{
		PxPerEm: fixed.I(12),
    }, 0, 100, system.Locale{}, "Hello")
    // Iterate the glyphs from that string.
    for glyph, ok := shaper.NextGlyph(); ok; glyph, ok = shaper.NextGlyph() {
    	// Convert the glyph data into a path. In real uses, convert batches of glyphs
    	// rather than single glyphs to reduce the number of individual paths and offsets
    	// required to display your text.
    	shape := shaper.Shape([]text.Glyph{glyph})
    	// Offset the glyph to the position it declares within its fields. This will
    	// automatically handle correct bidirectional text glyph positioning.
    	offset := op.Offset(image.Pt(glyph.X.Floor(), int(glyph.Y))).Push(gtx.Ops)
    	// Create a clip area from the shape of the glyph.
    	area := clip.Outline{Path: shape}.Push(gtx.Ops)
    	// Paint whatever the current color is within the glyph's shape.
    	paint.PaintOp{}.Add(gtx.Ops)
    	area.Pop()
        offset.Pop()
    }

This API will transparently handle both font fallback (choosing appropriate fonts
from those loaded when the primary font doesn't contain a required glyph) and
bidirectional text (mixed left-to-right and right-to-left text). Glyphs are
iterated in order of the input runes, not their visual order, but proper use
of the provided offsets will ensure that text always displays correctly.

Thanks to Elias Naur for suggesting this glyph iterator strategy. It let us cut
through a lot of accumulated complexity from trying to match our old text APIs,
meaning that this change actually is a net negative change in lines of code.

This commit consumes the upstream github.com/go-text/typesetting/shaping API
now that my prior work is merged there, removing the need for the font/opentype/internal
package entirely.

As part of my efforts, I fuzzed both the low-level text shaping stack and the
editor widget extensively. I've committed regression tests found that way into
the appropriate testdata files to ensure the fuzzer re-checks them.

Fixes: https://todo.sr.ht/~eliasnaur/gio/425
Fixes: https://todo.sr.ht/~eliasnaur/gio/211
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
Chris Waldon
2022-10-10 16:44:22 -04:00
committed by Elias Naur
parent 513250122c
commit b7d126e24c
43 changed files with 3523 additions and 3980 deletions
+147 -432
View File
@@ -3,8 +3,6 @@
package widget
import (
"bytes"
"fmt"
"image"
"io"
"math"
@@ -28,6 +26,7 @@ import (
"gioui.org/text"
"gioui.org/unit"
"golang.org/x/exp/constraints"
"golang.org/x/image/math/fixed"
)
@@ -55,7 +54,7 @@ type Editor struct {
eventKey int
font text.Font
shaper text.Shaper
shaper *text.Shaper
textSize fixed.Int26_6
blinkStart time.Time
focused bool
@@ -65,17 +64,15 @@ type Editor struct {
maxWidth int
viewSize image.Point
valid bool
lines []text.Line
regions []region
dims layout.Dimensions
requestFocus bool
// index tracks combined caret positions at regularly
// spaced intervals to speed up caret seeking.
index []combinedPos
// offIndex is an index of rune index to byte offsets.
offIndex []offEntry
index glyphIndex
// ime tracks the state relevant to input methods.
ime struct {
imeState
@@ -140,24 +137,6 @@ type maskReader struct {
mask []byte
}
// combinedPos is a point in the editor.
type combinedPos struct {
// runes is the offset in runes.
runes int
// lineCol.Y = line (offset into Editor.lines), and X = col (rune offset into
// Editor.lines[Y])
lineCol screenPos
// Pixel coordinates
x fixed.Int26_6
y int
// clusterIndex is the glyph cluster index that contains the rune referred
// to by the y coordinate.
clusterIndex int
}
type selectionAction int
const (
@@ -244,7 +223,7 @@ func (e *Editor) makeValid() {
if e.valid {
return
}
e.lines, e.dims = e.layoutText(e.shaper)
e.layoutText(e.shaper)
e.valid = true
}
@@ -415,8 +394,8 @@ func (e *Editor) processKey(gtx layout.Context) {
ke.Start -= adjust
ke.End -= adjust
adjust = 0
e.caret.start = e.closestPosition(combinedPos{runes: ke.Start}).runes
e.caret.end = e.closestPosition(combinedPos{runes: ke.End}).runes
e.caret.start = e.closestToRune(ke.Start).runes
e.caret.end = e.closestToRune(ke.End).runes
}
}
if e.rr.Changed() {
@@ -424,12 +403,28 @@ func (e *Editor) processKey(gtx layout.Context) {
}
}
func (e *Editor) closestToRune(runeIdx int) combinedPos {
e.makeValid()
pos, _ := e.index.closestToRune(runeIdx)
return pos
}
func (e *Editor) closestToLineCol(line, col int) combinedPos {
e.makeValid()
return e.index.closestToLineCol(screenPos{line: line, col: col})
}
func (e *Editor) closestToXY(x fixed.Int26_6, y int) combinedPos {
e.makeValid()
return e.index.closestToXY(x, y)
}
func (e *Editor) moveLines(distance int, selAct selectionAction) {
caretStart := e.closestPosition(combinedPos{runes: e.caret.start})
caretStart := e.closestToRune(e.caret.start)
x := caretStart.x + e.caret.xoff
// Seek to line.
pos := e.closestPosition(combinedPos{lineCol: screenPos{Y: caretStart.lineCol.Y + distance}})
pos = e.closestPosition(combinedPos{x: x, y: pos.y})
pos := e.closestToLineCol(caretStart.lineCol.line+distance, 0)
pos = e.closestToXY(x, pos.y)
e.caret.start = pos.runes
e.caret.xoff = x - pos.x
e.updateSelection(selAct)
@@ -537,7 +532,7 @@ func (e *Editor) calculateViewSize(gtx layout.Context) image.Point {
}
// Layout lays out the editor. If content is not nil, it is laid out on top.
func (e *Editor) Layout(gtx layout.Context, sh text.Shaper, font text.Font, size unit.Sp, content layout.Widget) layout.Dimensions {
func (e *Editor) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, content layout.Widget) layout.Dimensions {
if e.locale != gtx.Locale {
e.locale = gtx.Locale
e.invalidate()
@@ -550,14 +545,14 @@ func (e *Editor) Layout(gtx layout.Context, sh text.Shaper, font text.Font, size
}
maxWidth := gtx.Constraints.Max.X
if e.SingleLine {
maxWidth = inf
maxWidth = math.MaxInt
}
if maxWidth != e.maxWidth {
e.maxWidth = maxWidth
e.invalidate()
}
if sh != e.shaper {
e.shaper = sh
if lt != e.shaper {
e.shaper = lt
e.invalidate()
}
if e.Mask != e.lastMask {
@@ -611,8 +606,8 @@ func (e *Editor) updateSnippet(gtx layout.Context, start, end int) {
if start > end {
start, end = end, start
}
imeStart := e.closestPosition(combinedPos{runes: start})
imeEnd := e.closestPosition(combinedPos{runes: end})
imeStart := e.closestToRune(start)
imeEnd := e.closestToRune(end)
e.ime.start = imeStart.runes
e.ime.end = imeEnd.runes
startOff := e.runeOffset(imeStart.runes)
@@ -664,14 +659,22 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens
const keyFilterNoRightDown = "(ShortAlt)-(Shift)-[←,↑]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z"
const keyFilterNoArrows = "(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z"
const keyFilterAllArrows = "(ShortAlt)-(Shift)-[←,→,↑,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z"
caret := e.closestPosition(combinedPos{runes: e.caret.start})
caret := e.closestToRune(e.caret.start)
switch {
case caret.runes == 0 && caret.runes == e.Len():
keys = keyFilterNoArrows
case caret.runes == 0:
keys = keyFilterNoLeftUp
if gtx.Locale.Direction.Progression() == system.FromOrigin {
keys = keyFilterNoLeftUp
} else {
keys = keyFilterNoRightDown
}
case caret.runes == e.Len():
keys = keyFilterNoRightDown
if gtx.Locale.Direction.Progression() == system.FromOrigin {
keys = keyFilterNoRightDown
} else {
keys = keyFilterNoLeftUp
}
default:
keys = keyFilterAllArrows
}
@@ -720,98 +723,60 @@ func (e *Editor) PaintSelection(gtx layout.Context) {
if !e.focused {
return
}
cl := textPadding(e.lines)
cl.Max = cl.Max.Add(e.viewSize)
defer clip.Rect(cl).Push(gtx.Ops).Pop()
selStart, selEnd := e.caret.start, e.caret.end
if selStart > selEnd {
selStart, selEnd = selEnd, selStart
}
caretStart := e.closestPosition(combinedPos{runes: selStart})
caretEnd := e.closestPosition(combinedPos{runes: selEnd})
scroll := image.Point{
X: e.scrollOff.X,
Y: e.scrollOff.Y,
}
cl = cl.Add(scroll)
pos := e.seekFirstVisibleLine(cl.Min.Y)
for !posIsBelow(e.lines, pos, cl.Max.Y) {
leftmost, rightmost := clipLine(e.lines, e.Alignment, e.viewSize.X, cl, pos)
lineIdx := leftmost.lineCol.Y
if lineIdx < caretStart.lineCol.Y {
// Line is before selection start; skip.
pos = e.closestPosition(combinedPos{lineCol: screenPos{Y: pos.lineCol.Y + 1}})
continue
}
if lineIdx > caretEnd.lineCol.Y {
// Line is after selection end; we're done.
return
}
line := e.lines[leftmost.lineCol.Y]
flip := line.Layout.Direction.Progression() == system.TowardOrigin
// Clamp start, end to selection.
if !flip {
if leftmost.runes < selStart {
leftmost = caretStart
}
if rightmost.runes > selEnd {
rightmost = caretEnd
}
} else {
if leftmost.runes > selEnd {
leftmost = caretEnd
}
if rightmost.runes < selStart {
rightmost = caretStart
}
}
dotStart := image.Pt(leftmost.x.Round(), leftmost.y)
dotEnd := image.Pt(rightmost.x.Round(), rightmost.y)
t := op.Offset(scroll.Mul(-1)).Push(gtx.Ops)
size := image.Rectangle{
Min: dotStart.Sub(image.Point{Y: line.Ascent.Ceil()}),
Max: dotEnd.Add(image.Point{Y: line.Descent.Ceil()}),
}
op := clip.Rect(size).Push(gtx.Ops)
localViewport := image.Rectangle{Max: e.viewSize}
docViewport := image.Rectangle{Max: e.viewSize}.Add(e.scrollOff)
defer clip.Rect(localViewport).Push(gtx.Ops).Pop()
e.regions = e.index.locate(docViewport, e.caret.start, e.caret.end, e.regions)
for _, region := range e.regions {
area := clip.Rect(region.bounds.Sub(e.scrollOff)).Push(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
op.Pop()
t.Pop()
if pos.lineCol.Y == len(e.lines)-1 {
break
}
pos = e.closestPosition(combinedPos{lineCol: screenPos{Y: pos.lineCol.Y + 1}})
area.Pop()
}
}
func (e *Editor) PaintText(gtx layout.Context) {
cl := textPadding(e.lines)
cl.Max = cl.Max.Add(e.viewSize)
defer clip.Rect(cl).Push(gtx.Ops).Pop()
scroll := image.Point{
X: e.scrollOff.X,
Y: e.scrollOff.Y,
var gs [32]text.Glyph
line := gs[:0]
var lineOff image.Point
m := op.Record(gtx.Ops)
viewport := image.Rectangle{
Min: e.scrollOff,
Max: e.viewSize.Add(e.scrollOff),
}
cl = cl.Add(scroll)
pos := e.seekFirstVisibleLine(cl.Min.Y)
for !posIsBelow(e.lines, pos, cl.Max.Y) {
start, end := clipLine(e.lines, e.Alignment, e.viewSize.X, cl, pos)
line := e.lines[start.lineCol.Y]
off := image.Point{X: start.x.Floor(), Y: start.y}.Sub(scroll)
l := subLayout(line, start, end)
it := textIterator{viewport: viewport}
t := op.Offset(off).Push(gtx.Ops)
op := clip.Outline{Path: e.shaper.Shape(e.font, e.textSize, l)}.Op().Push(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
op.Pop()
t.Pop()
if pos.lineCol.Y == len(e.lines)-1 {
startGlyph := 0
for _, line := range e.index.lines {
if line.descent.Ceil()+line.yOff >= viewport.Min.Y {
break
}
pos = e.closestPosition(combinedPos{lineCol: screenPos{Y: pos.lineCol.Y + 1}})
startGlyph += line.glyphs
}
for _, g := range e.index.glyphs[startGlyph:] {
if !it.Glyph(g, true) {
break
}
if it.visible {
if len(line) == 0 {
lineOff = image.Point{X: it.g.X.Floor(), Y: int(it.g.Y)}.Sub(e.scrollOff)
}
line = append(line, g)
}
if g.Flags&text.FlagLineBreak > 0 || cap(line)-len(line) == 0 {
t := op.Offset(lineOff).Push(gtx.Ops)
op := clip.Outline{Path: e.shaper.Shape(line)}.Op().Push(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
op.Pop()
t.Pop()
line = line[:0]
}
}
call := m.Stop()
viewport.Min = viewport.Min.Add(it.padding.Min)
viewport.Max = viewport.Max.Add(it.padding.Max)
defer clip.Rect(viewport.Sub(e.scrollOff)).Push(gtx.Ops).Pop()
call.Add(gtx.Ops)
}
// caretWidth returns the width occupied by the caret for the current
@@ -835,15 +800,7 @@ func (e *Editor) PaintCaret(gtx layout.Context) {
Min: caretPos.Sub(image.Pt(carWidth2, carAsc)),
Max: caretPos.Add(image.Pt(carWidth2, carDesc)),
}
cl := textPadding(e.lines)
// Account for caret width to each side.
if cl.Max.X < carWidth2 {
cl.Max.X = carWidth2
}
if cl.Min.X > -carWidth2 {
cl.Min.X = -carWidth2
}
cl.Max = cl.Max.Add(e.viewSize)
cl := image.Rectangle{Max: e.viewSize}
carRect = cl.Intersect(carRect)
if !carRect.Empty() {
defer clip.Rect(carRect).Push(gtx.Ops).Pop()
@@ -851,44 +808,24 @@ func (e *Editor) PaintCaret(gtx layout.Context) {
}
}
func (e *Editor) seekFirstVisibleLine(y int) combinedPos {
pos := e.closestPosition(combinedPos{y: y})
for pos.lineCol.Y > 0 {
prevLine := pos.lineCol.Y - 1
prev := e.closestPosition(combinedPos{lineCol: screenPos{Y: prevLine}})
if posIsAbove(e.lines, prev, y) {
break
}
pos = prev
}
return pos
}
func (e *Editor) caretInfo() (pos image.Point, ascent, descent int) {
caretStart := e.closestPosition(combinedPos{runes: e.caret.start})
carX := caretStart.x
carY := caretStart.y
caretStart := e.closestToRune(e.caret.start)
ascent = caretStart.ascent.Ceil()
descent = caretStart.descent.Ceil()
ascent = -e.lines[caretStart.lineCol.Y].Bounds.Min.Y.Ceil()
descent = e.lines[caretStart.lineCol.Y].Bounds.Max.Y.Ceil()
pos = image.Point{
X: carX.Round(),
Y: carY,
X: caretStart.x.Round(),
Y: caretStart.y,
}
pos = pos.Sub(e.scrollOff)
return
}
// TODO: copied from package math. Remove when Go 1.18 is minimum.
const (
intSize = 32 << (^uint(0) >> 63) // 32 or 64
maxInt = 1<<(intSize-1) - 1
)
// Len is the length of the editor contents, in runes.
func (e *Editor) Len() int {
end := e.closestPosition(combinedPos{runes: maxInt})
return end.runes
e.makeValid()
return e.closestToRune(math.MaxInt).runes
}
// Text returns the contents of the editor.
@@ -911,8 +848,9 @@ func (e *Editor) SetText(s string) {
func (e *Editor) scrollBounds() image.Rectangle {
var b image.Rectangle
if e.SingleLine {
if len(e.lines) > 0 {
b.Min.X = align(e.Alignment, e.locale.Direction, e.lines[0].Width, e.viewSize.X).Floor()
if len(e.index.lines) > 0 {
line := e.index.lines[0]
b.Min.X = line.xOff.Floor()
if b.Min.X > 0 {
b.Min.X = 0
}
@@ -949,234 +887,51 @@ func (e *Editor) scrollAbs(x, y int) {
func (e *Editor) moveCoord(pos image.Point) {
x := fixed.I(pos.X + e.scrollOff.X)
y := pos.Y + e.scrollOff.Y
e.caret.start = e.closestPosition(combinedPos{x: x, y: y}).runes
e.caret.start = e.closestToXY(x, y).runes
e.caret.xoff = 0
}
func (e *Editor) layoutText(s text.Shaper) ([]text.Line, layout.Dimensions) {
func (e *Editor) layoutText(lt *text.Shaper) {
e.rr.Reset()
var r io.RuneReader = &e.rr
if e.Mask != 0 {
e.maskReader.Reset(&e.rr, e.Mask)
r = &e.maskReader
}
var lines []text.Line
if s != nil {
lines, _ = s.Layout(e.font, e.textSize, e.maxWidth, e.locale, r)
if len(lines) == 0 {
// The editor does not tolerate a zero-length list of lines being returned from the shaper.
lines = append(lines, text.Line{})
e.index = glyphIndex{}
it := textIterator{viewport: image.Rectangle{Max: image.Point{X: math.MaxInt, Y: math.MaxInt}}}
if lt != nil {
lt.Layout(text.Parameters{
Font: e.font,
PxPerEm: e.textSize,
Alignment: e.Alignment,
}, 0, e.maxWidth, e.locale, r)
for it.Glyph(lt.NextGlyph()) {
e.index.Glyph(it.g)
}
} else {
lines, _ = nullLayout(r)
// Make a fake glyph for every rune in the reader.
for _, _, err := r.ReadRune(); err != io.EOF; _, _, err = r.ReadRune() {
it.Glyph(text.Glyph{Runes: 1, Flags: text.FlagClusterBreak}, true)
e.index.Glyph(it.g)
}
}
dims := linesDimens(lines)
return lines, dims
dims := layout.Dimensions{Size: it.bounds.Size()}
dims.Baseline = dims.Size.Y - it.baseline
e.dims = dims
}
// CaretPos returns the line & column numbers of the caret.
func (e *Editor) CaretPos() (line, col int) {
caret := e.closestPosition(combinedPos{runes: e.caret.start})
return caret.lineCol.Y, caret.lineCol.X
pos := e.closestToRune(e.caret.start)
return pos.lineCol.line, pos.lineCol.col
}
// CaretCoords returns the coordinates of the caret, relative to the
// editor itself.
func (e *Editor) CaretCoords() f32.Point {
caret := e.closestPosition(combinedPos{runes: e.caret.start})
return f32.Pt(float32(caret.x)/64-float32(e.scrollOff.X), float32(caret.y-e.scrollOff.Y))
}
// indexPosition returns the latest position from the index no later than pos.
func (e *Editor) indexPosition(pos combinedPos) combinedPos {
e.makeValid()
// Initialize index with first caret position.
if len(e.index) == 0 {
e.index = append(e.index, firstPos(e.lines[0], e.Alignment, e.viewSize.X))
}
i := sort.Search(len(e.index), func(i int) bool {
return positionGreaterOrEqual(e.lines, e.index[i], pos)
})
// Return position just before pos, which is guaranteed to be less than or equal to pos.
if i > 0 {
i--
}
return e.index[i]
}
// positionGreaterOrEqual reports whether p1 >= p2 according to the non-zero fields
// of p2. All fields of p1 must be consistent and valid.
func positionGreaterOrEqual(lines []text.Line, p1, p2 combinedPos) bool {
l := lines[p1.lineCol.Y]
// Check whether the final glyph cluster has no glyphs, indicating a newline
// rune that forced the existence of a line break.
hardNewLine := len(l.Layout.Clusters) > 0 && l.Layout.Clusters[len(l.Layout.Clusters)-1].Glyphs.Count == 0
endCol := l.Layout.Runes.Count
if hardNewLine {
// If there was a hard newline, prevent the cursor for passing it on
// this line.
endCol--
}
eol := p1.lineCol.X == endCol
switch {
case p2.runes != 0:
return p1.runes >= p2.runes
case p2.lineCol != (screenPos{}):
if p1.lineCol.Y != p2.lineCol.Y {
return p1.lineCol.Y > p2.lineCol.Y
}
return eol || p1.lineCol.X >= p2.lineCol.X
case p2.x != 0 || p2.y != 0:
ly := p1.y + l.Descent.Ceil()
prevy := p1.y - l.Ascent.Ceil()
switch {
case ly < p2.y && p1.lineCol.Y < len(lines)-1:
// p1 is on a line before p2.y.
return false
case prevy >= p2.y && p1.lineCol.Y > 0:
// p1 is on a line after p2.y.
return true
}
if eol {
return true
}
// If clusterIndex is equal, they could be positions of different
// runes within the same cluster, so we fall back to the positional
// test below.
if p2.clusterIndex != 0 && p1.clusterIndex != p2.clusterIndex {
return p1.clusterIndex > p2.clusterIndex
}
flip := l.Layout.Direction.Progression() == system.TowardOrigin
if p1.clusterIndex == len(l.Layout.Clusters) {
return (!flip && p1.x >= p2.x) || (flip && p1.x <= p2.x)
} else {
adv := l.Layout.Clusters[p1.clusterIndex].RuneWidth()
left := p1.x + adv - p2.x
right := p2.x - p1.x
return (!flip && left >= right) || (flip && left <= right)
}
}
return true
}
// clusterIndexFor returns the index of the glyph cluster containing the rune
// at the given position within the line. As a special case, if the rune is one
// beyond the final rune of the line, it returns the length of the line's clusters
// slice. Otherwise, it panics if given a rune beyond the
// dimensions of the line. The startIdx must be known to be at or before
// the real index of the cluster. This means that this function is
// only useful for searching forward through text, not backward.
// Passing a startIdx after the cluster corresponding to the runeIdx
// will trigger a panic.
//
// All indices are relative to the content in the line. The runeIdx 0
// refers to the first rune on the line, regardless of the line's
// rune offset. Similarly, the provided and returned glyph cluster
// indices are relative to the line's cluster slice.
func clusterIndexFor(line text.Line, runeIdx, startIdx int) int {
if runeIdx == line.Layout.Runes.Count {
return len(line.Layout.Clusters)
}
lineStart := line.Layout.Runes.Offset
for i := startIdx; i < len(line.Layout.Clusters); i++ {
cluster := line.Layout.Clusters[i]
clusterStart := cluster.Runes.Offset - lineStart
clusterEnd := clusterStart + cluster.Runes.Count
if runeIdx >= clusterStart && runeIdx < clusterEnd {
return i
}
}
panic(fmt.Errorf("requested cluster index for rune %d outside of line with %d runes", runeIdx, line.Layout.Runes.Count))
}
// closestPosition takes a position and returns its closest valid position.
// Zero fields of pos are ignored.
func (e *Editor) closestPosition(pos combinedPos) combinedPos {
closest := e.indexPosition(pos)
const runesPerIndexEntry = 50
for {
var done bool
closest, done = seekPosition(e.lines, e.Alignment, e.viewSize.X, closest, pos, runesPerIndexEntry)
if done {
return closest
}
e.index = append(e.index, closest)
}
}
// seekPosition seeks to the position closest to needle, starting at start and returns true.
// If limit is non-zero, seekPosition stops seeks after limit runes and returns false.
// Start must have all fields valid, and needle must have at least one of runes, lineCol,
// or x+y valid. Start must be known to be before needle.
func seekPosition(lines []text.Line, alignment text.Alignment, width int, start, needle combinedPos, limit int) (combinedPos, bool) {
count := 0
// Advance until start is greater than or equal to needle.
for {
if positionGreaterOrEqual(lines, start, needle) {
return start, true
}
var eof bool
start, eof = incrementPosition(lines, alignment, width, start)
if eof {
return start, true
}
count++
if limit != 0 && count == limit {
return start, false
}
}
}
// incrementLinePosition transitions pos from the end of a line to the beginning of the next one.
// If pos is not at the end of a line, it will have no effect. It returns the (possibly modified)
// position, whether it handled the transition from the end of a line, and whether the position
// is at the end of the text data.
func incrementLinePosition(lines []text.Line, alignment text.Alignment, width int, pos combinedPos) (_ combinedPos, eol, eof bool) {
l := lines[pos.lineCol.Y]
if pos.lineCol.X >= l.Layout.Runes.Count {
if pos.lineCol.Y == len(lines)-1 {
// End of file.
return pos, false, true
}
// Move to next line.
prevDesc := l.Descent
pos.lineCol.Y++
pos.lineCol.X = 0
pos.clusterIndex = 0
l = lines[pos.lineCol.Y]
// Use firstPos to get the correct x coordinate of the beginning of the line.
alignedPos := firstPos(l, alignment, width)
pos.x = alignedPos.x
pos.y += (prevDesc + l.Ascent).Ceil()
return pos, true, false
}
return pos, false, false
}
// incrementPosition updates pos to be one position further into the text. This will either
// move pos one rune further into the text, transition pos from the end of one line to the
// beginning of the next, or both.
// All fields of pos must be valid before calling incrementPosition. eof will be true when
// pos represents the final text position in the lines.
func incrementPosition(lines []text.Line, alignment text.Alignment, width int, pos combinedPos) (_ combinedPos, eof bool) {
var eol bool
pos, eol, eof = incrementLinePosition(lines, alignment, width, pos)
if eof || eol {
return pos, eof
}
l := lines[pos.lineCol.Y]
isHardNewLine := l.Layout.Clusters[pos.clusterIndex].Glyphs.Count == 0
pos.x += l.Layout.Clusters[pos.clusterIndex].RuneWidth()
pos.runes++
pos.lineCol.X++
pos.clusterIndex = clusterIndexFor(l, pos.lineCol.X, pos.clusterIndex)
if isHardNewLine {
pos, _, eof = incrementLinePosition(lines, alignment, width, pos)
}
return pos, false
pos := e.closestToRune(e.caret.start)
return f32.Pt(float32(pos.x)/64-float32(e.scrollOff.X), float32(pos.y-e.scrollOff.Y))
}
// indexRune returns the latest rune index and byte offset no later than r.
@@ -1214,7 +969,6 @@ func (e *Editor) runeOffset(r int) int {
}
func (e *Editor) invalidate() {
e.index = e.index[:0]
e.offIndex = e.offIndex[:0]
e.valid = false
}
@@ -1315,8 +1069,8 @@ func (e *Editor) replace(start, end int, s string, addHistory bool) int {
if start > end {
start, end = end, start
}
startPos := e.closestPosition(combinedPos{runes: start})
endPos := e.closestPosition(combinedPos{runes: end})
startPos := e.closestToRune(start)
endPos := e.closestToRune(end)
startOff := e.runeOffset(startPos.runes)
replaceSize := endPos.runes - startPos.runes
el := e.Len()
@@ -1376,10 +1130,10 @@ func (e *Editor) replace(start, end int, s string, addHistory bool) int {
}
func (e *Editor) movePages(pages int, selAct selectionAction) {
caret := e.closestPosition(combinedPos{runes: e.caret.start})
caret := e.closestToRune(e.caret.start)
x := caret.x + e.caret.xoff
y := caret.y + pages*e.viewSize.Y
pos := e.closestPosition(combinedPos{x: x, y: y})
pos := e.closestToXY(x, y)
e.caret.start = pos.runes
e.caret.xoff = x - pos.x
e.updateSelection(selAct)
@@ -1390,25 +1144,23 @@ func (e *Editor) movePages(pages int, selAct selectionAction) {
// negative distances moves backward. Distances are in runes.
func (e *Editor) MoveCaret(startDelta, endDelta int) {
e.caret.xoff = 0
e.caret.start = e.closestPosition(combinedPos{runes: e.caret.start + startDelta}).runes
e.caret.end = e.closestPosition(combinedPos{runes: e.caret.end + endDelta}).runes
e.caret.start = e.closestToRune(e.caret.start + startDelta).runes
e.caret.end = e.closestToRune(e.caret.end + endDelta).runes
}
func (e *Editor) moveStart(selAct selectionAction) {
caret := e.closestPosition(combinedPos{runes: e.caret.start})
caret = e.closestPosition(combinedPos{lineCol: screenPos{Y: caret.lineCol.Y}})
caret := e.closestToRune(e.caret.start)
caret = e.closestToLineCol(caret.lineCol.line, 0)
e.caret.start = caret.runes
e.caret.xoff = -caret.x
e.updateSelection(selAct)
}
func (e *Editor) moveEnd(selAct selectionAction) {
caret := e.closestPosition(combinedPos{runes: e.caret.start})
caret = e.closestPosition(combinedPos{lineCol: screenPos{X: maxInt, Y: caret.lineCol.Y}})
caret := e.closestToRune(e.caret.start)
caret = e.closestToLineCol(caret.lineCol.line, math.MaxInt)
e.caret.start = caret.runes
l := e.lines[caret.lineCol.Y]
a := align(e.Alignment, e.locale.Direction, l.Width, e.viewSize.X)
e.caret.xoff = l.Width + a - caret.x
e.caret.xoff = fixed.I(e.maxWidth) - caret.x
e.updateSelection(selAct)
}
@@ -1423,7 +1175,7 @@ func (e *Editor) moveWord(distance int, selAct selectionAction) {
words, direction = distance*-1, -1
}
// atEnd if caret is at either side of the buffer.
caret := e.closestPosition(combinedPos{runes: e.caret.start})
caret := e.closestToRune(e.caret.start)
atEnd := func() bool {
return caret.runes == 0 || caret.runes == e.Len()
}
@@ -1440,13 +1192,13 @@ func (e *Editor) moveWord(distance int, selAct selectionAction) {
for ii := 0; ii < words; ii++ {
for r := next(); unicode.IsSpace(r) && !atEnd(); r = next() {
e.MoveCaret(direction, 0)
caret = e.closestPosition(combinedPos{runes: e.caret.start})
caret = e.closestToRune(e.caret.start)
}
e.MoveCaret(direction, 0)
caret = e.closestPosition(combinedPos{runes: e.caret.start})
caret = e.closestToRune(e.caret.start)
for r := next(); !unicode.IsSpace(r) && !atEnd(); r = next() {
e.MoveCaret(direction, 0)
caret = e.closestPosition(combinedPos{runes: e.caret.start})
caret = e.closestToRune(e.caret.start)
}
}
e.updateSelection(selAct)
@@ -1477,7 +1229,7 @@ func (e *Editor) deleteWord(distance int) {
words, direction = distance*-1, -1
}
// atEnd if offset is at or beyond either side of the buffer.
caret := e.closestPosition(combinedPos{runes: e.caret.start})
caret := e.closestToRune(e.caret.start)
atEnd := func(runes int) bool {
idx := caret.runes + runes*direction
return idx <= 0 || idx >= e.Len()
@@ -1511,8 +1263,7 @@ func (e *Editor) deleteWord(distance int) {
}
func (e *Editor) scrollToCaret() {
caret := e.closestPosition(combinedPos{runes: e.caret.start})
l := e.lines[caret.lineCol.Y]
caret := e.closestToRune(e.caret.start)
if e.SingleLine {
var dist int
if d := caret.x.Floor() - e.scrollOff.X; d < 0 {
@@ -1522,8 +1273,8 @@ func (e *Editor) scrollToCaret() {
}
e.scrollRel(dist, 0)
} else {
miny := caret.y - l.Ascent.Ceil()
maxy := caret.y + l.Descent.Ceil()
miny := caret.y - caret.ascent.Ceil()
maxy := caret.y + caret.descent.Ceil()
var dist int
if d := miny - e.scrollOff.Y; d < 0 {
dist = d
@@ -1534,12 +1285,6 @@ func (e *Editor) scrollToCaret() {
}
}
// NumLines returns the number of lines in the editor.
func (e *Editor) NumLines() int {
e.makeValid()
return len(e.lines)
}
// SelectionLen returns the length of the selection, in runes; it is
// equivalent to utf8.RuneCountInString(e.SelectedText()).
func (e *Editor) SelectionLen() int {
@@ -1555,8 +1300,8 @@ func (e *Editor) Selection() (start, end int) {
// SetCaret moves the caret to start, and sets the selection end to end. start
// and end are in runes, and represent offsets into the editor text.
func (e *Editor) SetCaret(start, end int) {
e.caret.start = e.closestPosition(combinedPos{runes: start}).runes
e.caret.end = e.closestPosition(combinedPos{runes: end}).runes
e.caret.start = e.closestToRune(start).runes
e.caret.end = e.closestToRune(end).runes
e.caret.scroll = true
e.scroller.Stop()
}
@@ -1612,14 +1357,14 @@ func max(a, b int) int {
return b
}
func min(a, b int) int {
func min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
func abs(n int) int {
func abs[T constraints.Signed](n T) T {
if n < 0 {
return -n
}
@@ -1637,36 +1382,6 @@ func sign(n int) int {
}
}
func nullLayout(rr io.RuneReader) ([]text.Line, error) {
var rerr error
var n int
var buf bytes.Buffer
for {
r, _, err := rr.ReadRune()
if err != nil {
rerr = err
break
}
n++
buf.WriteRune(r)
}
clusters := make([]text.GlyphCluster, n)
for i := range clusters {
clusters[i].Runes.Count = 1
clusters[i].Runes.Offset = i
}
return []text.Line{
{
Layout: text.Layout{
Clusters: clusters,
Runes: text.Range{
Count: n,
},
},
},
}, rerr
}
func (s ChangeEvent) isEditorEvent() {}
func (s SubmitEvent) isEditorEvent() {}
func (s SelectEvent) isEditorEvent() {}
+82 -91
View File
@@ -9,11 +9,9 @@ import (
"io"
"math/rand"
"reflect"
"strings"
"testing"
"testing/quick"
"time"
"unicode"
"unicode/utf8"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
@@ -29,7 +27,6 @@ import (
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
"golang.org/x/image/math/fixed"
)
var english = system.Locale{
@@ -105,7 +102,7 @@ func TestEditorZeroDimensions(t *testing.T) {
},
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e := new(Editor)
@@ -121,7 +118,7 @@ func TestEditorConfigurations(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
sentence := "\n\n\n\n\n\n\n\n\n\n\n\nthe quick brown fox jumps over the lazy dog"
@@ -161,7 +158,7 @@ func TestEditor(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
@@ -209,35 +206,40 @@ func TestEditor(t *testing.T) {
assertCaret(t, e, 1, 1, len("æbc\na"))
e.MoveCaret(-3, -3)
assertCaret(t, e, 0, 2, len("æb"))
e.Mask = '\U0001F92B'
e.Layout(gtx, cache, font, fontSize, nil)
e.moveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
/*
NOTE(whereswaldon): it isn't possible to check the raw glyph data
like this anymore. How should we handle this?
e.Mask = '\U0001F92B'
e.Layout(gtx, cache, font, fontSize, nil)
e.moveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
// When a password mask is applied, it should replace all visible glyphs
spaces := 0
for _, r := range textSample {
if unicode.IsSpace(r) {
spaces++
}
}
nonSpaces := len([]rune(textSample)) - spaces
glyphCounts := make(map[int]int)
for _, line := range e.lines {
for _, glyph := range line.Layout.Glyphs {
glyphCounts[int(glyph.ID)]++
}
}
if len(glyphCounts) > 2 {
t.Errorf("masked text contained glyphs other than mask and whitespace")
}
// When a password mask is applied, it should replace all visible glyphs
spaces := 0
for _, r := range textSample {
if unicode.IsSpace(r) {
spaces++
}
}
nonSpaces := len([]rune(textSample)) - spaces
glyphCounts := make(map[int]int)
// This loop assumes a single-run text, which we know is safe here.
for _, line := range e.lines {
for _, glyph := range line.Runs[0].Glyphs {
glyphCounts[int(glyph.ID)]++
}
}
if len(glyphCounts) > 2 {
t.Errorf("masked text contained glyphs other than mask and whitespace")
}
for gid, count := range glyphCounts {
if count != spaces && count != nonSpaces {
t.Errorf("glyph with id %d occurred %d times, expected either %d or %d", gid, count, spaces, nonSpaces)
}
}
for gid, count := range glyphCounts {
if count != spaces && count != nonSpaces {
t.Errorf("glyph with id %d occurred %d times, expected either %d or %d", gid, count, spaces, nonSpaces)
}
}
*/
// Test that moveLine applies x offsets from previous moves.
e.SetText("long line\nshort")
e.SetCaret(0, 0)
@@ -264,7 +266,7 @@ func TestEditorRTL(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: arabic,
}
cache := text.NewCache(arabicCollection)
cache := text.NewShaper(arabicCollection)
fontSize := unit.Sp(10)
font := text.Font{}
@@ -333,7 +335,7 @@ func TestEditorLigature(t *testing.T) {
if err != nil {
t.Skipf("failed parsing test font: %v", err)
}
cache := text.NewCache([]text.FontFace{
cache := text.NewShaper([]text.FontFace{
{
Font: text.Font{
Typeface: "Roboto",
@@ -454,7 +456,7 @@ func TestEditorDimensions(t *testing.T) {
Queue: tq,
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
dims := e.Layout(gtx, cache, font, fontSize, nil)
@@ -501,7 +503,7 @@ func TestEditorCaretConsistency(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
for _, a := range []text.Alignment{text.Start, text.Middle, text.End} {
@@ -516,11 +518,11 @@ func TestEditorCaretConsistency(t *testing.T) {
gotCoords := e.CaretCoords()
// Blow away index to re-compute position from scratch.
e.invalidate()
want := e.closestPosition(combinedPos{runes: e.caret.start})
want := e.closestToRune(e.caret.start)
wantCoords := f32.Pt(float32(want.x)/64, float32(want.y))
if want.lineCol.Y != gotLine || want.lineCol.X != gotCol || gotCoords != wantCoords {
if want.lineCol.line != gotLine || int(want.lineCol.col) != gotCol || gotCoords != wantCoords {
return fmt.Errorf("caret (%d,%d) pos %s, want (%d,%d) pos %s",
gotLine, gotCol, gotCoords, want.lineCol.Y, want.lineCol.X, wantCoords)
gotLine, gotCol, gotCoords, want.lineCol.line, want.lineCol.col, wantCoords)
}
return nil
}
@@ -594,7 +596,7 @@ func TestEditorMoveWord(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.SetText(t)
@@ -699,7 +701,7 @@ func TestEditorInsert(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.SetText(t)
@@ -789,7 +791,7 @@ func TestEditorDeleteWord(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.SetText(t)
@@ -823,12 +825,12 @@ func (editMutation) Generate(rand *rand.Rand, size int) reflect.Value {
return reflect.ValueOf(t)
}
// TestSelect tests the selection code. It lays out an editor with several
// TestEditorSelect tests the selection code. It lays out an editor with several
// lines in it, selects some text, verifies the selection, resizes the editor
// to make it much narrower (which makes the lines in the editor reflow), and
// then verifies that the updated (col, line) positions of the selected text
// are where we expect.
func TestSelect(t *testing.T) {
func TestEditorSelect(t *testing.T) {
e := new(Editor)
e.SetText(`a 2 4 6 8 a
b 2 4 6 8 b
@@ -843,7 +845,7 @@ g 2 4 6 8 g
Ops: new(op.Ops),
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
font := text.Font{}
fontSize := unit.Sp(10)
@@ -855,8 +857,8 @@ g 2 4 6 8 g
_ = e.Events() // throw away any events from this layout
// Build the selection events
startPos := e.closestPosition(combinedPos{runes: start})
endPos := e.closestPosition(combinedPos{runes: end})
startPos := e.closestToRune(start)
endPos := e.closestToRune(end)
tq := &testQueue{
events: []event.Event{
pointer.Event{
@@ -864,13 +866,13 @@ g 2 4 6 8 g
Type: pointer.Press,
Source: pointer.Mouse,
Time: tim,
Position: f32.Pt(textWidth(e, startPos.lineCol.Y, 0, startPos.lineCol.X), textHeight(e, startPos.lineCol.Y)),
Position: f32.Pt(textWidth(e, startPos.lineCol.line, 0, startPos.lineCol.col), textBaseline(e, startPos.lineCol.line)),
},
pointer.Event{
Type: pointer.Release,
Source: pointer.Mouse,
Time: tim,
Position: f32.Pt(textWidth(e, endPos.lineCol.Y, 0, endPos.lineCol.X), textHeight(e, endPos.lineCol.Y)),
Position: f32.Pt(textWidth(e, endPos.lineCol.line, 0, endPos.lineCol.col), textBaseline(e, endPos.lineCol.line)),
},
},
}
@@ -886,6 +888,15 @@ g 2 4 6 8 g
}
return ""
}
type screenPos image.Point
logicalPosMatch := func(t *testing.T, n int, label string, expected screenPos, actual combinedPos) {
t.Helper()
if actual.lineCol.line != expected.Y || actual.lineCol.col != expected.X {
t.Errorf("Test %d: Expected %s %#v; got %#v",
n, label,
expected, actual)
}
}
type testCase struct {
// input text offsets
@@ -916,15 +927,10 @@ g 2 4 6 8 g
gtx.Queue = nil
e.Layout(gtx, cache, font, fontSize, nil)
caretStart := e.closestPosition(combinedPos{runes: e.caret.start})
caretEnd := e.closestPosition(combinedPos{runes: e.caret.end})
if caretEnd.lineCol != tst.startPos || caretStart.lineCol != tst.endPos {
t.Errorf("Test %d pt2: Expected %#v, %#v; got %#v, %#v",
n,
caretEnd.lineCol, caretStart.lineCol,
tst.startPos, tst.endPos)
continue
}
caretStart := e.closestToRune(e.caret.start)
caretEnd := e.closestToRune(e.caret.end)
logicalPosMatch(t, n, "start", tst.startPos, caretEnd)
logicalPosMatch(t, n, "end", tst.endPos, caretStart)
}
}
@@ -937,7 +943,7 @@ func TestSelectMove(t *testing.T) {
Ops: new(op.Ops),
Locale: english,
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
font := text.Font{}
fontSize := unit.Sp(10)
@@ -1025,7 +1031,7 @@ func TestEditor_MaxLen(t *testing.T) {
key.SelectionEvent{Start: 4, End: 4},
),
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.Layout(gtx, cache, font, fontSize, nil)
@@ -1056,7 +1062,7 @@ func TestEditor_Filter(t *testing.T) {
key.SelectionEvent{Start: 4, End: 4},
),
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.Layout(gtx, cache, font, fontSize, nil)
@@ -1080,7 +1086,7 @@ func TestEditor_Submit(t *testing.T) {
key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "ab1\n"},
),
}
cache := text.NewCache(gofont.Collection())
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.Layout(gtx, cache, font, fontSize, nil)
@@ -1098,26 +1104,24 @@ func TestEditor_Submit(t *testing.T) {
}
}
// textWidth is a text helper for building simple selection events.
// It assumes single-run lines, which isn't safe with non-test text
// data.
func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 {
var w fixed.Int26_6
glyphs := e.lines[lineNum].Layout.Glyphs
if colEnd > len(glyphs) {
colEnd = len(glyphs)
start := e.closestToLineCol(lineNum, colStart)
end := e.closestToLineCol(lineNum, colEnd)
delta := start.x - end.x
if delta < 0 {
delta = -delta
}
for _, glyph := range glyphs[colStart:colEnd] {
w += glyph.XAdvance
}
return float32(w.Floor())
return float32(delta.Round())
}
func textHeight(e *Editor, lineNum int) float32 {
var h int
var prevDesc fixed.Int26_6
for _, line := range e.lines[0:lineNum] {
h += (line.Ascent + prevDesc).Ceil()
prevDesc = line.Descent
}
return float32(h + prevDesc.Ceil() + 1)
// testBaseline returns the y coordinate of the baseline for the
// given line number.
func textBaseline(e *Editor, lineNum int) float32 {
start := e.closestToLineCol(lineNum, 0)
return float32(start.y)
}
type testQueue struct {
@@ -1131,16 +1135,3 @@ func newQueue(e ...event.Event) *testQueue {
func (q *testQueue) Events(_ event.Tag) []event.Event {
return q.events
}
func printLines(e *Editor) {
for _, line := range e.lines {
start := e.runeOffset(line.Layout.Runes.Offset)
buf := make([]byte, 0, 4*line.Layout.Runes.Count)
e.Seek(int64(start), 0)
n, _ := e.Read(buf)
buf = buf[:n]
asStr := string([]rune(string(buf))[:line.Layout.Runes.Count])
text := strings.TrimSuffix(asStr, "\n")
fmt.Printf("%d: %s\n", n, text)
}
}
+392
View File
@@ -0,0 +1,392 @@
// 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 {
// 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
// We should insert new positions if the glyph we're processing terminates
// a glyph cluster.
insertPositionAfter := gl.Flags&text.FlagClusterBreak > 0
if gl.Flags&text.FlagSynthetic > 0 {
// Synthetic 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)
insertPositionAfter = false
}
// 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 an area of interest within shaped text.
type region struct {
// bounds is the coordinates of the bounding box in document space.
bounds image.Rectangle
// baseline is the quantity of vertical pixels between the baseline and
// the bottom of bounds.
baseline int
}
// 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.
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
}
}
}
}
}
return rects
}
+424
View File
@@ -0,0 +1,424 @@
package widget
import (
"testing"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"gioui.org/font/opentype"
"gioui.org/text"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed"
)
// makePosTestText returns two bidi samples of shaped text at the given
// font size and wrapped to the given line width. The runeLimit, if nonzero,
// truncates the sample text to ensure shorter output for expensive tests.
func makePosTestText(fontSize, lineWidth int, alignOpposite bool) (bidiLTR, bidiRTL []text.Glyph) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := text.NewShaper([]text.FontFace{
{
Font: text.Font{Typeface: "LTR"},
Face: ltrFace,
},
{
Font: text.Font{Typeface: "RTL"},
Face: rtlFace,
},
})
// bidiSource is crafted to contain multiple consecutive RTL runs (by
// changing scripts within the RTL).
bidiSource := "The quick سماء שלום لا fox تمط שלום غير the lazy dog."
ltrParams := text.Parameters{Font: text.Font{Typeface: "LTR"}, PxPerEm: fixed.I(fontSize)}
rtlParams := text.Parameters{Alignment: text.End, Font: text.Font{Typeface: "RTL"}, PxPerEm: fixed.I(fontSize)}
if alignOpposite {
ltrParams.Alignment = text.End
rtlParams.Alignment = text.Start
}
shaper.LayoutString(ltrParams, 0, lineWidth, english, bidiSource)
for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() {
bidiLTR = append(bidiLTR, g)
}
shaper.LayoutString(rtlParams, 0, lineWidth, arabic, bidiSource)
for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() {
bidiRTL = append(bidiRTL, g)
}
return bidiLTR, bidiRTL
}
// makeAccountingTestText shapes text designed to stress rune accounting
// logic within the index.
func makeAccountingTestText(fontSize, lineWidth int) (txt []text.Glyph) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := text.NewShaper([]text.FontFace{{
Font: text.Font{Typeface: "LTR"},
Face: ltrFace,
},
{
Font: text.Font{Typeface: "RTL"},
Face: rtlFace,
},
})
// bidiSource is crafted to contain multiple consecutive RTL runs (by
// changing scripts within the RTL).
bidiSource := "The\nquick سماء של\nום لا fox\nتمط של\nום."
params := text.Parameters{PxPerEm: fixed.I(fontSize)}
shaper.LayoutString(params, 0, lineWidth, english, bidiSource)
for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() {
txt = append(txt, g)
}
return txt
}
// TestIndexPositionBidi tests whether the index correct generates cursor positions for
// complex bidirectional text.
func TestIndexPositionBidi(t *testing.T) {
fontSize := 16
lineWidth := fontSize * 10
bidiLTRText, bidiRTLText := makePosTestText(fontSize, lineWidth, false)
type testcase struct {
name string
glyphs []text.Glyph
expectedXs []fixed.Int26_6
}
for _, tc := range []testcase{
{
name: "bidi ltr",
glyphs: bidiLTRText,
expectedXs: []fixed.Int26_6{
0, 626, 1196, 1766, 2051, 2621, 3191, 3444, 3956, 4468, 4753, 7133, 6330, 5738, 5440, 5019, 4753, // Positions on line 0.
3953, 3185, 2417, 1649, 881, 596, 298, 0, 3953, 4238, 4523, 5093, 5605, 5890, 7905, 7599, 7007, 6156, 5890, // Positions on line 1.
4660, 3892, 3124, 2356, 1588, 1303, 788, 406, 0, 4660, 4945, 5235, 5805, 6375, 6660, 6934, 7504, 8016, 8528, 8813, // Positions on line 2.
0, 570, 1140, 1710, 2034, // Positions on line 3.
},
},
{
name: "bidi rtl",
glyphs: bidiRTLText,
expectedXs: []fixed.Int26_6{
5368, 5994, 6564, 7134, 7419, 7989, 8559, 8812, 9324, 9836, 5368, 5102, 4299, 3707, 3409, 2988, 2722, 2108, 1494, 880, 266, 0, // Positions on line 0.
8801, 8503, 8205, 7939, 6572, 6857, 7427, 7939, 6572, 6306, 6000, 5408, 4557, 4291, 3677, 3063, 2449, 1835, 1569, 1054, 672, 266, 0, // Positions on line 1.
274, 564, 1134, 1704, 1989, 2263, 2833, 3345, 3857, 4142, 4712, 5282, 5852, 274, 0, // Positions on line 2.
},
},
} {
t.Run(tc.name, func(t *testing.T) {
var gi glyphIndex
for _, g := range tc.glyphs {
gi.Glyph(g)
}
if len(gi.positions) != len(tc.expectedXs) {
t.Errorf("expected %d positions, got %d", len(tc.expectedXs), len(gi.positions))
}
lastRunes := 0
lastLine := 0
lastCol := -1
lastY := 0
for i := 0; i < min(len(gi.positions), len(tc.expectedXs)); i++ {
actualX := gi.positions[i].x
expectedX := tc.expectedXs[i]
if actualX != expectedX {
t.Errorf("position %d: expected x=%v(%d), got x=%v(%d)", i, expectedX, expectedX, actualX, actualX)
}
if r := gi.positions[i].runes; r < lastRunes {
t.Errorf("position %d: expected runes >= %d, got %d", i, lastRunes, r)
}
lastRunes = gi.positions[i].runes
if y := gi.positions[i].y; y < lastY {
t.Errorf("position %d: expected y>= %d, got %d", i, lastY, y)
}
lastY = gi.positions[i].y
if y := gi.positions[i].y; y < lastY {
t.Errorf("position %d: expected y>= %d, got %d", i, lastY, y)
}
lastY = gi.positions[i].y
if lineCol := gi.positions[i].lineCol; lineCol.line == lastLine && lineCol.col < lastCol {
t.Errorf("position %d: expected col >= %d, got %d", i, lastCol, lineCol.col)
}
lastCol = gi.positions[i].lineCol.col
if line := gi.positions[i].lineCol.line; line < lastLine {
t.Errorf("position %d: expected line >= %d, got %d", i, lastLine, line)
}
lastLine = gi.positions[i].lineCol.line
}
printPositions(t, gi.positions)
if t.Failed() {
printGlyphs(t, tc.glyphs)
}
})
}
}
func TestIndexPositionLines(t *testing.T) {
fontSize := 16
lineWidth := fontSize * 10
bidiLTRText, bidiRTLText := makePosTestText(fontSize, lineWidth, false)
bidiLTRTextOpp, bidiRTLTextOpp := makePosTestText(fontSize, lineWidth, true)
type testcase struct {
name string
glyphs []text.Glyph
expectedLines []lineInfo
}
for _, tc := range []testcase{
{
name: "bidi ltr",
glyphs: bidiLTRText,
expectedLines: []lineInfo{
{
xOff: fixed.Int26_6(0),
yOff: 22,
glyphs: 15,
width: fixed.Int26_6(7133),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(0),
yOff: 56,
glyphs: 15,
width: fixed.Int26_6(7905),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(0),
yOff: 90,
glyphs: 18,
width: fixed.Int26_6(8813),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(0),
yOff: 117,
glyphs: 4,
width: fixed.Int26_6(2034),
ascent: fixed.Int26_6(968),
descent: fixed.Int26_6(216),
},
},
},
{
name: "bidi rtl",
glyphs: bidiRTLText,
expectedLines: []lineInfo{
{
xOff: fixed.Int26_6(0),
yOff: 22,
glyphs: 20,
width: fixed.Int26_6(9836),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(0),
yOff: 56,
glyphs: 19,
width: fixed.Int26_6(8801),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(0),
yOff: 90,
glyphs: 13,
width: fixed.Int26_6(5852),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
},
},
{
name: "bidi ltr opposite alignment",
glyphs: bidiLTRTextOpp,
expectedLines: []lineInfo{
{
xOff: fixed.Int26_6(3072),
yOff: 22,
glyphs: 15,
width: fixed.Int26_6(7133),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(2304),
yOff: 56,
glyphs: 15,
width: fixed.Int26_6(7905),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(1408),
yOff: 90,
glyphs: 18,
width: fixed.Int26_6(8813),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(8192),
yOff: 117,
glyphs: 4,
width: fixed.Int26_6(2034),
ascent: fixed.Int26_6(968),
descent: fixed.Int26_6(216),
},
},
},
{
name: "bidi rtl opposite alignment",
glyphs: bidiRTLTextOpp,
expectedLines: []lineInfo{
{
xOff: fixed.Int26_6(384),
yOff: 22,
glyphs: 20,
width: fixed.Int26_6(9836),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(1408),
yOff: 56,
glyphs: 19,
width: fixed.Int26_6(8801),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(4352),
yOff: 90,
glyphs: 13,
width: fixed.Int26_6(5852),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
var gi glyphIndex
for _, g := range tc.glyphs {
gi.Glyph(g)
}
if len(gi.lines) != len(tc.expectedLines) {
t.Errorf("expected %d lines, got %d", len(tc.expectedLines), len(gi.lines))
}
for i := 0; i < min(len(gi.lines), len(tc.expectedLines)); i++ {
actual := gi.lines[i]
expected := tc.expectedLines[i]
if actual != expected {
t.Errorf("line %d: expected:\n%#+v, got:\n%#+v", i, expected, actual)
}
}
})
}
}
// TestIndexPositionRunes checks for rune accounting errors in positions
// generated by the index.
func TestIndexPositionRunes(t *testing.T) {
fontSize := 16
lineWidth := fontSize * 10
testText := makeAccountingTestText(fontSize, lineWidth)
type testcase struct {
name string
glyphs []text.Glyph
expected []combinedPos
}
for _, tc := range []testcase{
{
name: "many newlines",
glyphs: testText,
expected: []combinedPos{
{runes: 0, lineCol: screenPos{line: 0, col: 0}, runIndex: 0, towardOrigin: false},
{runes: 1, lineCol: screenPos{line: 0, col: 1}, runIndex: 0, towardOrigin: false},
{runes: 2, lineCol: screenPos{line: 0, col: 2}, runIndex: 0, towardOrigin: false},
{runes: 3, lineCol: screenPos{line: 0, col: 3}, runIndex: 0, towardOrigin: false},
{runes: 4, lineCol: screenPos{line: 1, col: 0}, runIndex: 0, towardOrigin: false},
{runes: 5, lineCol: screenPos{line: 1, col: 1}, runIndex: 0, towardOrigin: false},
{runes: 6, lineCol: screenPos{line: 1, col: 2}, runIndex: 0, towardOrigin: false},
{runes: 7, lineCol: screenPos{line: 1, col: 3}, runIndex: 0, towardOrigin: false},
{runes: 8, lineCol: screenPos{line: 1, col: 4}, runIndex: 0, towardOrigin: false},
{runes: 9, lineCol: screenPos{line: 1, col: 5}, runIndex: 0, towardOrigin: false},
{runes: 10, lineCol: screenPos{line: 1, col: 6}, runIndex: 0, towardOrigin: false},
{runes: 10, lineCol: screenPos{line: 1, col: 6}, runIndex: 1, towardOrigin: true},
{runes: 11, lineCol: screenPos{line: 1, col: 7}, runIndex: 1, towardOrigin: true},
{runes: 12, lineCol: screenPos{line: 1, col: 8}, runIndex: 1, towardOrigin: true},
{runes: 13, lineCol: screenPos{line: 1, col: 9}, runIndex: 1, towardOrigin: true},
{runes: 14, lineCol: screenPos{line: 1, col: 10}, runIndex: 1, towardOrigin: true},
{runes: 15, lineCol: screenPos{line: 1, col: 11}, runIndex: 1, towardOrigin: true},
{runes: 16, lineCol: screenPos{line: 1, col: 12}, runIndex: 2, towardOrigin: true},
{runes: 17, lineCol: screenPos{line: 1, col: 13}, runIndex: 2, towardOrigin: true},
{runes: 18, lineCol: screenPos{line: 2, col: 0}, runIndex: 0, towardOrigin: true},
{runes: 19, lineCol: screenPos{line: 2, col: 1}, runIndex: 0, towardOrigin: true},
{runes: 20, lineCol: screenPos{line: 2, col: 2}, runIndex: 0, towardOrigin: true},
{runes: 21, lineCol: screenPos{line: 2, col: 3}, runIndex: 0, towardOrigin: true},
{runes: 22, lineCol: screenPos{line: 2, col: 4}, runIndex: 1, towardOrigin: true},
{runes: 23, lineCol: screenPos{line: 2, col: 5}, runIndex: 1, towardOrigin: true},
{runes: 24, lineCol: screenPos{line: 2, col: 6}, runIndex: 1, towardOrigin: true},
{runes: 24, lineCol: screenPos{line: 2, col: 6}, runIndex: 2, towardOrigin: false},
{runes: 25, lineCol: screenPos{line: 2, col: 7}, runIndex: 2, towardOrigin: false},
{runes: 26, lineCol: screenPos{line: 2, col: 8}, runIndex: 2, towardOrigin: false},
{runes: 27, lineCol: screenPos{line: 2, col: 9}, runIndex: 2, towardOrigin: false},
{runes: 28, lineCol: screenPos{line: 3, col: 0}, runIndex: 0, towardOrigin: true},
{runes: 29, lineCol: screenPos{line: 3, col: 1}, runIndex: 0, towardOrigin: true},
{runes: 30, lineCol: screenPos{line: 3, col: 2}, runIndex: 0, towardOrigin: true},
{runes: 31, lineCol: screenPos{line: 3, col: 3}, runIndex: 0, towardOrigin: true},
{runes: 32, lineCol: screenPos{line: 3, col: 4}, runIndex: 0, towardOrigin: true},
{runes: 33, lineCol: screenPos{line: 3, col: 5}, runIndex: 1, towardOrigin: true},
{runes: 34, lineCol: screenPos{line: 3, col: 6}, runIndex: 1, towardOrigin: true},
{runes: 35, lineCol: screenPos{line: 4, col: 0}, runIndex: 0, towardOrigin: true},
{runes: 36, lineCol: screenPos{line: 4, col: 1}, runIndex: 0, towardOrigin: true},
{runes: 37, lineCol: screenPos{line: 4, col: 2}, runIndex: 0, towardOrigin: true},
{runes: 38, lineCol: screenPos{line: 4, col: 3}, runIndex: 0, towardOrigin: true},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
var gi glyphIndex
for _, g := range tc.glyphs {
gi.Glyph(g)
}
if len(gi.positions) != len(tc.expected) {
t.Errorf("expected %d positions, got %d", len(tc.expected), len(gi.positions))
}
for i := 0; i < min(len(gi.positions), len(tc.expected)); i++ {
actual := gi.positions[i]
expected := tc.expected[i]
if expected.runes != actual.runes {
t.Errorf("position %d: expected runes=%d, got %d", i, expected.runes, actual.runes)
}
if expected.lineCol != actual.lineCol {
t.Errorf("position %d: expected lineCol=%v, got %v", i, expected.lineCol, actual.lineCol)
}
if expected.runIndex != actual.runIndex {
t.Errorf("position %d: expected runIndex=%d, got %d", i, expected.runIndex, actual.runIndex)
}
if expected.towardOrigin != actual.towardOrigin {
t.Errorf("position %d: expected towardOrigin=%v, got %v", i, expected.towardOrigin, actual.towardOrigin)
}
}
printPositions(t, gi.positions)
if t.Failed() {
printGlyphs(t, tc.glyphs)
}
})
}
}
func printPositions(t *testing.T, positions []combinedPos) {
t.Helper()
for i, p := range positions {
t.Logf("positions[%2d] = {runes: %2d, line: %2d, col: %2d, x: %5d, y: %3d}", i, p.runes, p.lineCol.line, p.lineCol.col, p.x, p.y)
}
}
func printGlyphs(t *testing.T, glyphs []text.Glyph) {
t.Helper()
for i, g := range glyphs {
t.Logf("glyphs[%2d] = {ID: 0x%013x, Flags: %4s, Advance: %4d(%6v), Runes: %d, Y: %3d, X: %4d(%6v)} ", i, g.ID, g.Flags, g.Advance, g.Advance, g.Runes, g.Y, g.X, g.X)
}
}
+82 -174
View File
@@ -3,11 +3,9 @@
package widget
import (
"fmt"
"image"
"gioui.org/io/semantic"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
@@ -26,190 +24,100 @@ type Label struct {
MaxLines int
}
// screenPos describes a character position (in text line and column numbers,
// not pixels): Y = line number, X = rune column.
type screenPos image.Point
const inf = 1e6
// posIsAbove returns whether the position described in pos by the lineCol and
// y fields is above the given y coordinate. It is invalid to call this function
// unless both the lineCol and (x,y) fields of pos are populated.
func posIsAbove(lines []text.Line, pos combinedPos, y int) bool {
line := lines[pos.lineCol.Y]
return pos.y+line.Bounds.Max.Y.Ceil() < y
}
// posIsAbove returns whether the position described in pos by the lineCol and
// y fields is below the given y coordinate. It is invalid to call this function
// unless both the lineCol and (x,y) fields of pos are populated.
func posIsBelow(lines []text.Line, pos combinedPos, y int) bool {
line := lines[pos.lineCol.Y]
return pos.y+line.Bounds.Min.Y.Floor() > y
}
func clipLine(lines []text.Line, alignment text.Alignment, width int, clip image.Rectangle, linePos combinedPos) (start combinedPos, end combinedPos) {
// Seek to first (potentially) visible column.
lineIdx := linePos.lineCol.Y
line := lines[lineIdx]
// runeWidth is the width of the widest rune in line.
runeWidth := (line.Bounds.Max.X - line.Width).Ceil()
lineStart := fixed.I(clip.Min.X - runeWidth)
lineEnd := fixed.I(clip.Max.X + runeWidth)
flip := line.Layout.Direction.Progression() == system.TowardOrigin
if flip {
lineStart, lineEnd = lineEnd, lineStart
}
q := combinedPos{y: start.y, x: lineStart}
start, _ = seekPosition(lines, alignment, width, linePos, q, 0)
// Seek to first invisible column after start.
q = combinedPos{y: start.y, x: lineEnd}
end, _ = seekPosition(lines, alignment, width, start, q, 0)
if flip {
start, end = end, start
}
return start, end
}
func subLayout(line text.Line, start, end combinedPos) text.Layout {
startCluster := clusterIndexFor(line, start.lineCol.X, start.clusterIndex)
endCluster := clusterIndexFor(line, end.lineCol.X, end.clusterIndex)
if line.Layout.Direction.Progression() == system.TowardOrigin {
startCluster, endCluster = endCluster, startCluster
}
return line.Layout.Slice(startCluster, endCluster)
}
// firstPos returns a combinedPos with *only* the x and y
// fields populated. They will be set to the location of the
// dot at the beginning of the line, with text alignment taken
// into account. For RTL text, this will
// be on the right edge of the available space.
//
// The results can be counterinuitive due to the fact that meaning
// of alignment changes depending on the text direction.
//
// The returned pos can be considered valid only for the first line
// of a body of text.
func firstPos(line text.Line, alignment text.Alignment, width int) combinedPos {
p := combinedPos{
x: align(alignment, line.Layout.Direction, line.Width, width),
y: line.Ascent.Ceil(),
}
if line.Layout.Direction.Progression() == system.TowardOrigin {
p.x += line.Width
}
return p
}
func (l Label) Layout(gtx layout.Context, s text.Shaper, font text.Font, size unit.Sp, txt string) layout.Dimensions {
func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, txt string) layout.Dimensions {
cs := gtx.Constraints
textSize := fixed.I(gtx.Sp(size))
lines := s.LayoutString(font, textSize, cs.Max.X, gtx.Locale, txt)
if max := l.MaxLines; max > 0 && len(lines) > max {
lines = lines[:max]
}
dims := linesDimens(lines)
dims.Size = cs.Constrain(dims.Size)
if len(lines) == 0 {
return dims
}
cl := textPadding(lines)
cl.Max = cl.Max.Add(dims.Size)
defer clip.Rect(cl).Push(gtx.Ops).Pop()
lt.LayoutString(text.Parameters{
Font: font,
PxPerEm: textSize,
MaxLines: l.MaxLines,
Alignment: l.Alignment,
}, cs.Min.X, cs.Max.X, gtx.Locale, txt)
m := op.Record(gtx.Ops)
viewport := image.Rectangle{Max: cs.Max}
it := textIterator{viewport: viewport}
semantic.LabelOp(txt).Add(gtx.Ops)
pos := firstPos(lines[0], l.Alignment, dims.Size.X)
for !posIsBelow(lines, pos, cl.Max.Y) {
start, end := clipLine(lines, l.Alignment, dims.Size.X, cl, pos)
line := lines[start.lineCol.Y]
lt := subLayout(line, start, end)
off := image.Point{X: start.x.Floor(), Y: start.y}
t := op.Offset(off).Push(gtx.Ops)
op := clip.Outline{Path: s.Shape(font, textSize, lt)}.Op().Push(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
op.Pop()
t.Pop()
if pos.lineCol.Y == len(lines)-1 {
break
var gs [32]text.Glyph
line := gs[:0]
var lineOff image.Point
for it.Glyph(lt.NextGlyph()) {
if it.visible {
if len(line) == 0 {
lineOff = image.Point{X: it.g.X.Floor(), Y: int(it.g.Y)}
}
line = append(line, it.g)
}
if it.g.Flags&text.FlagLineBreak > 0 || cap(line)-len(line) == 0 {
t := op.Offset(lineOff).Push(gtx.Ops)
op := clip.Outline{Path: lt.Shape(line)}.Op().Push(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
op.Pop()
t.Pop()
line = line[:0]
}
pos, _ = seekPosition(lines, l.Alignment, dims.Size.X, pos, combinedPos{lineCol: screenPos{Y: pos.lineCol.Y + 1}}, 0)
}
call := m.Stop()
viewport.Min = viewport.Min.Add(it.padding.Min)
viewport.Max = viewport.Max.Add(it.padding.Max)
clipStack := clip.Rect(viewport).Push(gtx.Ops)
call.Add(gtx.Ops)
dims := layout.Dimensions{Size: it.bounds.Size()}
dims.Size = cs.Constrain(dims.Size)
dims.Baseline = dims.Size.Y - it.baseline
clipStack.Pop()
return dims
}
func textPadding(lines []text.Line) (padding image.Rectangle) {
if len(lines) == 0 {
return
}
first := lines[0]
if d := first.Ascent + first.Bounds.Min.Y; d < 0 {
padding.Min.Y = d.Ceil()
}
last := lines[len(lines)-1]
if d := last.Bounds.Max.Y - last.Descent; d > 0 {
padding.Max.Y = d.Ceil()
}
if d := first.Bounds.Min.X; d < 0 {
padding.Min.X = d.Ceil()
}
if d := first.Bounds.Max.X - first.Width; d > 0 {
padding.Max.X = d.Ceil()
}
return
func r2p(r clip.Rect) clip.Op {
return clip.Stroke{Path: r.Path(), Width: 1}.Op()
}
func linesDimens(lines []text.Line) layout.Dimensions {
var width fixed.Int26_6
var h int
var baseline int
if len(lines) > 0 {
baseline = lines[0].Ascent.Ceil()
var prevDesc fixed.Int26_6
for _, l := range lines {
h += (prevDesc + l.Ascent).Ceil()
prevDesc = l.Descent
if l.Width > width {
width = l.Width
}
}
h += lines[len(lines)-1].Descent.Ceil()
}
w := width.Ceil()
return layout.Dimensions{
Size: image.Point{
X: w,
Y: h,
},
Baseline: h - baseline,
}
type textIterator struct {
g text.Glyph
viewport image.Rectangle
padding image.Rectangle
bounds image.Rectangle
visible bool
first bool
baseline int
}
// align returns the x offset that should be applied to text with width so that it
// appears correctly aligned within a space of size maxWidth and with the primary
// text direction dir.
func align(align text.Alignment, dir system.TextDirection, width fixed.Int26_6, maxWidth int) fixed.Int26_6 {
mw := fixed.I(maxWidth)
if dir.Progression() == system.TowardOrigin {
switch align {
case text.Start:
align = text.End
case text.End:
align = text.Start
}
func (t *textIterator) Glyph(g text.Glyph, ok bool) bool {
t.g = g
bounds := image.Rectangle{
Min: image.Pt(g.Bounds.Min.X.Floor(), g.Bounds.Min.Y.Floor()),
Max: image.Pt(g.Bounds.Max.X.Ceil(), g.Bounds.Max.Y.Ceil()),
}
switch align {
case text.Middle:
return fixed.I(((mw - width) / 2).Floor())
case text.End:
return fixed.I((mw - width).Floor())
case text.Start:
return 0
default:
panic(fmt.Errorf("unknown alignment %v", align))
// Compute the maximum extent to which glyphs overhang on the horizontal
// axis.
if d := g.Bounds.Min.X.Floor(); d < t.padding.Min.X {
t.padding.Min.X = d
}
if d := (g.Bounds.Max.X - g.Advance).Ceil(); d > t.padding.Max.X {
t.padding.Max.X = d
}
// Convert the bounds from dot-relative coordinates to document coordinates.
bounds = bounds.Add(image.Pt(g.X.Round(), int(g.Y)))
if !t.first {
t.first = true
t.baseline = int(g.Y)
t.bounds = bounds
}
above := bounds.Max.Y < t.viewport.Min.Y
below := bounds.Min.Y > t.viewport.Max.Y
left := bounds.Max.X < t.viewport.Min.X
right := bounds.Min.X > t.viewport.Max.X
t.visible = !above && !below && !left && !right
if t.visible {
t.bounds.Min.X = min(t.bounds.Min.X, bounds.Min.X)
t.bounds.Min.Y = min(t.bounds.Min.Y, int(g.Y)-g.Ascent.Ceil())
t.bounds.Max.X = max(t.bounds.Max.X, bounds.Max.X)
t.bounds.Max.Y = max(t.bounds.Max.Y, int(g.Y)+g.Descent.Ceil())
}
if t.bounds.Dy() == 0 {
t.bounds.Min.Y = -g.Ascent.Ceil()
t.bounds.Max.Y = g.Descent.Ceil()
}
return ok && !below
}
+1 -1
View File
@@ -28,7 +28,7 @@ type ButtonStyle struct {
CornerRadius unit.Dp
Inset layout.Inset
Button *widget.Clickable
shaper text.Shaper
shaper *text.Shaper
}
type ButtonLayoutStyle struct {
+1 -1
View File
@@ -22,7 +22,7 @@ type checkable struct {
TextSize unit.Sp
IconColor color.NRGBA
Size unit.Dp
shaper text.Shaper
shaper *text.Shaper
checkedStateIcon *widget.Icon
uncheckedStateIcon *widget.Icon
}
+1 -1
View File
@@ -28,7 +28,7 @@ type EditorStyle struct {
SelectionColor color.NRGBA
Editor *widget.Editor
shaper text.Shaper
shaper *text.Shaper
}
func Editor(th *Theme, editor *widget.Editor, hint string) EditorStyle {
+1 -1
View File
@@ -24,7 +24,7 @@ type LabelStyle struct {
Text string
TextSize unit.Sp
shaper text.Shaper
shaper *text.Shaper
}
func H1(th *Theme, txt string) LabelStyle {
+2 -2
View File
@@ -32,7 +32,7 @@ type Palette struct {
}
type Theme struct {
Shaper text.Shaper
Shaper *text.Shaper
Palette
TextSize unit.Sp
Icon struct {
@@ -48,7 +48,7 @@ type Theme struct {
func NewTheme(fontCollection []text.FontFace) *Theme {
t := &Theme{
Shaper: text.NewCache(fontCollection),
Shaper: text.NewShaper(fontCollection),
}
t.Palette = Palette{
Fg: rgb(0x000000),
@@ -0,0 +1,4 @@
go test fuzz v1
string("\xa3\xd90")
int16(-11)
int16(2237)
@@ -0,0 +1,4 @@
go test fuzz v1
string("")
int16(44)
int16(2299)
@@ -0,0 +1,4 @@
go test fuzz v1
string("و سأعر مثpل dolor sitamet, لم يتحم ج د adipscin eit, seddo الحصول علىpمhزة nرidiإun utNlaboe أ يتد magna aliqua.\nPorotit.r رادتهم فيتسوي rbi non aنcuيدكن ما يعقبها .\nNibhiنشجب ورستنكر ommodo nulla\nبكل سهولة ورونة ut consquat لهذا، من من .nam liber jظsto.يRisعs n hمndrerit لينا اضواجبالعمل.نNatoqe تكون Lدتا علي magis disparturient يخسك زمم الأمور ويخرار.\nIn جد ما يsنعنeu أcelerisquecونرا للاتزامات التي ةer entum.إMattis شرط وعندما لا neلue viverra.\nيمسكrزام الأمور abaan لهذر، .\nNisl تي يفض ا علينانfaucًbus ،من منا rm nec.\nSedكauue rلي الاختيار غر vitacongue eu nsequat.\nAt quis risus ك زمام الأمخر ومتا.tSit amet volutpat conseqt maسrisالأمو يiتارثإما nisi.\nDiguissim واجبوالعdل tincidunt نتنمزل feugiatn\nFauلibus التزاماتin eu m bإbendum.رOdio وcخرار إما أن ير صاسر السعادة ned adpiscig ذا، منeنا لم tistiue.\nFeretum leo vel ور ويختور ما pulvi\nr\nUtرإما أ يرفض صادر الmعادة من in metus تlون قدرتوايعليقgىelis imeodلet.\nي التتgر غي مقيدةمبشر et mنمsuada iamمs acنturpis.\nVennatis عل ميزة أو فائدة؟ ولكن oege nuc scele pque سزام ولأمو ويختار ما in.\nرتنا ultricieوtristique ي لااتيار غير قيدة بشرط enim trtor.\nRisus اختيiر غير قيدةeشط عندما quam سان الحكيم عليه أن .suspedsse in.Interمm vاlit نظرا للالتزام التي pellentesue massa pعaceiat ل مرضويتار إما أن يرفض acus.nProin دا تكون قدرتنا علي الاختيار lec us a.\nAuqtor الوقم عيدا تون aegue neque مال q fermentm et.\nطمeet ماك زام الأمور uيختار يmet cursuم لم يتحملجهدو نictum.uIn\nfermetum et sollicitudin ac orci nhبsellus علo الاخت.ا غ rutrum\nTemus mperdietلالمفترض أن.نفرق pellentesque ت بك سهو ة eget raidaم\nرonsequat id prvaمصادا السادة cras ned.\nVlputat رعلي الاختيارغير قيدة sitاamet aliquam\nmongue mauis حيمن ونظراً للالتزاnات التي elelit.\nRgsus qeis vهrius اuam quisque id ار غير قدة بشرط elementumوPreti m تي يفرضهاعuينا الاجب io in vitee.\n شاق إلا مب أل retium qua احكيم عليه أنsيمسك suspendss in et.Vlit ونظراً للالتزامات التي يفرض ultrice .\n الوقت عندما تكون velit dinissim يه ن يمس .\nرnc sclerisque vverramauris inلaliquam sim ً إا أن ut.\nالسعادt ك أجل مl هو أكثر أهمية أو يتحل الألم\nConvillis pفsuere morbi leo una molestie at.")
int16(613)
int16(1976)
@@ -0,0 +1,4 @@
go test fuzz v1
string("د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ\nد بوولنكcmme ul\nبsسة uولnوuoeaهnا،من mن tn libeo jtsteاisusهin hen عnنادmب eلعملNaلeسكنعقرنا علي ااs diلre rinti مسl زي.pلrور و \nار.تn ندمم نا e sc\nlلrsueونً Tالت mاL التمeزe.\nMaيit e و وكدمl مfeierta.\nمسmزامhالر قhi tnoلهاموoرNu تrFر ع ي اauipus ،i نالمneauemiاm علنتنارغيرvitas au c n eqnt.\nAtuسuخ sبمام grأور ختaغ.\n mee v utهateس e alاmis\nأمأاكيخاار إماsi.igmi واجب ا rتnتunt iنتنزiueلia.ضةaucb التتا n eu mi رمndكOd ايي. اإهنيرe مدرلخ اد sd ndiاun ذ من مالe triلu.iقmcnoui أo وaوياوإ.pulvinar.\nrت إماأ يرف مصSاfعدمنiو مts iن ورتنم eliاieيرt.\nي ا شغرحميمبط ettlsuda apeاsa turqe.Veneتtisiع ميةوأو sة؟ كن eرncaيliuatم امnر ايتارإga uemاutrci biqu ً.لiيار ي مlي لطen اtو or\nRssخياoغيراميe شmcصناو uaيويمعله دس su.endiRseini\nnrاie\nt نرللسنز يات لتي pelqe iact لأمو را تريض macsmPoاا ا نأSدتن امدrت م lntuا u.\nAuatr سيتiياtجون ut nequ ضيى eo seض etumثtجaoee مسl tم.مuأيت وmt crrsغ c sمs اdictn.I أ,entrm Doiiludin c iaseleusيعلالختيررر rutrmmيmp mprieh فeرةa ن dف\nesمeldمsue يdكل امrخd egt i a eu.uCs uaن drtaمشدر لعدةشrنeظ\nVlpuatrعي pختيارهmرcمقيoةit am t اleua.\n ongulmaقisين.نnرل لللتز لتي م.\niousqui varNs اeل uiue ld uريحميcن برeneeum\nPrtزm يفرضا عاالاجب ن sيeشدelممن جل prstiتiqu rا\nحكlم ل ها،نك uspe duse اn ًaنeliت ً رازv لتي رفfيىutلise lsق oدمتكونيvelidgsدc tvle sNe أscاplsqنeمvشساtuتsرin aliqطi e ر إمدأنةقut لع ن أجuلما ه vرمة tصتحماللم\nConveli posمeل er leرr mلee .")
int16(1228)
int16(782)
+196 -116
View File
@@ -4,15 +4,19 @@ import (
"fmt"
"image"
"math/rand"
"os"
"sort"
"testing"
"time"
"gioui.org/font/gofont"
"gioui.org/gpu/headless"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
"golang.org/x/exp/maps"
)
var (
@@ -33,142 +37,218 @@ func init() {
rand.Seed(int64(time.Now().Nanosecond()))
}
func BenchmarkLabelStatic(b *testing.B) {
func runBenchmarkPermutations(b *testing.B, benchmark func(b *testing.B, runes int, locale system.Locale, document string)) {
docKeys := maps.Keys(documents)
sort.Strings(docKeys)
for _, locale := range locales {
for _, runes := range sizes {
for textType, txt := range documents {
for _, textType := range docKeys {
txt := documents[textType]
b.Run(fmt.Sprintf("%drunes-%s-%s", runes, locale.Direction, textType), func(b *testing.B) {
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: image.Pt(200, 1000),
},
Locale: locale,
}
cache := text.NewCache(benchFonts)
fontSize := unit.Sp(10)
font := text.Font{}
runes := []rune(txt)[:runes]
runesStr := string(runes)
l := Label{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Layout(gtx, cache, font, fontSize, runesStr)
gtx.Ops.Reset()
}
benchmark(b, runes, locale, txt)
})
}
}
}
}
var render bool
func init() {
if _, ok := os.LookupEnv("RENDER_WIDGET_TESTS"); ok {
render = true
}
}
func BenchmarkLabelStatic(b *testing.B) {
runBenchmarkPermutations(b, func(b *testing.B, runeCount int, locale system.Locale, txt string) {
var win *headless.Window
size := image.Pt(200, 1000)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: size,
},
Locale: locale,
}
cache := text.NewShaper(benchFonts)
if render {
win, _ = headless.NewWindow(size.X, size.Y)
defer win.Release()
}
fontSize := unit.Sp(10)
font := text.Font{}
runes := []rune(txt)[:runeCount]
runesStr := string(runes)
l := Label{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Layout(gtx, cache, font, fontSize, runesStr)
if render {
win.Frame(gtx.Ops)
}
gtx.Ops.Reset()
}
})
}
func BenchmarkLabelDynamic(b *testing.B) {
for _, locale := range locales {
for _, runes := range sizes {
for textType, txt := range documents {
b.Run(fmt.Sprintf("%drunes-%s-%s", runes, locale.Direction, textType), func(b *testing.B) {
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: image.Pt(200, 1000),
},
Locale: locale,
}
cache := text.NewCache(benchFonts)
fontSize := unit.Sp(10)
font := text.Font{}
runes := []rune(txt)[:runes]
l := Label{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// simulate a constantly changing string
a := rand.Intn(len(runes))
b := rand.Intn(len(runes))
runes[a], runes[b] = runes[b], runes[a]
l.Layout(gtx, cache, font, fontSize, string(runes))
gtx.Ops.Reset()
}
})
}
runBenchmarkPermutations(b, func(b *testing.B, runeCount int, locale system.Locale, txt string) {
var win *headless.Window
size := image.Pt(200, 1000)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: size,
},
Locale: locale,
}
}
cache := text.NewShaper(benchFonts)
if render {
win, _ = headless.NewWindow(size.X, size.Y)
defer win.Release()
}
fontSize := unit.Sp(10)
font := text.Font{}
runes := []rune(txt)[:runeCount]
l := Label{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// simulate a constantly changing string
a := rand.Intn(len(runes))
b := rand.Intn(len(runes))
runes[a], runes[b] = runes[b], runes[a]
l.Layout(gtx, cache, font, fontSize, string(runes))
if render {
win.Frame(gtx.Ops)
}
gtx.Ops.Reset()
}
})
}
func BenchmarkEditorStatic(b *testing.B) {
for _, locale := range locales {
for _, runes := range sizes {
for textType, txt := range documents {
b.Run(fmt.Sprintf("%drunes-%s-%s", runes, locale.Direction, textType), func(b *testing.B) {
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: image.Pt(200, 1000),
},
Locale: locale,
}
cache := text.NewCache(benchFonts)
fontSize := unit.Sp(10)
font := text.Font{}
runes := []rune(txt)[:runes]
runesStr := string(runes)
e := Editor{}
e.SetText(runesStr)
b.ResetTimer()
for i := 0; i < b.N; i++ {
e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions {
e.PaintSelection(gtx)
e.PaintText(gtx)
e.PaintCaret(gtx)
return layout.Dimensions{Size: gtx.Constraints.Min}
})
gtx.Ops.Reset()
}
})
}
runBenchmarkPermutations(b, func(b *testing.B, runeCount int, locale system.Locale, txt string) {
var win *headless.Window
size := image.Pt(200, 1000)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: size,
},
Locale: locale,
}
}
cache := text.NewShaper(benchFonts)
if render {
win, _ = headless.NewWindow(size.X, size.Y)
defer win.Release()
}
fontSize := unit.Sp(10)
font := text.Font{}
runes := []rune(txt)[:runeCount]
runesStr := string(runes)
e := Editor{}
e.SetText(runesStr)
b.ResetTimer()
for i := 0; i < b.N; i++ {
e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions {
e.PaintSelection(gtx)
e.PaintText(gtx)
e.PaintCaret(gtx)
return layout.Dimensions{Size: gtx.Constraints.Min}
})
if render {
win.Frame(gtx.Ops)
}
gtx.Ops.Reset()
}
})
}
func BenchmarkEditorDynamic(b *testing.B) {
for _, locale := range locales {
for _, runes := range sizes {
for textType, txt := range documents {
b.Run(fmt.Sprintf("%drunes-%s-%s", runes, locale.Direction, textType), func(b *testing.B) {
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: image.Pt(200, 1000),
},
Locale: locale,
}
cache := text.NewCache(benchFonts)
fontSize := unit.Sp(10)
font := text.Font{}
runes := []rune(txt)[:runes]
e := Editor{}
e.SetText(string(runes))
b.ResetTimer()
for i := 0; i < b.N; i++ {
// simulate a constantly changing string
a := rand.Intn(e.Len())
b := rand.Intn(e.Len())
e.SetCaret(a, a+1)
takeStr := e.SelectedText()
e.Insert("")
e.SetCaret(b, b)
e.Insert(takeStr)
e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions {
e.PaintSelection(gtx)
e.PaintText(gtx)
e.PaintCaret(gtx)
return layout.Dimensions{Size: gtx.Constraints.Min}
})
gtx.Ops.Reset()
}
})
}
runBenchmarkPermutations(b, func(b *testing.B, runeCount int, locale system.Locale, txt string) {
var win *headless.Window
size := image.Pt(200, 1000)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: size,
},
Locale: locale,
}
cache := text.NewShaper(benchFonts)
if render {
win, _ = headless.NewWindow(size.X, size.Y)
defer win.Release()
}
fontSize := unit.Sp(10)
font := text.Font{}
runes := []rune(txt)[:runeCount]
e := Editor{}
e.SetText(string(runes))
b.ResetTimer()
for i := 0; i < b.N; i++ {
// simulate a constantly changing string
a := rand.Intn(e.Len())
b := rand.Intn(e.Len())
e.SetCaret(a, a+1)
takeStr := e.SelectedText()
e.Insert("")
e.SetCaret(b, b)
e.Insert(takeStr)
e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions {
e.PaintSelection(gtx)
e.PaintText(gtx)
e.PaintCaret(gtx)
return layout.Dimensions{Size: gtx.Constraints.Min}
})
if render {
win.Frame(gtx.Ops)
}
gtx.Ops.Reset()
}
})
}
func FuzzEditorEditing(f *testing.F) {
f.Add(complexDocument, int16(0), int16(len([]rune(complexDocument))))
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: image.Pt(200, 1000),
},
Locale: arabic,
}
cache := text.NewShaper(benchFonts)
fontSize := unit.Sp(10)
font := text.Font{}
e := Editor{}
f.Fuzz(func(t *testing.T, txt string, replaceFrom, replaceTo int16) {
e.SetText(txt)
e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions {
e.PaintSelection(gtx)
e.PaintText(gtx)
e.PaintCaret(gtx)
return layout.Dimensions{Size: gtx.Constraints.Min}
})
// simulate a constantly changing string
if e.Len() > 0 {
a := int(replaceFrom) % e.Len()
b := int(replaceTo) % e.Len()
e.SetCaret(a, a+1)
takeStr := e.SelectedText()
e.Insert("")
e.SetCaret(b, b)
e.Insert(takeStr)
}
e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions {
e.PaintSelection(gtx)
e.PaintText(gtx)
e.PaintCaret(gtx)
return layout.Dimensions{Size: gtx.Constraints.Min}
})
gtx.Ops.Reset()
})
}
const (
-528
View File
@@ -1,528 +0,0 @@
package widget
import (
"math"
"strconv"
"testing"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"gioui.org/font/opentype"
"gioui.org/io/system"
"gioui.org/text"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed"
)
// makeTestText returns an ltr and rtl sample of shaped text at the given
// font size and wrapped to the given line width. The runeLimit, if nonzero,
// truncates the sample text to ensure shorter output for expensive tests.
func makeTestText(fontSize, lineWidth, runeLimit int) ([]text.Line, []text.Line) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := text.NewCache([]text.FontFace{
{
Font: text.Font{Typeface: "LTR"},
Face: ltrFace,
},
{
Font: text.Font{Typeface: "RTL"},
Face: rtlFace,
},
})
ltrSource := "The quick brown fox\njumps over the lazy dog."
rtlSource := "الحب سماء لا\nتمط غير الأحلام"
if runeLimit != 0 {
ltrRunes := []rune(ltrSource)
rtlRunes := []rune(rtlSource)
if runeLimit < len(ltrRunes) {
ltrSource = string(ltrRunes[:runeLimit])
}
if runeLimit < len(rtlRunes) {
rtlSource = string(rtlRunes[:runeLimit])
}
}
ltrText := shaper.LayoutString(text.Font{Typeface: "LTR"}, fixed.I(fontSize), lineWidth, english, ltrSource)
rtlText := shaper.LayoutString(text.Font{Typeface: "RTL"}, fixed.I(fontSize), lineWidth, arabic, rtlSource)
return ltrText, rtlText
}
func TestFirstPos(t *testing.T) {
fontSize := 16
lineWidth := fontSize * 10
ltrText, rtlText := makeTestText(fontSize, lineWidth, 0)
type testcase struct {
name string
line text.Line
xAlignMap map[text.Alignment]map[int]fixed.Int26_6
expected combinedPos
}
for _, tc := range []testcase{
{
name: "ltr line 0",
line: ltrText[0],
xAlignMap: map[text.Alignment]map[int]fixed.Int26_6{
text.Start: {
lineWidth: 0,
lineWidth * 2: 0,
},
text.Middle: {
lineWidth: fixed.I(8),
lineWidth * 2: fixed.I(88),
},
text.End: {
lineWidth: fixed.I(16),
lineWidth * 2: fixed.I(176),
},
},
expected: combinedPos{
x: 0,
y: ltrText[0].Ascent.Ceil(),
},
},
{
name: "ltr line 1",
line: ltrText[1],
xAlignMap: map[text.Alignment]map[int]fixed.Int26_6{
text.Start: {
lineWidth: 0,
lineWidth * 2: 0,
},
text.Middle: {
lineWidth: fixed.I(8),
lineWidth * 2: fixed.I(88),
},
text.End: {
lineWidth: fixed.I(16),
lineWidth * 2: fixed.I(176),
},
},
expected: combinedPos{
x: 0,
y: ltrText[1].Ascent.Ceil(),
},
},
{
name: "rtl line 0",
line: rtlText[0],
xAlignMap: map[text.Alignment]map[int]fixed.Int26_6{
text.End: {
lineWidth: rtlText[0].Width,
lineWidth * 2: rtlText[0].Width,
},
text.Middle: {
lineWidth: fixed.Int26_6(7827),
lineWidth * 2: fixed.Int26_6(12947),
},
text.Start: {
lineWidth: fixed.Int26_6(10195),
lineWidth * 2: fixed.Int26_6(20435),
},
},
expected: combinedPos{
x: 0,
y: rtlText[0].Ascent.Ceil(),
},
},
{
name: "rtl line 1",
line: rtlText[1],
xAlignMap: map[text.Alignment]map[int]fixed.Int26_6{
text.End: {
lineWidth: rtlText[1].Width,
lineWidth * 2: rtlText[1].Width,
},
text.Middle: {
lineWidth: fixed.Int26_6(8184),
lineWidth * 2: fixed.Int26_6(13304),
},
text.Start: {
lineWidth: fixed.Int26_6(10232),
lineWidth * 2: fixed.Int26_6(20472),
},
},
expected: combinedPos{
x: 0,
y: rtlText[1].Ascent.Ceil(),
},
},
} {
for align, cases := range tc.xAlignMap {
for width, expectedX := range cases {
t.Run(tc.name+" "+align.String()+" "+strconv.Itoa(width), func(t *testing.T) {
actual := firstPos(tc.line, align, width)
tc.expected.x = expectedX
if tc.expected.x != actual.x {
t.Errorf("expected x=%s(%d), got %s(%d)", tc.expected.x, tc.expected.x, actual.x, actual.x)
}
if tc.expected.y != actual.y {
t.Errorf("expected y=%d(%d), got %d(%d)", tc.expected.y, tc.expected.y, actual.y, actual.y)
}
})
}
}
}
}
func TestIncrementPosition(t *testing.T) {
fontSize := 16
lineWidth := fontSize * 3
ltrText, rtlText := makeTestText(fontSize, lineWidth, 0)
type trial struct {
input, output combinedPos
}
type testcase struct {
name string
align text.Alignment
width int
lines []text.Line
firstInput combinedPos
check func(t *testing.T, iteration int, input, output combinedPos, end bool)
}
for _, tc := range []testcase{
{
name: "ltr",
align: text.Start,
width: lineWidth,
lines: ltrText,
firstInput: firstPos(ltrText[0], text.Start, lineWidth),
},
{
name: "rtl",
align: text.Start,
width: lineWidth,
lines: rtlText,
firstInput: firstPos(rtlText[0], text.Start, lineWidth),
},
} {
t.Run(tc.name, func(t *testing.T) {
input := tc.firstInput
for i := 0; true; i++ {
output, end := incrementPosition(tc.lines, tc.align, tc.width, input)
finalRunes := tc.lines[len(tc.lines)-1].Layout.Runes
finalRune := finalRunes.Count + finalRunes.Offset
if end && output.runes != finalRune {
t.Errorf("iteration %d ended prematurely. Has runes %d, expected %d", i, output.runes, finalRune)
}
if end {
break
}
if input == output {
t.Errorf("iteration %d: identical output:\ninput: %#+v\noutput: %#+v", i, input, output)
}
// We should always advance on either the X or Y axis.
if input.y == output.y {
expectedAdvance := tc.lines[input.lineCol.Y].Layout.Clusters[input.clusterIndex].Advance != 0
rtl := tc.lines[input.lineCol.Y].Layout.Direction.Progression() == system.TowardOrigin
if expectedAdvance {
if (rtl && input.x <= output.x) || (!rtl && input.x >= output.x) {
t.Errorf("iteration %d advanced the wrong way on x axis: input %v(%d) output %v(%d)", i, input.x, input.x, output.x, output.x)
}
} else if input.x != output.x {
t.Errorf("iteration %d advanced x axis when it should not have: input %v(%d) output %v(%d)", i, input.x, input.x, output.x, output.x)
}
// If we stayed on the same line, the line-local rune count should
// be incremented.
if input.lineCol.X >= output.lineCol.X {
t.Errorf("iteration %d advanced lineCol.X incorrectly: input %d output %d", i, input.lineCol.X, output.lineCol.X)
}
// We don't necessarily increment clusters every time, but it should never
// go down.
if input.clusterIndex > output.clusterIndex {
t.Errorf("iteration %d advanced clusterIndex incorrectly: input %d output %d", i, input.clusterIndex, output.clusterIndex)
}
if output.runes != input.runes+1 {
t.Errorf("iteration %d advanced runes incorrectly: input %d output %d", i, input.runes, output.runes)
}
} else {
if input.y >= output.y {
t.Errorf("iteration %d advanced the wrong way on y axis: input %v(%d) output %v(%d)", i, input.y, input.y, output.y, output.y)
} else {
// We correctly advanced on Y axis, so X should be reset to "start of line"
// for the text direction.
rtl := tc.lines[input.lineCol.Y].Layout.Direction.Progression() == system.TowardOrigin
if (rtl && input.x >= output.x) || (!rtl && input.x <= output.x) {
t.Errorf("iteration %d reset x axis incorrectly: input %v(%d) output %v(%d)", i, input.x, input.x, output.x, output.x)
}
}
if input.lineCol.Y >= output.lineCol.Y {
t.Errorf("iteration %d advanced lineCol.Y incorrectly: input %d output %d", i, input.lineCol.Y, output.lineCol.Y)
}
if output.clusterIndex != 0 {
t.Errorf("iteration %d should have zeroed clusterIndex, got: %d", i, output.clusterIndex)
}
if output.lineCol.X != 0 {
t.Errorf("iteration %d should have zeroed lineCol.X, got: %d", i, output.lineCol.X)
}
}
input = output
}
})
}
}
func TestClusterIndexFor(t *testing.T) {
fontSize := 16
lineWidth := fontSize * 3
ltrText, rtlText := makeTestText(fontSize, lineWidth, 0)
type input struct {
runeIdx int
clusterStartIdx int
expected int
panics bool
}
type testcase struct {
name string
line text.Line
inputs []input
}
for _, tc := range []testcase{
{
name: "ltr",
line: ltrText[0],
inputs: []input{
{runeIdx: 0, clusterStartIdx: 0, expected: 0},
{runeIdx: 1, clusterStartIdx: 0, expected: 1},
{runeIdx: 1, clusterStartIdx: 1, expected: 1},
{runeIdx: 1, clusterStartIdx: 2, panics: true},
{runeIdx: 2, clusterStartIdx: 0, expected: 2},
{runeIdx: 2, clusterStartIdx: 1, expected: 2},
{runeIdx: 2, clusterStartIdx: 2, expected: 2},
{runeIdx: 3, clusterStartIdx: 0, expected: 3},
{runeIdx: 3, clusterStartIdx: 1, expected: 3},
{runeIdx: 3, clusterStartIdx: 2, expected: 3},
{runeIdx: 3, clusterStartIdx: 3, expected: 3},
{runeIdx: 4, clusterStartIdx: 0, expected: 4},
{runeIdx: 4, clusterStartIdx: 1, expected: 4},
{runeIdx: 4, clusterStartIdx: 2, expected: 4},
{runeIdx: 4, clusterStartIdx: 3, expected: 4},
{runeIdx: 4, clusterStartIdx: 4, expected: 4},
{runeIdx: 5, panics: true},
},
},
{
name: "rtl",
line: rtlText[0],
inputs: []input{
{runeIdx: 0, clusterStartIdx: 0, expected: 0},
{runeIdx: 1, clusterStartIdx: 0, expected: 1},
{runeIdx: 1, clusterStartIdx: 1, expected: 1},
{runeIdx: 1, clusterStartIdx: 2, panics: true},
{runeIdx: 2, clusterStartIdx: 0, expected: 2},
{runeIdx: 2, clusterStartIdx: 1, expected: 2},
{runeIdx: 2, clusterStartIdx: 2, expected: 2},
{runeIdx: 3, clusterStartIdx: 0, expected: 3},
{runeIdx: 3, clusterStartIdx: 1, expected: 3},
{runeIdx: 3, clusterStartIdx: 2, expected: 3},
{runeIdx: 3, clusterStartIdx: 3, expected: 3},
{runeIdx: 4, clusterStartIdx: 0, expected: 4},
{runeIdx: 4, clusterStartIdx: 1, expected: 4},
{runeIdx: 4, clusterStartIdx: 2, expected: 4},
{runeIdx: 4, clusterStartIdx: 3, expected: 4},
{runeIdx: 4, clusterStartIdx: 4, expected: 4},
{runeIdx: 5, clusterStartIdx: 0, expected: 5},
{runeIdx: 6, panics: true},
},
},
{
name: "rtl-ligatures",
line: rtlText[4],
inputs: []input{
{runeIdx: 0, clusterStartIdx: 0, expected: 0},
{runeIdx: 1, clusterStartIdx: 0, expected: 1},
{runeIdx: 1, clusterStartIdx: 1, expected: 1},
{runeIdx: 2, clusterStartIdx: 0, expected: 1},
{runeIdx: 2, clusterStartIdx: 1, expected: 1},
{runeIdx: 2, clusterStartIdx: 2, panics: true},
{runeIdx: 3, clusterStartIdx: 0, expected: 2},
{runeIdx: 3, clusterStartIdx: 1, expected: 2},
{runeIdx: 3, clusterStartIdx: 2, expected: 2},
{runeIdx: 4, clusterStartIdx: 0, expected: 3},
{runeIdx: 4, clusterStartIdx: 1, expected: 3},
{runeIdx: 4, clusterStartIdx: 2, expected: 3},
{runeIdx: 4, clusterStartIdx: 3, expected: 3},
{runeIdx: 5, clusterStartIdx: 0, expected: 3},
{runeIdx: 5, clusterStartIdx: 1, expected: 3},
{runeIdx: 5, clusterStartIdx: 2, expected: 3},
{runeIdx: 5, clusterStartIdx: 3, expected: 3},
{runeIdx: 6, clusterStartIdx: 0, expected: 4},
{runeIdx: 6, clusterStartIdx: 1, expected: 4},
{runeIdx: 6, clusterStartIdx: 2, expected: 4},
{runeIdx: 6, clusterStartIdx: 3, expected: 4},
{runeIdx: 6, clusterStartIdx: 4, expected: 4},
{runeIdx: 7, clusterStartIdx: 0, expected: 5},
{runeIdx: 8, panics: true},
},
},
} {
for i, input := range tc.inputs {
t.Run(tc.name+strconv.Itoa(i), func(t *testing.T) {
defer func() {
err := recover()
if err != nil != input.panics {
t.Errorf("panic state mismatch")
}
}()
actual := clusterIndexFor(tc.line, input.runeIdx, input.clusterStartIdx)
if actual != input.expected {
t.Errorf("input[%d]: expected %d, got %d", i, input.expected, actual)
}
})
}
}
}
func TestPositionGreaterOrEqual(t *testing.T) {
fontSize := 16
lineWidth := fontSize * 10
// Be careful tuning the runeLimit here. This test case's complexity
// is O(N^2) where N=runeLimit. It's easy to make this test take a stupid
// amount of time accidentally.
ltrText, rtlText := makeTestText(fontSize, lineWidth, 15)
type testcase struct {
name string
lines []text.Line
align text.Alignment
width int
}
for _, tc := range []testcase{
{
name: "ltr",
lines: ltrText,
align: text.Start,
width: lineWidth,
},
{
name: "rtl",
lines: rtlText,
align: text.Start,
width: lineWidth,
},
} {
finalLineRunes := tc.lines[len(tc.lines)-1].Layout.Runes
// Statically generate all valid positions.
positions := make([]combinedPos, finalLineRunes.Offset+finalLineRunes.Count+1)
positions[0] = firstPos(tc.lines[0], tc.align, tc.width)
for i := 1; i < len(positions); i++ {
positions[i], _ = incrementPosition(tc.lines, tc.align, tc.width, positions[i-1])
}
// For each valid position, check every other valid position returns the correct
// result with each permutation of populated fields.
for i, p1 := range positions {
for k, p2 := range positions {
t.Run(tc.name+" "+strconv.Itoa(i)+">="+strconv.Itoa(k), func(t *testing.T) {
for kind := 0; kind < 3; kind++ {
p2 := p2
transform := ""
switch kind {
case 0: // only runes populated
transform = "runes only"
p2.lineCol = screenPos{}
p2.x = 0
p2.y = 0
case 1: // only lineCol populated
transform = "lineCol only"
p2.runes = 0
p2.x = 0
p2.y = 0
case 2: // only x and y populated
transform = "x,y only"
p2.runes = 0
p2.lineCol = screenPos{}
}
isGreaterOrEqual := i >= k
result := positionGreaterOrEqual(tc.lines, p1, p2)
if result != isGreaterOrEqual {
t.Errorf("unexpected result comparing p[%d] >= p[%d](%s) (%v)\np1: %#+v\np2:%#+v", i, k, transform, result, p1, p2)
}
}
})
}
}
}
}
func TestSeekPosition(t *testing.T) {
fontSize := 16
lineWidth := fontSize * 10
// Be careful tuning the runeLimit here. This test case's complexity
// is O(N^2) where N=runeLimit. It's easy to make this test take a stupid
// amount of time accidentally.
ltrText, rtlText := makeTestText(fontSize, lineWidth, 15)
type testcase struct {
name string
lines []text.Line
align text.Alignment
width int
}
for _, tc := range []testcase{
{
name: "ltr",
lines: ltrText,
align: text.Start,
width: lineWidth,
},
{
name: "rtl",
lines: rtlText,
align: text.Start,
width: lineWidth,
},
} {
t.Run(tc.name, func(t *testing.T) {
finalLineRunes := tc.lines[len(tc.lines)-1].Layout.Runes
// Statically generate all valid positions.
positions := make([]combinedPos, 1, finalLineRunes.Offset+finalLineRunes.Count+1)
positions[0] = firstPos(tc.lines[0], tc.align, tc.width)
for i := 1; ; i++ {
pos, eof := incrementPosition(tc.lines, tc.align, tc.width, positions[i-1])
positions = append(positions, pos)
if eof {
break
}
}
for i, start := range positions {
for k, needle := range positions {
if k < i {
continue
}
t.Run(tc.name+" "+strconv.Itoa(i)+"->"+strconv.Itoa(k), func(t *testing.T) {
for kind := 0; kind < 3; kind++ {
p2 := needle
transform := ""
switch kind {
case 0: // only runes populated
transform = "runes only"
p2.lineCol = screenPos{}
p2.x = 0
p2.y = 0
case 1: // only lineCol populated
transform = "lineCol only"
p2.runes = 0
p2.x = 0
p2.y = 0
case 2: // only x and y populated
transform = "x,y only"
p2.runes = 0
p2.lineCol = screenPos{}
}
for limit := 0; limit <= 10; limit += 5 {
result, found := seekPosition(tc.lines, tc.align, tc.width, start, p2, limit)
if (found && result != needle) || (!found && needle.runes-start.runes < limit) {
t.Errorf("unexpected result seeking p[%d] -> p[%d](%s) (limit %d, found %v) = %#+v\np1: %#+v\np2:%#+v", i, k, transform, limit, found, result, start, p2)
}
}
}
})
}
}
result, found := seekPosition(tc.lines, tc.align, tc.width, positions[0], combinedPos{runes: math.MaxInt}, 0)
if !found {
t.Errorf("reported hit limit on max int")
}
if expected := positions[len(positions)-1]; result != expected {
t.Errorf("expected maximum int position to equal final position.\nexpected %#+v\nactual %#+v", expected, result)
}
})
}
}