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) {