~eliasnaur/gio

d71f170c29810a889d10ddb05246b05616a4cea0 — Chris Waldon 1 year, 19 days ago 7e8c109
text: truncate multi-paragraph text correctly

This commit fixes a subtle problem when trunating text widgets that contain
multiple newline-delimited paragraphs.

Paragraphs are the unit of text shaping, so we divide the text into paragraphs
and then iterate those paragraphs performing shaping and line wrapping. If we
have a maximum number of lines to fill, we stop iterating paragraphs when we
use all of the available lines. Usually, if we fill all of the lines the text
shaper will insert the truncator symbol. However, if we exactly fill all of the
lines with the end of a paragraph, the line wrapper is able to fill the line
quota without actually truncating any of the text in that paragraph. Thus it
doesn't insert a truncator even though subsequent paragraphs were truncated (it
has no way to know).

To fix this, I've taught the line wrapper about an explicit scenario in which
we always want to show the truncator symbol *if* we hit the line limit, even if
all of the text in the current paragraph fit. I've then plumbed support for
that through our text stack.

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

M go.mod
M go.sum
M text/gotext.go
M text/lru.go
M text/shaper.go
M text/shaper_test.go
M go.mod => go.mod +1 -1
@@ 6,7 6,7 @@ require (
	eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d
	gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2
	gioui.org/shader v1.0.6
	github.com/go-text/typesetting v0.0.0-20230327141846-b6333f70ed72
	github.com/go-text/typesetting v0.0.0-20230329143336-a38d00edd832
	golang.org/x/exp v0.0.0-20221012211006-4de253d81b95
	golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91
	golang.org/x/image v0.5.0

M go.sum => go.sum +2 -2
@@ 5,8 5,8 @@ gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJG
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.6 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y=
gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/go-text/typesetting v0.0.0-20230327141846-b6333f70ed72 h1:oIG5nO+VCMVXIP+5u7t44AEc0kcS45cfi+3Hawv9xQs=
github.com/go-text/typesetting v0.0.0-20230327141846-b6333f70ed72/go.mod h1:zvWM81wAVW6QfVDI6yxfbCuoLnobSYTuMsrXU/u11y8=
github.com/go-text/typesetting v0.0.0-20230329143336-a38d00edd832 h1:yV4rFdcvwZXE0lZZ3EoBWjVysHyVo8DLY8VihDciNN0=
github.com/go-text/typesetting v0.0.0-20230329143336-a38d00edd832/go.mod h1:zvWM81wAVW6QfVDI6yxfbCuoLnobSYTuMsrXU/u11y8=
github.com/go-text/typesetting-utils v0.0.0-20230326210548-458646692de6 h1:zAAA1U4ykFwqPbcj6YDxvq3F2g0wc/ngPfLJjkR/8zs=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

M text/gotext.go => text/gotext.go +5 -2
@@ 404,6 404,7 @@ func (s *shaperImpl) shapeText(faces []font.Face, ppem fixed.Int26_6, lc system.
func (s *shaperImpl) shapeAndWrapText(faces []font.Face, params Parameters, txt []rune) (_ []shaping.Line, truncated int) {
	wc := shaping.WrapConfig{
		TruncateAfterLines: params.MaxLines,
		TextContinues:      params.forceTruncate,
	}
	if wc.TruncateAfterLines > 0 {
		if len(params.Truncator) == 0 {


@@ 475,7 476,9 @@ func (s *shaperImpl) LayoutRunes(params Parameters, txt []rune) document {
	}
	ls, truncated := s.shapeAndWrapText(s.orderer.sortedFacesForStyle(params.Font), params, replaceControlCharacters(txt))

	if truncated > 0 && hasNewline {
	didTruncate := truncated > 0 || (params.forceTruncate && params.MaxLines == len(ls))

	if didTruncate && hasNewline {
		// We've truncated the newline, since it was at the end and we've truncated some amount of runes
		// before it.
		truncated++


@@ 513,7 516,7 @@ func (s *shaperImpl) LayoutRunes(params Parameters, txt []rune) document {
				otLine.runs[finalRunIdx].Glyphs[0] = syntheticGlyph
			}
		}
		if isFinalLine && truncated > 0 {
		if isFinalLine && didTruncate {
			// If we've truncated the text with a truncator, adjust the rune counts within the
			// truncator to make it represent the truncated text.
			finalRunIdx := len(otLine.runs) - 1

M text/lru.go => text/lru.go +1 -0
@@ 157,6 157,7 @@ type layoutKey struct {
	truncator          string
	locale             system.Locale
	font               Font
	forceTruncate      bool
}

type pathKey struct {

M text/shaper.go => text/shaper.go +23 -9
@@ 31,11 31,17 @@ type Parameters struct {
	// can currently ohly happen if MaxLines is nonzero and the text on the final line is
	// truncated.
	Truncator string

	// MinWidth and MaxWidth provide the minimum and maximum horizontal space constraints
	// for the shaped text.
	MinWidth, MaxWidth int
	// Locale provides primary direction and language information for the shaped text.
	Locale system.Locale

	// forceTruncate controls whether the truncator string is inserted on the final line of
	// text with a MaxLines. It is unexported because this behavior only makes sense for the
	// shaper to control when it iterates paragraphs of text.
	forceTruncate bool
}

// A FontFace is a Font and a matching Face.


@@ 218,7 224,7 @@ func (l *Shaper) reset(align Alignment) {
// layoutText lays out a large text document by breaking it into paragraphs and laying
// out each of them separately. This allows the shaping results to be cached independently
// by paragraph. Only one of txt and str should be provided.
func (l *Shaper) layoutText(params Parameters, txt io.RuneReader, str string) {
func (l *Shaper) layoutText(params Parameters, txt *bufio.Reader, str string) {
	l.reset(params.Alignment)
	if txt == nil && len(str) == 0 {
		l.txt.append(l.layoutParagraph(params, "", nil))


@@ 243,6 249,9 @@ func (l *Shaper) layoutText(params Parameters, txt io.RuneReader, str string) {
					break
				}
			}
			_, _, re := txt.ReadRune()
			done = re != nil
			_ = txt.UnreadRune()
		} else {
			for endByte = startByte; endByte < len(str); {
				r, width := utf8.DecodeRuneInString(str[endByte:])


@@ 255,6 264,7 @@ func (l *Shaper) layoutText(params Parameters, txt io.RuneReader, str string) {
			done = endByte == len(str)
		}
		if startByte != endByte || (len(l.paragraph) > 0 || len(l.txt.lines) == 0) {
			params.forceTruncate = truncating && !done
			lines := l.layoutParagraph(params, str[startByte:endByte], l.paragraph)
			if truncating {
				params.MaxLines -= len(lines.lines)


@@ 290,6 300,9 @@ func (l *Shaper) layoutText(params Parameters, txt io.RuneReader, str string) {
	}
}

// layoutParagraph shapes and wraps a paragraph using the provided parameters.
// It accepts the paragraph data in either string or rune format, preferring the
// string in order to hit the shaper cache more quickly.
func (l *Shaper) layoutParagraph(params Parameters, asStr string, asRunes []rune) document {
	if l == nil {
		return document{}


@@ 299,14 312,15 @@ func (l *Shaper) layoutParagraph(params Parameters, asStr string, asRunes []rune
	}
	// Alignment is not part of the cache key because changing it does not impact shaping.
	lk := layoutKey{
		ppem:      params.PxPerEm,
		maxWidth:  params.MaxWidth,
		minWidth:  params.MinWidth,
		maxLines:  params.MaxLines,
		truncator: params.Truncator,
		locale:    params.Locale,
		font:      params.Font,
		str:       asStr,
		ppem:          params.PxPerEm,
		maxWidth:      params.MaxWidth,
		minWidth:      params.MinWidth,
		maxLines:      params.MaxLines,
		truncator:     params.Truncator,
		locale:        params.Locale,
		font:          params.Font,
		forceTruncate: params.forceTruncate,
		str:           asStr,
	}
	if l, ok := l.layoutCache.Get(lk); ok {
		return l

M text/shaper_test.go => text/shaper_test.go +68 -0
@@ 80,6 80,74 @@ func TestWrappingTruncation(t *testing.T) {
	}
}

// TestWrappingForcedTruncation checks that the line wrapper's truncation features
// activate correctly on multi-paragraph text when later paragraphs are truncated.
func TestWrappingForcedTruncation(t *testing.T) {
	// Use a test string containing multiple newlines to ensure that they are shaped
	// as separate paragraphs.
	textInput := "Lorem ipsum\ndolor sit\namet"
	ltrFace, _ := opentype.Parse(goregular.TTF)
	collection := []FontFace{{Face: ltrFace}}
	cache := NewShaper(collection)
	cache.LayoutString(Parameters{
		Alignment: Middle,
		PxPerEm:   fixed.I(10),
		MinWidth:  200,
		MaxWidth:  200,
		Locale:    english,
	}, textInput)
	untruncatedCount := len(cache.txt.lines)

	for i := untruncatedCount + 1; i > 0; i-- {
		t.Run(fmt.Sprintf("truncated to %d/%d lines", i, untruncatedCount), func(t *testing.T) {
			cache.LayoutString(Parameters{
				Alignment: Middle,
				PxPerEm:   fixed.I(10),
				MaxLines:  i,
				MinWidth:  200,
				MaxWidth:  200,
				Locale:    english,
			}, textInput)
			lineCount := 0
			glyphs := []Glyph{}
			untruncatedRunes := 0
			truncatedRunes := 0
			for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
				glyphs = append(glyphs, g)
				if g.Flags&FlagTruncator != 0 && g.Flags&FlagClusterBreak != 0 {
					truncatedRunes += g.Runes
				} else {
					untruncatedRunes += g.Runes
				}
				if g.Flags&FlagLineBreak != 0 {
					lineCount++
				}
			}
			expectedTruncated := false
			expectedLines := 0
			if i < untruncatedCount {
				expectedLines = i
				expectedTruncated = true
			} else if i == untruncatedCount {
				expectedLines = i
				expectedTruncated = false
			} else if i > untruncatedCount {
				expectedLines = untruncatedCount
				expectedTruncated = false
			}
			if lineCount != expectedLines {
				t.Errorf("expected %d lines, got %d", expectedLines, lineCount)
			}
			if truncatedRunes > 0 != expectedTruncated {
				t.Errorf("expected expectedTruncated=%v, truncatedRunes=%d", expectedTruncated, truncatedRunes)
			}
			if expected := len([]rune(textInput)); truncatedRunes+untruncatedRunes != expected {
				t.Errorf("expected %d total runes, got %d (%d truncated)", expected, truncatedRunes+untruncatedRunes, truncatedRunes)
			}
		})
	}
}

// TestShapingNewlineHandling checks that the shaper's newline splitting behaves
// consistently and does not create spurious lines of text.
func TestShapingNewlineHandling(t *testing.T) {