~eliasnaur/gio

12da71821ab1255602e91dc901ded35aa4c4c3df — Chris Waldon 1 year, 9 months ago 5b40d3c
widget: update glyph iteration

This commit updates the textIterator and glyphIndex types to consume
new flag information provided on glyphs. These changes allow widget.Label
and widget.Editor to correctly compute text bounding boxes and to
generate valid cursor positions at the end of text.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
4 files changed, 316 insertions(+), 25 deletions(-)

M widget/index.go
M widget/index_test.go
M widget/label.go
A widget/label_test.go
M widget/index.go => widget/index.go +5 -5
@@ 108,7 108,7 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
	if end := gl.X + gl.Advance; end > g.currentLineMax {
		g.currentLineMax = end
	}
	if !g.skipPrior || gl.Flags&text.FlagTowardOrigin != g.prog {
	if !g.skipPrior || gl.Flags&text.FlagTowardOrigin != g.prog || gl.Flags&text.FlagParagraphStart != 0 {
		// Set the new text progression based on that of the first glyph.
		g.prog = gl.Flags & text.FlagTowardOrigin
		g.pos.towardOrigin = g.prog == text.FlagTowardOrigin


@@ 127,19 127,19 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
	}
	needsNewLine := gl.Flags&text.FlagLineBreak > 0
	needsNewRun := gl.Flags&text.FlagRunBreak > 0
	breaksParagraph := gl.Flags&text.FlagParagraphBreak > 0

	// We should insert new positions if the glyph we're processing terminates
	// a glyph cluster.
	insertPositionAfter := gl.Flags&text.FlagClusterBreak > 0
	if gl.Flags&text.FlagSynthetic > 0 {
		// Synthetic clusters shouldn't have positions generated for both
	insertPositionAfter := gl.Flags&text.FlagClusterBreak > 0 && !breaksParagraph && gl.Runes > 0
	if breaksParagraph {
		// Paragraph breaking clusters shouldn't have positions generated for both
		// sides of them. They're always zero-width, so doing so would
		// create two visually identical cursor positions. Just reset
		// cluster state, increment by their runes, and move on to the
		// next glyph.
		g.clusterAdvance = 0
		g.pos.runes += int(gl.Runes)
		insertPositionAfter = false
	}
	// Always track the cumulative advance added by the glyph, even if it
	// doesn't terminate a cluster itself.

M widget/index_test.go => widget/index_test.go +116 -0
@@ 73,6 73,122 @@ func makeAccountingTestText(fontSize, lineWidth int) (txt []text.Glyph) {
	return txt
}

// getGlyphs shapes text as english.
func getGlyphs(fontSize, minWidth, lineWidth int, align text.Alignment, str string) (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,
		},
	})
	params := text.Parameters{PxPerEm: fixed.I(fontSize), Alignment: align}
	shaper.LayoutString(params, minWidth, lineWidth, english, str)
	for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() {
		txt = append(txt, g)
	}
	return txt
}

