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-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
M go.sum => go.sum +0 -2
@@ 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=
M text/gotext.go => text/gotext.go +43 -6
@@ 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)
M text/gotext_test.go => text/gotext_test.go +2 -2
@@ 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
}
M text/lru.go => text/lru.go +1 -0
@@ 154,6 154,7 @@ type layoutKey struct {
maxWidth, minWidth int
maxLines int
str string
+ truncator string
locale system.Locale
font Font
}
M text/shaper.go => text/shaper.go +53 -13
@@ 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
}
}
M text/shaper_test.go => text/shaper_test.go +42 -22
@@ 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)
+ }
+ })
}
}
M widget/index.go => widget/index.go +15 -4
@@ 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
M widget/label.go => widget/label.go +5 -0
@@ 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)
M widget/material/label.go => widget/material/label.go +5 -2
@@ 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
M widget/text.go => widget/text.go +4 -0
@@ 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)