From 12da71821ab1255602e91dc901ded35aa4c4c3df Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Fri, 16 Dec 2022 13:11:42 -0500 Subject: [PATCH] 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 --- widget/index.go | 10 +-- widget/index_test.go | 116 ++++++++++++++++++++++++++++++ widget/label.go | 47 ++++++------ widget/label_test.go | 168 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 25 deletions(-) create mode 100644 widget/label_test.go diff --git a/widget/index.go b/widget/index.go index 9d070fd3..28b0014f 100644 --- a/widget/index.go +++ b/widget/index.go @@ -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. diff --git a/widget/index_test.go b/widget/index_test.go index 9d65032d..cb3f6a8d 100644 --- a/widget/index_test.go +++ b/widget/index_test.go @@ -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) { diff --git a/widget/label.go b/widget/label.go index 6e5cae85..787f105e 100644 --- a/widget/label.go +++ b/widget/label.go @@ -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 } diff --git a/widget/label_test.go b/widget/label_test.go new file mode 100644 index 00000000..4e57d487 --- /dev/null +++ b/widget/label_test.go @@ -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) + } + }) + } +}