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)
+ }
+ })
+ }
+}