~eliasnaur/gio

5b40d3cd47f6e32fe037ac83b332aa0e16a7eeb8 — Chris Waldon 5 months ago b048397
text: provide start of paragraph glyph marker

This commit adds a new flag to glyphs indicating that they are the
beginning of a new paragraph, as well as adding a guarantee that a
glyph with this flag will always follow a glyph with FlagParagraphBreak,
even if a paragraph break is the last rune in the text. This helps
widgets to find the boundaries and positions of text ending with
newlines reliably.

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

M text/shaper.go
M text/shaper_test.go
M text/shaper.go => text/shaper.go +45 -11
@@ 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

M text/shaper_test.go => text/shaper_test.go +83 -25
@@ 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 {