diff --git a/go.mod b/go.mod index fa3c20a3..11bc3421 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a9a64b60..126d100a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/text/gotext.go b/text/gotext.go index 14f21ab4..38eaa722 100644 --- a/text/gotext.go +++ b/text/gotext.go @@ -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 diff --git a/text/lru.go b/text/lru.go index d96b1dc9..f8b7caf1 100644 --- a/text/lru.go +++ b/text/lru.go @@ -157,6 +157,7 @@ type layoutKey struct { truncator string locale system.Locale font Font + forceTruncate bool } type pathKey struct { diff --git a/text/shaper.go b/text/shaper.go index 38ba8ef8..0c4a7ab3 100644 --- a/text/shaper.go +++ b/text/shaper.go @@ -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 diff --git a/text/shaper_test.go b/text/shaper_test.go index a6984662..b2926070 100644 --- a/text/shaper_test.go +++ b/text/shaper_test.go @@ -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) {