forked from joejulian/gio
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:
+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)
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ type layoutKey struct {
|
||||
maxWidth, minWidth int
|
||||
maxLines int
|
||||
str string
|
||||
truncator string
|
||||
locale system.Locale
|
||||
font Font
|
||||
}
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user