go.*,text,widget{,/material}: implement text truncators

This commit adds support for the idea of a text "Truncator", a string
that is shown at the end of truncated text to indicate that it has been
shortened because it would not fit within the requested number of lines.

When specifying a maximum number of lines, a truncator symbol is always
used. If the user does not provide one, the rune `…` is used. This
requirement results in a better user experience and significantly simpler
code, as we can rely upon the presence of one or more truncator glyphs in
the output glyph stream when truncation has occurred.

When interacting with truncated text, the truncator glyphs all act as
a single, indivisible unit. They can be selected or not, and if selected
they act as the entire contents of the truncated portion of the text.
This means that copying all of a truncated label will copy the entire
label text content, with the truncator symbol not appearing at all.

Concretely, the exposed text API now accepts a Truncator string in
text.Parameters, and there is a new glyph flag FlagTruncator which indicates
that the glyph is part of the truncator run. The truncator run will only
have a single FlagClusterBreak (even if the run would usually have many),
and the glyph with both FlagClusterBreak and FlagTruncator will have the
quantity of truncated runes in its Runes field. This necessitated increasing
the size of the Runes field from a byte to an int, as it's theoretically possible
for quite a lot of text to be truncated.

This commit necessarily bumps our go-text/typesetting dependency to the version
exposing truncation in the exported API.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
Chris Waldon
2023-03-22 15:19:04 -04:00
committed by Elias Naur
parent 5c54268d40
commit 959f5889a1
11 changed files with 171 additions and 52 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ require (
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2
gioui.org/shader v1.0.6 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 v0.0.0-20221012211006-4de253d81b95
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91
golang.org/x/image v0.5.0 golang.org/x/image v0.5.0
-2
View File
@@ -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/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 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y=
gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= 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 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-20230327141846-b6333f70ed72/go.mod h1:zvWM81wAVW6QfVDI6yxfbCuoLnobSYTuMsrXU/u11y8=
github.com/go-text/typesetting-utils v0.0.0-20230326210548-458646692de6 h1:zAAA1U4ykFwqPbcj6YDxvq3F2g0wc/ngPfLJjkR/8zs= github.com/go-text/typesetting-utils v0.0.0-20230326210548-458646692de6 h1:zAAA1U4ykFwqPbcj6YDxvq3F2g0wc/ngPfLJjkR/8zs=
+43 -6
View File
@@ -143,6 +143,9 @@ type runLayout struct {
Direction system.TextDirection Direction system.TextDirection
// face is the font face that the ID of each Glyph in the Layout refers to. // face is the font face that the ID of each Glyph in the Layout refers to.
face font.Face 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. // 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. // 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 { func (s *shaperImpl) shapeAndWrapText(faces []font.Face, params Parameters, maxWidth int, lc system.Locale, txt []rune) (_ []shaping.Line, truncated int) {
// Wrap outputs into lines. wc := shaping.WrapConfig{
return s.wrapper.WrapParagraph(shaping.WrapConfig{
TruncateAfterLines: params.MaxLines, 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 // replaceControlCharacters replaces problematic unicode
@@ -461,12 +473,20 @@ func (s *shaperImpl) LayoutRunes(params Parameters, minWidth, maxWidth int, lc s
if hasNewline { if hasNewline {
txt = txt[:len(txt)-1] 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. // Convert to Lines.
textLines := make([]line, len(ls)) textLines := make([]line, len(ls))
for i := range ls { for i := range ls {
otLine := toLine(&s.orderer, ls[i], lc.Direction) 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 // If there was a trailing newline update the rune counts to include
// it on the last line of the paragraph. // it on the last line of the paragraph.
finalRunIdx := len(otLine.runs) - 1 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 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 textLines[i] = otLine
} }
calculateYOffsets(textLines) calculateYOffsets(textLines)
+2 -2
View File
@@ -256,8 +256,8 @@ func makeTestText(shaper *shaperImpl, primaryDir system.TextDirection, fontSize,
rtlSource = string(complexRunes[:runeLimit]) rtlSource = string(complexRunes[:runeLimit])
} }
} }
simpleText := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), Parameters{PxPerEm: fixed.I(fontSize)}, lineWidth, locale, []rune(simpleSource)) 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)) complexText, _ := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), Parameters{PxPerEm: fixed.I(fontSize)}, lineWidth, locale, []rune(complexSource))
testShaper(rtlFace, ltrFace) testShaper(rtlFace, ltrFace)
return simpleText, complexText return simpleText, complexText
} }
+1
View File
@@ -154,6 +154,7 @@ type layoutKey struct {
maxWidth, minWidth int maxWidth, minWidth int
maxLines int maxLines int
str string str string
truncator string
locale system.Locale locale system.Locale
font Font font Font
} }
+53 -13
View File
@@ -27,6 +27,10 @@ type Parameters struct {
PxPerEm fixed.Int26_6 PxPerEm fixed.Int26_6
// MaxLines limits the quantity of shaped lines. Zero means no limit. // MaxLines limits the quantity of shaped lines. Zero means no limit.
MaxLines int 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. // 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 // belongs to. If Flags does not contain FlagClusterBreak, this value will
// always be zero. The final glyph in the cluster contains the runes count // always be zero. The final glyph in the cluster contains the runes count
// for the entire cluster. // for the entire cluster.
Runes byte Runes int
// Flags encode special properties of this glyph. // Flags encode special properties of this glyph.
Flags Flags Flags Flags
} }
@@ -105,6 +109,11 @@ const (
FlagParagraphBreak FlagParagraphBreak
// FlagParagraphStart indicates that the glyph starts a new paragraph. // FlagParagraphStart indicates that the glyph starts a new paragraph.
FlagParagraphStart 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 { func (f Flags) String() string {
@@ -139,6 +148,11 @@ func (f Flags) String() string {
} else { } else {
b.WriteString("_") b.WriteString("_")
} }
if f&FlagTruncator != 0 {
b.WriteString("…")
} else {
b.WriteString("_")
}
return b.String() return b.String()
} }
@@ -206,7 +220,6 @@ func (l *Shaper) layoutText(params Parameters, minWidth, maxWidth int, lc system
return return
} }
truncating := params.MaxLines > 0 truncating := params.MaxLines > 0
maxLines := params.MaxLines
var done bool var done bool
var startByte int var startByte int
var endByte int var endByte int
@@ -237,13 +250,33 @@ func (l *Shaper) layoutText(params Parameters, minWidth, maxWidth int, lc system
done = endByte == len(str) done = endByte == len(str)
} }
if startByte != endByte || (len(l.paragraph) > 0 || len(l.txt.lines) == 0) { 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 { if truncating {
params.MaxLines = maxLines - len(l.txt.lines) params.MaxLines -= len(lines.lines)
if params.MaxLines == 0 { if params.MaxLines == 0 {
done = true 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 { if done {
return 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. // Alignment is not part of the cache key because changing it does not impact shaping.
lk := layoutKey{ lk := layoutKey{
ppem: params.PxPerEm, truncator: params.Truncator,
maxWidth: maxWidth, ppem: params.PxPerEm,
minWidth: minWidth, maxWidth: maxWidth,
maxLines: params.MaxLines, minWidth: minWidth,
str: asStr, maxLines: params.MaxLines,
locale: lc, str: asStr,
font: params.Font, locale: lc,
font: params.Font,
} }
if l, ok := l.layoutCache.Get(lk); ok { if l, ok := l.layoutCache.Get(lk); ok {
return l return l
@@ -349,13 +383,16 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
Ascent: line.ascent, Ascent: line.ascent,
Descent: line.descent, Descent: line.descent,
Advance: g.xAdvance, Advance: g.xAdvance,
Runes: byte(g.runeCount), Runes: g.runeCount,
Offset: fixed.Point26_6{ Offset: fixed.Point26_6{
X: g.xOffset, X: g.xOffset,
Y: g.yOffset, Y: g.yOffset,
}, },
Bounds: g.bounds, Bounds: g.bounds,
} }
if run.truncator {
glyph.Flags |= FlagTruncator
}
l.glyph++ l.glyph++
if !rtl { if !rtl {
l.advance += g.xAdvance l.advance += g.xAdvance
@@ -375,6 +412,10 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
nextGlyph = len(run.Glyphs) - 1 - nextGlyph nextGlyph = len(run.Glyphs) - 1 - nextGlyph
} }
endOfCluster := endOfRun || run.Glyphs[nextGlyph].clusterIndex != g.clusterIndex 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 { if endOfCluster {
glyph.Flags |= FlagClusterBreak glyph.Flags |= FlagClusterBreak
} else { } else {
@@ -404,7 +445,6 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
l.pararagraphStart.Y = glyph.Y + int32((glyph.Ascent + glyph.Descent).Ceil()) l.pararagraphStart.Y = glyph.Y + int32((glyph.Ascent + glyph.Descent).Ceil())
} }
} }
return glyph, true return glyph, true
} }
} }
+42 -22
View File
@@ -28,29 +28,49 @@ func TestWrappingTruncation(t *testing.T) {
}, 200, 200, english, textInput) }, 200, 200, english, textInput)
untruncatedCount := len(cache.txt.lines) untruncatedCount := len(cache.txt.lines)
for expectedLines := untruncatedCount; expectedLines > 0; expectedLines-- { for i := untruncatedCount + 1; i > 0; i-- {
cache.LayoutString(Parameters{ t.Run(fmt.Sprintf("truncated to %d/%d lines", i, untruncatedCount), func(t *testing.T) {
Alignment: Middle, cache.LayoutString(Parameters{
PxPerEm: fixed.I(10), Alignment: Middle,
MaxLines: expectedLines, PxPerEm: fixed.I(10),
}, 200, 200, english, textInput) MaxLines: i,
lineCount := 0 }, 200, 200, english, textInput)
lastGlyphWasLineBreak := false lineCount := 0
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() { lastGlyphWasLineBreak := false
if g.Flags&FlagLineBreak != 0 { glyphs := []Glyph{}
lineCount++ untruncatedRunes := 0
lastGlyphWasLineBreak = true truncatedRunes := 0
} else { for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
lastGlyphWasLineBreak = false 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 && truncatedRunes == 0 {
if lastGlyphWasLineBreak { // There was no actual line of text following this break.
// There was no actual line of text following this break. lineCount--
lineCount-- }
} if i <= untruncatedCount {
if lineCount != expectedLines { if lineCount != i {
t.Errorf("expected %d lines, got %d", expectedLines, lineCount) 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)
}
})
} }
} }
+15 -4
View File
@@ -48,6 +48,8 @@ type glyphIndex struct {
// next glyph. Usually this should not happen, but the boundaries of // next glyph. Usually this should not happen, but the boundaries of
// lines and bidi runs require it. // lines and bidi runs require it.
skipPrior bool skipPrior bool
// truncated indicates that the text was truncated by the shaper.
truncated bool
} }
// reset prepares the index for reuse. // reset prepares the index for reuse.
@@ -62,6 +64,7 @@ func (g *glyphIndex) reset() {
g.prog = 0 g.prog = 0
g.clusterAdvance = 0 g.clusterAdvance = 0
g.skipPrior = false g.skipPrior = false
g.truncated = false
} }
// screenPos represents a character position in text line and column numbers, // 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.ascent = gl.Ascent
pos.descent = gl.Descent pos.descent = gl.Descent
width := g.clusterAdvance 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) adjust := fixed.Int26_6(0)
if pos.towardOrigin { if pos.towardOrigin {
// If RTL, subtract increments from the width of the cluster // If RTL, subtract increments from the width of the cluster
@@ -176,10 +187,10 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
adjust = width adjust = width
perRune = -perRune 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.x = gl.X + adjust + perRune*fixed.Int26_6(i)
pos.runes++ pos.runes += runesPerPosition
pos.lineCol.col++ pos.lineCol.col += runesPerPosition
g.positions = append(g.positions, pos) g.positions = append(g.positions, pos)
} }
g.pos = pos g.pos = pos
+5
View File
@@ -22,6 +22,9 @@ type Label struct {
Alignment text.Alignment Alignment text.Alignment
// MaxLines limits the number of lines. Zero means no limit. // MaxLines limits the number of lines. Zero means no limit.
MaxLines int 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, // Selectable optionally provides text selection state. If nil,
// text will not be selectable. // text will not be selectable.
Selectable *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.Alignment = l.Alignment
l.Selectable.text.MaxLines = l.MaxLines l.Selectable.text.MaxLines = l.MaxLines
l.Selectable.text.Truncator = l.Truncator
l.Selectable.SetText(txt) l.Selectable.SetText(txt)
return l.Selectable.Layout(gtx, lt, font, size, textMaterial, selectionMaterial) 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, Font: font,
PxPerEm: textSize, PxPerEm: textSize,
MaxLines: l.MaxLines, MaxLines: l.MaxLines,
Truncator: l.Truncator,
Alignment: l.Alignment, Alignment: l.Alignment,
}, cs.Min.X, cs.Max.X, gtx.Locale, txt) }, cs.Min.X, cs.Max.X, gtx.Locale, txt)
m := op.Record(gtx.Ops) m := op.Record(gtx.Ops)
+5 -2
View File
@@ -25,8 +25,11 @@ type LabelStyle struct {
Alignment text.Alignment Alignment text.Alignment
// MaxLines limits the number of lines. Zero means no limit. // MaxLines limits the number of lines. Zero means no limit.
MaxLines int MaxLines int
Text string // Truncator is the text that will be shown at the end of the final
TextSize unit.Sp // line if MaxLines is exceeded. Defaults to "…" if empty.
Truncator string
Text string
TextSize unit.Sp
shaper *text.Shaper shaper *text.Shaper
State *widget.Selectable State *widget.Selectable
+4
View File
@@ -50,6 +50,9 @@ type textView struct {
SingleLine bool SingleLine bool
// MaxLines limits the shaped text to a specific quantity of shaped lines. // MaxLines limits the shaped text to a specific quantity of shaped lines.
MaxLines int 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. // 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 // Newline characters are not masked. When non-zero, the unmasked contents
// are accessed by Len, Text, and SetText. // are accessed by Len, Text, and SetText.
@@ -459,6 +462,7 @@ func (e *textView) layoutText(lt *text.Shaper) {
PxPerEm: e.textSize, PxPerEm: e.textSize,
Alignment: e.Alignment, Alignment: e.Alignment,
MaxLines: e.MaxLines, MaxLines: e.MaxLines,
Truncator: e.Truncator,
}, e.minWidth, e.maxWidth, e.locale, r) }, e.minWidth, e.maxWidth, e.locale, r)
for glyph, ok := it.processGlyph(lt.NextGlyph()); ok; glyph, ok = it.processGlyph(lt.NextGlyph()) { for glyph, ok := it.processGlyph(lt.NextGlyph()); ok; glyph, ok = it.processGlyph(lt.NextGlyph()) {
e.index.Glyph(glyph) e.index.Glyph(glyph)