// TestIndexPositionWhitespace checks that the index correctly generates cursor positions
// for empty lines and the empty string.
func TestIndexPositionWhitespace(t *testing.T) {
	type testcase struct {
		name     string
		str      string
		align    text.Alignment
		expected []combinedPos
	}
	for _, tc := range []testcase{
		{
			name: "empty string",
			str:  "",
			expected: []combinedPos{
				{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
			},
		},
		{
			name: "just hard newline",
			str:  "\n",
			expected: []combinedPos{
				{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
				{x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}},
			},
		},
		{
			name: "trailing newline",
			str:  "a\n",
			expected: []combinedPos{
				{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
				{x: fixed.Int26_6(570), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{col: 1}},
				{x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 1}},
			},
		},
		{
			name: "just blank line",
			str:  "\n\n",
			expected: []combinedPos{
				{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
				{x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}},
				{x: fixed.Int26_6(0), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 2}},
			},
		},
		{
			name:  "middle aligned blank lines",
			str:   "\n\n\nabc",
			align: text.Middle,
			expected: []combinedPos{
				{x: fixed.Int26_6(832), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
				{x: fixed.Int26_6(832), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}},
				{x: fixed.Int26_6(832), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 2}},
				{x: fixed.Int26_6(0), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 3, lineCol: screenPos{line: 3}},
				{x: fixed.Int26_6(570), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 4, lineCol: screenPos{line: 3, col: 1}},
				{x: fixed.Int26_6(1140), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 5, lineCol: screenPos{line: 3, col: 2}},
				{x: fixed.Int26_6(1652), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 6, lineCol: screenPos{line: 3, col: 3}},
			},
		},
		{
			name: "blank line",
			str:  "a\n\nb",
			expected: []combinedPos{
				{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
				{x: fixed.Int26_6(570), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{col: 1}},
				{x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 1}},
				{x: fixed.Int26_6(0), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 3, lineCol: screenPos{line: 2}},
				{x: fixed.Int26_6(570), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 4, lineCol: screenPos{line: 2, col: 1}},
			},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			glyphs := getGlyphs(16, 0, 200, tc.align, tc.str)
			var gi glyphIndex
			for _, g := range 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 actual != expected {
					t.Errorf("position %d: expected:\n%#+v, got:\n%#+v", i, expected, actual)
				}
			}
			if t.Failed() {
				printPositions(t, gi.positions)
				printGlyphs(t, glyphs)
			}
		})
	}

}

// TestIndexPositionBidi tests whether the index correct generates cursor positions for
// complex bidirectional text.
func TestIndexPositionBidi(t *testing.T) {

M widget/label.go => widget/label.go +27 -20
@@ 35,7 35,7 @@ func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size 
	}, cs.Min.X, cs.Max.X, gtx.Locale, txt)
	m := op.Record(gtx.Ops)
	viewport := image.Rectangle{Max: cs.Max}
	it := textIterator{viewport: viewport}
	it := textIterator{viewport: viewport, maxLines: l.MaxLines}
	semantic.LabelOp(txt).Add(gtx.Ops)
	var glyphs [32]text.Glyph
	line := glyphs[:0]


@@ 66,7 66,11 @@ type textIterator struct {
	// viewport is the rectangle of document coordinates that the iterator is
	// trying to fill with text.
	viewport image.Rectangle
	// maxLines is the maximum number of text lines that should be displayed.
	maxLines int

	// linesSeen tracks the quantity of line endings this iterator has seen.
	linesSeen int
	// lineOff tracks the origin for the glyphs in the current line.
	lineOff image.Point
	// padding is the space needed outside of the bounds of the text to ensure no


@@ 86,9 90,13 @@ type textIterator struct {
// processGlyph checks whether the glyph is visible within the iterator's configured
// viewport and (if so) updates the iterator's text dimensions to include the glyph.
func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visibleOrBefore bool) {
	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()),
	if it.maxLines > 0 {
		if g.Flags&text.FlagLineBreak != 0 {
			it.linesSeen++
		}
		if it.linesSeen == it.maxLines && g.Flags&text.FlagParagraphBreak != 0 {
			return g, false
		}
	}
	// Compute the maximum extent to which glyphs overhang on the horizontal
	// axis.


@@ 98,26 106,26 @@ func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visib
	if d := (g.Bounds.Max.X - g.Advance).Ceil(); d > it.padding.Max.X {
		it.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)))
	logicalBounds := image.Rectangle{
		Min: image.Pt(g.X.Floor(), int(g.Y)-g.Ascent.Ceil()),
		Max: image.Pt((g.X + g.Advance).Ceil(), int(g.Y)+g.Descent.Ceil()),
	}
	if !it.first {
		it.first = true
		it.baseline = int(g.Y)
		it.bounds = bounds
		it.bounds = logicalBounds
	}

	lineTop := int(g.Y) - g.Ascent.Ceil()
	lineBottom := int(g.Y) + g.Descent.Ceil()
	above := lineBottom < it.viewport.Min.Y
	below := lineTop > it.viewport.Max.Y
	left := bounds.Max.X < it.viewport.Min.X
	right := bounds.Min.X > it.viewport.Max.X
	above := logicalBounds.Max.Y < it.viewport.Min.Y
	below := logicalBounds.Min.Y > it.viewport.Max.Y
	left := logicalBounds.Max.X < it.viewport.Min.X
	right := logicalBounds.Min.X > it.viewport.Max.X
	it.visible = !above && !below && !left && !right
	if it.visible {
		it.bounds.Min.X = min(it.bounds.Min.X, bounds.Min.X)
		it.bounds.Min.Y = min(it.bounds.Min.Y, lineTop)
		it.bounds.Max.X = max(it.bounds.Max.X, bounds.Max.X)
		it.bounds.Max.Y = max(it.bounds.Max.Y, lineBottom)
		it.bounds.Min.X = min(it.bounds.Min.X, logicalBounds.Min.X)
		it.bounds.Min.Y = min(it.bounds.Min.Y, logicalBounds.Min.Y)
		it.bounds.Max.X = max(it.bounds.Max.X, logicalBounds.Max.X)
		it.bounds.Max.Y = max(it.bounds.Max.Y, logicalBounds.Max.Y)
	}
	return g, ok && !below
}


