@@ 96,19 96,28 @@ const (
// sequence of glyphs which are logically a single unit, but require multiple
// symbols from a font to display.
FlagClusterBreak
- // FlagSynthetic indicates that the glyph cluster does not represent actual
+ // FlagParagraphBreak indicates that the glyph cluster does not represent actual
// font glyphs, but was inserted by the shaper to represent line-breaking
- // whitespace characters.
- FlagSynthetic
+ // whitespace characters. After a glyph with FlagParagraphBreak set, the shaper
+ // will always return a glyph with FlagParagraphStart providing the X and Y
+ // coordinates of the start of the next line, even if that line has no contents.
+ FlagParagraphBreak
+ // FlagParagraphStart indicates that the glyph starts a new paragraph.
+ FlagParagraphStart
)
func (f Flags) String() string {
var b strings.Builder
- if f&FlagSynthetic > 0 {
+ if f&FlagParagraphStart > 0 {
b.WriteString("S")
} else {
b.WriteString("_")
}
+ if f&FlagParagraphBreak > 0 {
+ b.WriteString("P")
+ } else {
+ b.WriteString("_")
+ }
if f&FlagTowardOrigin > 0 {
b.WriteString("T")
} else {
@@ 144,10 153,12 @@ type Shaper struct {
reader strings.Reader
// Iterator state.
- txt document
- line int
- run int
- glyph int
+ brokeParagraph bool
+ pararagraphStart Glyph
+ txt document
+ line int
+ run int
+ glyph int
// advance is the width of glyphs from the current run that have already been displayed.
advance fixed.Int26_6
// done tracks whether iteration is over.
@@ 223,7 234,7 @@ func (l *Shaper) layoutText(params Parameters, minWidth, maxWidth int, lc system
}
done = endByte == len(str)
}
- if startByte != endByte || len(l.paragraph) > 0 {
+ if startByte != endByte || (len(l.paragraph) > 0 || len(l.txt.lines) == 0) {
l.txt.append(l.layoutParagraph(params, minWidth, maxWidth, lc, str[startByte:endByte], l.paragraph))
if truncating {
params.MaxLines = maxLines - len(l.txt.lines)
@@ 275,6 286,10 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
}
for {
if l.line == len(l.txt.lines) {
+ if l.brokeParagraph {
+ l.brokeParagraph = false
+ return l.pararagraphStart, true
+ }
if l.err == nil {
l.err = io.EOF
}
@@ 297,7 312,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
X: align,
Y: int32(line.yOffset),
Runes: 0,
- Flags: FlagLineBreak | FlagClusterBreak | FlagRunBreak | FlagSynthetic,
+ Flags: FlagLineBreak | FlagClusterBreak | FlagRunBreak,
Ascent: line.ascent,
Descent: line.descent,
}, true
@@ 352,6 367,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
if endOfLine {
glyph.Flags |= FlagLineBreak
}
+ endOfText := endOfLine && l.line == len(l.txt.lines)-1
nextGlyph := l.glyph
if rtl {
nextGlyph = len(run.Glyphs) - 1 - nextGlyph
@@ 365,8 381,26 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
if run.Direction.Progression() == system.TowardOrigin {
glyph.Flags |= FlagTowardOrigin
}
+ if l.brokeParagraph {
+ glyph.Flags |= FlagParagraphStart
+ l.brokeParagraph = false
+ }
if g.glyphCount == 0 {
- glyph.Flags |= FlagSynthetic
+ glyph.Flags |= FlagParagraphBreak
+ l.brokeParagraph = true
+ if endOfText {
+ l.pararagraphStart = Glyph{
+ Ascent: glyph.Ascent,
+ Descent: glyph.Descent,
+ Flags: FlagParagraphStart | FlagLineBreak | FlagRunBreak | FlagClusterBreak,
+ }
+ // If a glyph is both a paragraph break and the final glyph, it's a newline
+ // at the end of the text. We must inform widgets like the text editor
+ // of a valid cursor position they can use for "after" such a newline,
+ // taking text alignment into account.
+ l.pararagraphStart.X = l.txt.alignment.Align(line.direction, 0, l.txt.alignWidth)
+ l.pararagraphStart.Y = glyph.Y + int32((glyph.Ascent + glyph.Descent).Ceil())
+ }
}
return glyph, true
@@ 1,12 1,14 @@
package text
import (
+ "fmt"
"strings"
"testing"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"gioui.org/font/opentype"
"gioui.org/io/system"
+ "golang.org/x/exp/slices"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed"
)
@@ 16,7 18,7 @@ import (
func TestWrappingTruncation(t *testing.T) {
// Use a test string containing multiple newlines to ensure that they are shaped
// as separate paragraphs.
- textInput := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua."
+ textInput := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua.\n"
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(collection)
@@ 33,10 35,18 @@ func TestWrappingTruncation(t *testing.T) {
MaxLines: i,
}, 200, 200, english, textInput)
lineCount := len(cache.txt.lines)
- if i <= untruncatedCount && lineCount != i {
- t.Errorf("expected %d lines, got %d", i, lineCount)
- } else if i > untruncatedCount && lineCount != untruncatedCount {
- t.Errorf("expected %d lines, got %d", untruncatedCount, lineCount)
+ glyphs := []Glyph{}
+ for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
+ glyphs = append(glyphs, g)
+ }
+ if i <= untruncatedCount {
+ if lineCount != i {
+ t.Errorf("expected %d lines, got %d", i, lineCount)
+ }
+ } else if i > untruncatedCount {
+ if lineCount != untruncatedCount {
+ t.Errorf("expected %d lines, got %d", untruncatedCount, lineCount)
+ }
}
}
}
@@ 44,26 54,74 @@ func TestWrappingTruncation(t *testing.T) {
// TestShapingNewlineHandling checks that the shaper's newline splitting behaves
// consistently and does not create spurious lines of text.
func TestShapingNewlineHandling(t *testing.T) {
- // Use a test string containing multiple newlines to ensure that they are shaped
- // as separate paragraphs.
- textInput := "\n"
- ltrFace, _ := opentype.Parse(goregular.TTF)
- collection := []FontFace{{Face: ltrFace}}
- cache := NewShaper(collection)
- cache.LayoutString(Parameters{
- Alignment: Middle,
- PxPerEm: fixed.I(10),
- }, 200, 200, english, textInput)
- if lineCount := len(cache.txt.lines); lineCount > 1 {
- t.Errorf("shaping string %q created %d lines", textInput, lineCount)
+ type testcase struct {
+ textInput string
+ expectedLines int
+ expectedGlyphs int
}
+ for _, tc := range []testcase{
+ {textInput: "a\n", expectedLines: 1, expectedGlyphs: 3},
+ {textInput: "a\nb", expectedLines: 2, expectedGlyphs: 3},
+ {textInput: "", expectedLines: 1, expectedGlyphs: 1},
+ } {
+ t.Run(fmt.Sprintf("%q", tc.textInput), func(t *testing.T) {
+ ltrFace, _ := opentype.Parse(goregular.TTF)
+ collection := []FontFace{{Face: ltrFace}}
+ cache := NewShaper(collection)
+ checkGlyphs := func() {
+ glyphs := []Glyph{}
+ for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
+ glyphs = append(glyphs, g)
+ }
+ if len(glyphs) != tc.expectedGlyphs {
+ t.Errorf("expected %d glyphs, got %d", tc.expectedGlyphs, len(glyphs))
+ }
+ findBreak := func(g Glyph) bool {
+ return g.Flags&FlagParagraphBreak != 0
+ }
+ found := 0
+ for idx := slices.IndexFunc(glyphs, findBreak); idx != -1; idx = slices.IndexFunc(glyphs, findBreak) {
+ found++
+ breakGlyph := glyphs[idx]
+ startGlyph := glyphs[idx+1]
+ glyphs = glyphs[idx+1:]
+ if flags := breakGlyph.Flags; flags&FlagParagraphBreak == 0 {
+ t.Errorf("expected newline glyph to have P flag, got %s", flags)
+ }
+ if flags := startGlyph.Flags; flags&FlagParagraphStart == 0 {
+ t.Errorf("expected newline glyph to have S flag, got %s", flags)
+ }
+ breakX, breakY := breakGlyph.X, breakGlyph.Y
+ startX, startY := startGlyph.X, startGlyph.Y
+ if breakX == startX {
+ t.Errorf("expected paragraph start glyph to have cursor x")
+ }
+ if breakY == startY {
+ t.Errorf("expected paragraph start glyph to have cursor y")
+ }
+ }
+ if count := strings.Count(tc.textInput, "\n"); found != count {
+ t.Errorf("expected %d paragraph breaks, found %d", count, found)
+ }
+ }
+ cache.LayoutString(Parameters{
+ Alignment: Middle,
+ PxPerEm: fixed.I(10),
+ }, 200, 200, english, tc.textInput)
+ if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
+ t.Errorf("shaping string %q created %d lines", tc.textInput, lineCount)
+ }
+ checkGlyphs()
- cache.Layout(Parameters{
- Alignment: Middle,
- PxPerEm: fixed.I(10),
- }, 200, 200, english, strings.NewReader(textInput))
- if lineCount := len(cache.txt.lines); lineCount > 1 {
- t.Errorf("shaping reader %q created %d lines", textInput, lineCount)
+ cache.Layout(Parameters{
+ Alignment: Middle,
+ PxPerEm: fixed.I(10),
+ }, 200, 200, english, strings.NewReader(tc.textInput))
+ if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
+ t.Errorf("shaping reader %q created %d lines", tc.textInput, lineCount)
+ }
+ checkGlyphs()
+ })
}
}
@@ 88,7 146,7 @@ func TestCacheEmptyString(t *testing.T) {
checkFlag(t, true, FlagClusterBreak, glyph, 0)
checkFlag(t, true, FlagRunBreak, glyph, 0)
checkFlag(t, true, FlagLineBreak, glyph, 0)
- checkFlag(t, true, FlagSynthetic, glyph, 0)
+ checkFlag(t, false, FlagParagraphBreak, glyph, 0)
if glyph.Ascent == 0 {
t.Errorf("expected non-zero ascent")
}
@@ 205,7 263,7 @@ func TestCacheGlyphConverstion(t *testing.T) {
checkFlag(t, endOfLine, FlagLineBreak, actual, glyphCursor)
checkFlag(t, endOfRun, FlagRunBreak, actual, glyphCursor)
checkFlag(t, towardOrigin, FlagTowardOrigin, actual, glyphCursor)
- checkFlag(t, synthetic, FlagSynthetic, actual, glyphCursor)
+ checkFlag(t, synthetic, FlagParagraphBreak, actual, glyphCursor)
checkFlag(t, endOfCluster, FlagClusterBreak, actual, glyphCursor)
glyphCursor++
if glyphIdx == end {