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