@@ 131,14 139,13 @@ func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visib
// to the heap.
func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyph text.Glyph, line []text.Glyph) ([]text.Glyph, bool) {
	_, visibleOrBefore := it.processGlyph(glyph, true)
	done := !visibleOrBefore
	if it.visible {
		if len(line) == 0 {
			it.lineOff = image.Point{X: glyph.X.Floor(), Y: int(glyph.Y)}.Sub(it.viewport.Min)
		}
		line = append(line, glyph)
	}
	if glyph.Flags&text.FlagLineBreak > 0 || cap(line)-len(line) == 0 || done {
	if glyph.Flags&text.FlagLineBreak > 0 || cap(line)-len(line) == 0 || !visibleOrBefore {
		t := op.Offset(it.lineOff).Push(gtx.Ops)
		op := clip.Outline{Path: shaper.Shape(line)}.Op().Push(gtx.Ops)
		paint.PaintOp{}.Add(gtx.Ops)


@@ 146,5 153,5 @@ func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyp
		t.Pop()
		line = line[:0]
	}
	return line, !done
	return line, visibleOrBefore
}

A widget/label_test.go => widget/label_test.go +168 -0
@@ 0,0 1,168 @@
package widget

import (
	"image"
	"math"
	"testing"

	"gioui.org/text"
	"golang.org/x/image/math/fixed"
)

// TestGlyphIterator ensures that the glyph iterator computes correct bounding
// boxes and baselines for a variety of glyph sequences.
func TestGlyphIterator(t *testing.T) {
	fontSize := 16
	stdAscent := fixed.I(fontSize)
	stdDescent := fixed.I(4)
	stdLineHeight := stdAscent + stdDescent
	type testcase struct {
		name             string
		str              string
		maxWidth         int
		maxLines         int
		viewport         image.Rectangle
		expectedDims     image.Rectangle
		expectedBaseline int
		stopAtGlyph      int
	}
	for _, tc := range []testcase{
		{
			name:     "empty string",
			str:      "",
			viewport: image.Rectangle{Max: image.Pt(math.MaxInt, math.MaxInt)},
			expectedDims: image.Rectangle{
				Max: image.Point{X: 0, Y: stdLineHeight.Round()},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      0,
		},
		{
			name:     "simple",
			str:      "MMM",
			viewport: image.Rectangle{Max: image.Pt(math.MaxInt, math.MaxInt)},
			expectedDims: image.Rectangle{
				Max: image.Point{X: 40, Y: stdLineHeight.Round()},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      2,
		},
		{
			name:     "simple clipped horizontally",
			str:      "MMM",
			viewport: image.Rectangle{Max: image.Pt(20, math.MaxInt)},
			// The dimensions should only include the first two glyphs.
			expectedDims: image.Rectangle{
				Max: image.Point{X: 27, Y: stdLineHeight.Round()},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      2,
		},
		{
			name:     "simple clipped vertically",
			str:      "M\nM\nM\nM",
			viewport: image.Rectangle{Max: image.Pt(math.MaxInt, 2*stdLineHeight.Floor()-3)},
			// The dimensions should only include the first two lines.
			expectedDims: image.Rectangle{
				Max: image.Point{X: 14, Y: 39},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      4,
		},
		{
			name:     "simple truncated",
			str:      "mmm",
			maxLines: 1,
			viewport: image.Rectangle{Max: image.Pt(math.MaxInt, math.MaxInt)},
			// This truncation should have no effect because the text is already one line.
			expectedDims: image.Rectangle{
				Max: image.Point{X: 40, Y: stdLineHeight.Round()},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      2,
		},
		{
			name:     "whitespace",
			str:      "   ",
			viewport: image.Rectangle{Max: image.Pt(math.MaxInt, math.MaxInt)},
			expectedDims: image.Rectangle{
				Max: image.Point{X: 14, Y: stdLineHeight.Round()},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      2,
		},
		{
			name:     "multi-line with hard newline",
			str:      "你\n好",
			viewport: image.Rectangle{Max: image.Pt(math.MaxInt, math.MaxInt)},
			expectedDims: image.Rectangle{
				Max: image.Point{X: 12, Y: 39},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      3,
		},
		{
			name:     "multi-line with soft newline",
			str:      "你好", // UAX#14 allows line breaking between these characters.
			maxWidth: fontSize,
			viewport: image.Rectangle{Max: image.Pt(math.MaxInt, math.MaxInt)},
			expectedDims: image.Rectangle{
				Max: image.Point{X: 12, Y: 39},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      2,
		},
		{
			name:     "trailing hard newline",
			str:      "m\n",
			viewport: image.Rectangle{Max: image.Pt(math.MaxInt, math.MaxInt)},
			// We expect the dimensions to account for two vertical lines because of the
			// newline at the end.
			expectedDims: image.Rectangle{
				Max: image.Point{X: 14, Y: 39},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      1,
		},
		{
			name:     "truncated trailing hard newline",
			str:      "m\n",
			maxLines: 1,
			viewport: image.Rectangle{Max: image.Pt(math.MaxInt, math.MaxInt)},
			// We expect the dimensions to reflect only a single line despite the newline
			// at the end.
			expectedDims: image.Rectangle{
				Max: image.Point{X: 14, Y: 20},
			},
			expectedBaseline: fontSize,
			stopAtGlyph:      1,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			maxWidth := 200
			if tc.maxWidth != 0 {
				maxWidth = tc.maxWidth
			}
			glyphs := getGlyphs(16, 0, maxWidth, text.Start, tc.str)
			it := textIterator{viewport: tc.viewport, maxLines: tc.maxLines}
			for i, g := range glyphs {
				gOut, ok := it.processGlyph(g, true)
				if gOut != g {
					t.Errorf("textIterator modified glyphs[%d], original:\n%#+v, modified:\n%#+v", i, g, gOut)
				}
				if !ok && i != tc.stopAtGlyph {
					t.Errorf("expected iterator to stop at glyph %d, stopped at %d", tc.stopAtGlyph, i)
				}
				if !ok {
					break
				}
			}
			if it.bounds != tc.expectedDims {
				t.Errorf("expected bounds %#+v, got %#+v", tc.expectedDims, it.bounds)
			}
			if it.baseline != tc.expectedBaseline {
				t.Errorf("expected baseline %d, got %d", tc.expectedBaseline, it.baseline)
			}
		})
	}
}