diff --git a/go.mod b/go.mod index 7b8bf717..fa3c20a3 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-20230327140021-5bac583ebb4f + github.com/go-text/typesetting v0.0.0-20230327141846-b6333f70ed72 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 0b972fe5..a9a64b60 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,6 @@ 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-20230327140021-5bac583ebb4f h1:c7b6naTuKNgug9cLnr0BVKu+GUy8KFPF8qHMwRIzaOM= -github.com/go-text/typesetting v0.0.0-20230327140021-5bac583ebb4f/go.mod h1:zvWM81wAVW6QfVDI6yxfbCuoLnobSYTuMsrXU/u11y8= 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-utils v0.0.0-20230326210548-458646692de6 h1:zAAA1U4ykFwqPbcj6YDxvq3F2g0wc/ngPfLJjkR/8zs= diff --git a/text/gotext.go b/text/gotext.go index 7177b27d..369e0467 100644 --- a/text/gotext.go +++ b/text/gotext.go @@ -143,6 +143,9 @@ type runLayout struct { Direction system.TextDirection // face is the font face that the ID of each Glyph in the Layout refers to. face font.Face + // truncator indicates that this run is a text truncator standing in for remaining + // text. + truncator bool } // faceOrderer chooses the order in which faces should be applied to text. @@ -398,11 +401,20 @@ func (s *shaperImpl) shapeText(faces []font.Face, ppem fixed.Int26_6, lc system. } // shapeAndWrapText invokes the text shaper and returns wrapped lines in the shaper's native format. -func (s *shaperImpl) shapeAndWrapText(faces []font.Face, params Parameters, maxWidth int, lc system.Locale, txt []rune) []shaping.Line { - // Wrap outputs into lines. - return s.wrapper.WrapParagraph(shaping.WrapConfig{ +func (s *shaperImpl) shapeAndWrapText(faces []font.Face, params Parameters, maxWidth int, lc system.Locale, txt []rune) (_ []shaping.Line, truncated int) { + wc := shaping.WrapConfig{ TruncateAfterLines: params.MaxLines, - }, maxWidth, txt, s.shapeText(faces, params.PxPerEm, lc, txt)...) + } + if wc.TruncateAfterLines > 0 { + if len(params.Truncator) == 0 { + params.Truncator = "…" + } + // We only permit a single run as the truncator, regardless of whether more were generated. + // Just use the first one. + wc.Truncator = s.shapeText(faces, params.PxPerEm, lc, []rune(params.Truncator))[0] + } + // Wrap outputs into lines. + return s.wrapper.WrapParagraph(wc, maxWidth, txt, s.shapeText(faces, params.PxPerEm, lc, txt)...) } // replaceControlCharacters replaces problematic unicode @@ -461,12 +473,20 @@ func (s *shaperImpl) LayoutRunes(params Parameters, minWidth, maxWidth int, lc s if hasNewline { txt = txt[:len(txt)-1] } - ls := s.shapeAndWrapText(s.orderer.sortedFacesForStyle(params.Font), params, maxWidth, lc, replaceControlCharacters(txt)) + ls, truncated := s.shapeAndWrapText(s.orderer.sortedFacesForStyle(params.Font), params, maxWidth, lc, replaceControlCharacters(txt)) + + if truncated > 0 && hasNewline { + // We've truncated the newline, since it was at the end and we've truncated some amount of runes + // before it. + truncated++ + hasNewline = false + } // Convert to Lines. textLines := make([]line, len(ls)) for i := range ls { otLine := toLine(&s.orderer, ls[i], lc.Direction) - if i == len(ls)-1 && hasNewline { + isFinalLine := i == len(ls)-1 + if isFinalLine && hasNewline { // If there was a trailing newline update the rune counts to include // it on the last line of the paragraph. finalRunIdx := len(otLine.runs) - 1 @@ -493,6 +513,23 @@ func (s *shaperImpl) LayoutRunes(params Parameters, minWidth, maxWidth int, lc s otLine.runs[finalRunIdx].Glyphs[0] = syntheticGlyph } } + if isFinalLine && truncated > 0 { + // 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 + otLine.runs[finalRunIdx].truncator = true + finalGlyphIdx := len(otLine.runs[finalRunIdx].Glyphs) - 1 + // The run represents all of the truncated text. + otLine.runs[finalRunIdx].Runes.Count = truncated + // Only the final glyph represents any runes, and it represents all truncated text. + for i := range otLine.runs[finalRunIdx].Glyphs { + if i == finalGlyphIdx { + otLine.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = truncated + } else { + otLine.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = 0 + } + } + } textLines[i] = otLine } calculateYOffsets(textLines) diff --git a/text/gotext_test.go b/text/gotext_test.go index 579c2626..47815a31 100644 --- a/text/gotext_test.go +++ b/text/gotext_test.go @@ -256,8 +256,8 @@ func makeTestText(shaper *shaperImpl, primaryDir system.TextDirection, fontSize, rtlSource = string(complexRunes[:runeLimit]) } } - simpleText := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), Parameters{PxPerEm: fixed.I(fontSize)}, lineWidth, locale, []rune(simpleSource)) - complexText := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), Parameters{PxPerEm: fixed.I(fontSize)}, lineWidth, locale, []rune(complexSource)) + simpleText, _ := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), Parameters{PxPerEm: fixed.I(fontSize)}, lineWidth, locale, []rune(simpleSource)) + complexText, _ := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), Parameters{PxPerEm: fixed.I(fontSize)}, lineWidth, locale, []rune(complexSource)) testShaper(rtlFace, ltrFace) return simpleText, complexText } diff --git a/text/lru.go b/text/lru.go index bbf7d412..d96b1dc9 100644 --- a/text/lru.go +++ b/text/lru.go @@ -154,6 +154,7 @@ type layoutKey struct { maxWidth, minWidth int maxLines int str string + truncator string locale system.Locale font Font } diff --git a/text/shaper.go b/text/shaper.go index 1b633e25..1f16d3cd 100644 --- a/text/shaper.go +++ b/text/shaper.go @@ -27,6 +27,10 @@ type Parameters struct { PxPerEm fixed.Int26_6 // MaxLines limits the quantity of shaped lines. Zero means no limit. MaxLines int + // Truncator is a string of text to insert where the shaped text was truncated, which + // can currently ohly happen if MaxLines is nonzero and the text on the final line is + // truncated. + Truncator string } // A FontFace is a Font and a matching Face. @@ -76,7 +80,7 @@ type Glyph struct { // belongs to. If Flags does not contain FlagClusterBreak, this value will // always be zero. The final glyph in the cluster contains the runes count // for the entire cluster. - Runes byte + Runes int // Flags encode special properties of this glyph. Flags Flags } @@ -105,6 +109,11 @@ const ( FlagParagraphBreak // FlagParagraphStart indicates that the glyph starts a new paragraph. FlagParagraphStart + // FlagTruncator indicates that the glyph is part of a special truncator run that + // represents the portion of text removed due to truncation. A glyph with both + // FlagTruncator and FlagClusterBreak will have a Runes field accounting for all + // runes truncated. + FlagTruncator ) func (f Flags) String() string { @@ -139,6 +148,11 @@ func (f Flags) String() string { } else { b.WriteString("_") } + if f&FlagTruncator != 0 { + b.WriteString("…") + } else { + b.WriteString("_") + } return b.String() } @@ -206,7 +220,6 @@ func (l *Shaper) layoutText(params Parameters, minWidth, maxWidth int, lc system return } truncating := params.MaxLines > 0 - maxLines := params.MaxLines var done bool var startByte int var endByte int @@ -237,13 +250,33 @@ func (l *Shaper) layoutText(params Parameters, minWidth, maxWidth int, lc system done = endByte == len(str) } 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)) + lines := l.layoutParagraph(params, minWidth, maxWidth, lc, str[startByte:endByte], l.paragraph) if truncating { - params.MaxLines = maxLines - len(l.txt.lines) + params.MaxLines -= len(lines.lines) if params.MaxLines == 0 { done = true + // We've truncated the text, but we need to account for all of the runes we never + // decoded in the truncator. + var unreadRunes int + if txt == nil { + unreadRunes = utf8.RuneCountInString(str[endByte:]) + } else { + for { + _, _, e := txt.ReadRune() + if e != nil { + break + } + unreadRunes++ + } + } + lastLineIdx := len(lines.lines) - 1 + lastRunIdx := len(lines.lines[lastLineIdx].runs) - 1 + lastGlyphIdx := len(lines.lines[lastLineIdx].runs[lastRunIdx].Glyphs) - 1 + lines.lines[lastLineIdx].runs[lastRunIdx].Runes.Count += unreadRunes + lines.lines[lastLineIdx].runs[lastRunIdx].Glyphs[lastGlyphIdx].runeCount += unreadRunes } } + l.txt.append(lines) } if done { return @@ -261,13 +294,14 @@ func (l *Shaper) layoutParagraph(params Parameters, minWidth, maxWidth int, lc s } // Alignment is not part of the cache key because changing it does not impact shaping. lk := layoutKey{ - ppem: params.PxPerEm, - maxWidth: maxWidth, - minWidth: minWidth, - maxLines: params.MaxLines, - str: asStr, - locale: lc, - font: params.Font, + truncator: params.Truncator, + ppem: params.PxPerEm, + maxWidth: maxWidth, + minWidth: minWidth, + maxLines: params.MaxLines, + str: asStr, + locale: lc, + font: params.Font, } if l, ok := l.layoutCache.Get(lk); ok { return l @@ -349,13 +383,16 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) { Ascent: line.ascent, Descent: line.descent, Advance: g.xAdvance, - Runes: byte(g.runeCount), + Runes: g.runeCount, Offset: fixed.Point26_6{ X: g.xOffset, Y: g.yOffset, }, Bounds: g.bounds, } + if run.truncator { + glyph.Flags |= FlagTruncator + } l.glyph++ if !rtl { l.advance += g.xAdvance @@ -375,6 +412,10 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) { nextGlyph = len(run.Glyphs) - 1 - nextGlyph } endOfCluster := endOfRun || run.Glyphs[nextGlyph].clusterIndex != g.clusterIndex + if run.truncator { + // Only emit a single cluster for the entire truncator sequence. + endOfCluster = endOfRun + } if endOfCluster { glyph.Flags |= FlagClusterBreak } else { @@ -404,7 +445,6 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) { l.pararagraphStart.Y = glyph.Y + int32((glyph.Ascent + glyph.Descent).Ceil()) } } - return glyph, true } } diff --git a/text/shaper_test.go b/text/shaper_test.go index 825ca6c5..b086b360 100644 --- a/text/shaper_test.go +++ b/text/shaper_test.go @@ -28,29 +28,49 @@ func TestWrappingTruncation(t *testing.T) { }, 200, 200, english, textInput) untruncatedCount := len(cache.txt.lines) - for expectedLines := untruncatedCount; expectedLines > 0; expectedLines-- { - cache.LayoutString(Parameters{ - Alignment: Middle, - PxPerEm: fixed.I(10), - MaxLines: expectedLines, - }, 200, 200, english, textInput) - lineCount := 0 - lastGlyphWasLineBreak := false - for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() { - if g.Flags&FlagLineBreak != 0 { - lineCount++ - lastGlyphWasLineBreak = true - } else { - lastGlyphWasLineBreak = false + 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, + }, 200, 200, english, textInput) + lineCount := 0 + lastGlyphWasLineBreak := false + 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++ + lastGlyphWasLineBreak = true + } else { + lastGlyphWasLineBreak = false + } } - } - if lastGlyphWasLineBreak { - // There was no actual line of text following this break. - lineCount-- - } - if lineCount != expectedLines { - t.Errorf("expected %d lines, got %d", expectedLines, lineCount) - } + if lastGlyphWasLineBreak && truncatedRunes == 0 { + // There was no actual line of text following this break. + lineCount-- + } + 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) + } + } + if expected := len([]rune(textInput)); truncatedRunes+untruncatedRunes != expected { + t.Errorf("expected %d total runes, got %d (%d truncated)", expected, truncatedRunes+untruncatedRunes, truncatedRunes) + } + }) } } diff --git a/widget/index.go b/widget/index.go index 6623f9cb..b930e3d2 100644 --- a/widget/index.go +++ b/widget/index.go @@ -48,6 +48,8 @@ type glyphIndex struct { // next glyph. Usually this should not happen, but the boundaries of // lines and bidi runs require it. skipPrior bool + // truncated indicates that the text was truncated by the shaper. + truncated bool } // reset prepares the index for reuse. @@ -62,6 +64,7 @@ func (g *glyphIndex) reset() { g.prog = 0 g.clusterAdvance = 0 g.skipPrior = false + g.truncated = false } // screenPos represents a character position in text line and column numbers, @@ -168,7 +171,15 @@ func (g *glyphIndex) Glyph(gl text.Glyph) { pos.ascent = gl.Ascent pos.descent = gl.Descent width := g.clusterAdvance - perRune := width / fixed.Int26_6(gl.Runes) + positionCount := int(gl.Runes) + runesPerPosition := 1 + if gl.Flags&text.FlagTruncator != 0 { + // Treat the truncator as a single unit that is either selected or not. + positionCount = 1 + runesPerPosition = int(gl.Runes) + g.truncated = true + } + perRune := width / fixed.Int26_6(positionCount) adjust := fixed.Int26_6(0) if pos.towardOrigin { // If RTL, subtract increments from the width of the cluster @@ -176,10 +187,10 @@ func (g *glyphIndex) Glyph(gl text.Glyph) { adjust = width perRune = -perRune } - for i := 1; i <= int(gl.Runes); i++ { + for i := 1; i <= positionCount; i++ { pos.x = gl.X + adjust + perRune*fixed.Int26_6(i) - pos.runes++ - pos.lineCol.col++ + pos.runes += runesPerPosition + pos.lineCol.col += runesPerPosition g.positions = append(g.positions, pos) } g.pos = pos diff --git a/widget/label.go b/widget/label.go index d0b56456..6fedbacb 100644 --- a/widget/label.go +++ b/widget/label.go @@ -22,6 +22,9 @@ type Label struct { Alignment text.Alignment // MaxLines limits the number of lines. Zero means no limit. MaxLines int + // Truncator is the text that will be shown at the end of the final + // line if MaxLines is exceeded. Defaults to "…" if empty. + Truncator string // Selectable optionally provides text selection state. If nil, // text will not be selectable. Selectable *Selectable @@ -37,6 +40,7 @@ func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size } l.Selectable.text.Alignment = l.Alignment l.Selectable.text.MaxLines = l.MaxLines + l.Selectable.text.Truncator = l.Truncator l.Selectable.SetText(txt) return l.Selectable.Layout(gtx, lt, font, size, textMaterial, selectionMaterial) } @@ -49,6 +53,7 @@ func (l Label) layout(gtx layout.Context, lt *text.Shaper, font text.Font, size Font: font, PxPerEm: textSize, MaxLines: l.MaxLines, + Truncator: l.Truncator, Alignment: l.Alignment, }, cs.Min.X, cs.Max.X, gtx.Locale, txt) m := op.Record(gtx.Ops) diff --git a/widget/material/label.go b/widget/material/label.go index 28ced81c..241da042 100644 --- a/widget/material/label.go +++ b/widget/material/label.go @@ -25,8 +25,11 @@ type LabelStyle struct { Alignment text.Alignment // MaxLines limits the number of lines. Zero means no limit. MaxLines int - Text string - TextSize unit.Sp + // Truncator is the text that will be shown at the end of the final + // line if MaxLines is exceeded. Defaults to "…" if empty. + Truncator string + Text string + TextSize unit.Sp shaper *text.Shaper State *widget.Selectable diff --git a/widget/text.go b/widget/text.go index c20d5254..ebc784d1 100644 --- a/widget/text.go +++ b/widget/text.go @@ -50,6 +50,9 @@ type textView struct { SingleLine bool // MaxLines limits the shaped text to a specific quantity of shaped lines. MaxLines int + // Truncator is the text that will be shown at the end of the final + // line if MaxLines is exceeded. Defaults to "…" if empty. + Truncator string // Mask replaces the visual display of each rune in the contents with the given rune. // Newline characters are not masked. When non-zero, the unmasked contents // are accessed by Len, Text, and SetText. @@ -459,6 +462,7 @@ func (e *textView) layoutText(lt *text.Shaper) { PxPerEm: e.textSize, Alignment: e.Alignment, MaxLines: e.MaxLines, + Truncator: e.Truncator, }, e.minWidth, e.maxWidth, e.locale, r) for glyph, ok := it.processGlyph(lt.NextGlyph()); ok; glyph, ok = it.processGlyph(lt.NextGlyph()) { e.index.Glyph(glyph)