go.*,text,widget{,/material}: enable configurable line wrapping within words

This commit enables consumers of the text shaper to select a policy for how
line breaking candidates will be chosen. The new default policy can break lines
within "words" (UAX#14 segments) when words do not fit by themselves on a line.
This ensures that text does not horizontally overflow its bounding box unless
the available width is insufficient to display a single UAX#29 grapheme cluster.

Fixes: https://todo.sr.ht/~eliasnaur/gio/467
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
Chris Waldon
2023-06-07 16:56:32 -04:00
committed by Elias Naur
parent a252394356
commit c6e4eecf21
12 changed files with 92 additions and 16 deletions
+1 -1
View File
@@ -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-20230413204129-b4f0492bf7ae
github.com/go-text/typesetting v0.0.0-20230602202114-9797aefac433
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
+2 -2
View File
@@ -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-20230413204129-b4f0492bf7ae h1:LCcaQgYrnS+sx9Tc3oGUvbRBRt+5oFnKWakaxeAvNVI=
github.com/go-text/typesetting v0.0.0-20230413204129-b4f0492bf7ae/go.mod h1:KmrpWuSMFcO2yjmyhGpnBGQHSKAoEgMTSSzvLDzCuEA=
github.com/go-text/typesetting v0.0.0-20230602202114-9797aefac433 h1:Pdyvqsfi1QYgFfZa4R8otBOtgO+CGyBDMEG8cM3jwvE=
github.com/go-text/typesetting v0.0.0-20230602202114-9797aefac433/go.mod h1:KmrpWuSMFcO2yjmyhGpnBGQHSKAoEgMTSSzvLDzCuEA=
github.com/go-text/typesetting-utils v0.0.0-20230412163830-89e4bcfa3ecc h1:9Kf84pnrmmjdRzZIkomfjowmGUhHs20jkrWYw/I6CYc=
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=
+13 -1
View File
@@ -401,11 +401,23 @@ func (s *shaperImpl) shapeText(faces []font.Face, ppem fixed.Int26_6, lc system.
return s.outScratchBuf
}
func wrapPolicyToGoText(p WrapPolicy) shaping.LineBreakPolicy {
switch p {
case WrapGraphemes:
return shaping.Always
case WrapWords:
return shaping.Never
default:
return shaping.WhenNecessary
}
}
// shapeAndWrapText invokes the text shaper and returns wrapped lines in the shaper's native format.
func (s *shaperImpl) shapeAndWrapText(faces []font.Face, params Parameters, txt []rune) (_ []shaping.Line, truncated int) {
wc := shaping.WrapConfig{
TruncateAfterLines: params.MaxLines,
TextContinues: params.forceTruncate,
BreakPolicy: wrapPolicyToGoText(params.WrapPolicy),
}
if wc.TruncateAfterLines > 0 {
if len(params.Truncator) == 0 {
@@ -416,7 +428,7 @@ func (s *shaperImpl) shapeAndWrapText(faces []font.Face, params Parameters, txt
wc.Truncator = s.shapeText(faces, params.PxPerEm, params.Locale, []rune(params.Truncator))[0]
}
// Wrap outputs into lines.
return s.wrapper.WrapParagraph(wc, params.MaxWidth, txt, s.shapeText(faces, params.PxPerEm, params.Locale, txt)...)
return s.wrapper.WrapParagraph(wc, params.MaxWidth, txt, shaping.NewSliceIterator(s.shapeText(faces, params.PxPerEm, params.Locale, txt)))
}
// replaceControlCharacters replaces problematic unicode
+18
View File
@@ -10,6 +10,7 @@ import (
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/shaping"
"golang.org/x/exp/slices"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed"
@@ -235,6 +236,21 @@ func complexGlyph(cluster, runes, glyphs int) shaping.Glyph {
}
}
// copyLines performs a deep copy of the provided lines. This is necessary if you
// want to use the line wrapper again while also using the lines.
func copyLines(lines []shaping.Line) []shaping.Line {
out := make([]shaping.Line, len(lines))
for lineIdx, line := range lines {
lineCopy := make([]shaping.Output, len(line))
for runIdx, run := range line {
lineCopy[runIdx] = run
lineCopy[runIdx].Glyphs = slices.Clone(run.Glyphs)
}
out[lineIdx] = lineCopy
}
return out
}
// makeTestText creates a simple and complex(bidi) sample of shaped text at the given
// font size and wrapped to the given line width. The runeLimit, if nonzero,
// truncates the sample text to ensure shorter output for expensive tests.
@@ -277,11 +293,13 @@ func makeTestText(shaper *shaperImpl, primaryDir system.TextDirection, fontSize,
MaxWidth: lineWidth,
Locale: locale,
}, []rune(simpleSource))
simpleText = copyLines(simpleText)
complexText, _ := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(giofont.Font{}), Parameters{
PxPerEm: fixed.I(fontSize),
MaxWidth: lineWidth,
Locale: locale,
}, []rune(complexSource))
complexText = copyLines(complexText)
testShaper(rtlFace, ltrFace)
return simpleText, complexText
}
+1
View File
@@ -159,6 +159,7 @@ type layoutKey struct {
locale system.Locale
font giofont.Font
forceTruncate bool
wrapPolicy WrapPolicy
}
type pathKey struct {
+25
View File
@@ -16,6 +16,27 @@ import (
"golang.org/x/image/math/fixed"
)
// WrapPolicy configures strategies for choosing where to break lines of text for line
// wrapping.
type WrapPolicy uint8
const (
// WrapHeuristically tries to minimize breaking within words (UAX#14 text segments)
// while also ensuring that text fits within the given MaxWidth. It will only break
// a line within a word (on a UAX#29 grapheme cluster boundary) when that word cannot
// fit on a line by itself. Additionally, when the final word of a line is being
// truncated, this policy will preserve as many symbols of that word as
// possible before the truncator.
WrapHeuristically WrapPolicy = iota
// WrapWords does not permit words (UAX#14 text segments) to be broken across lines.
// This means that sometimes long words will exceed the MaxWidth they are wrapped with.
WrapWords
// WrapGraphemes will maximize the amount of text on each line at the expense of readability,
// breaking any word across lines on UAX#29 grapheme cluster boundaries to maximize the number of
// grapheme clusters on each line.
WrapGraphemes
)
// Parameters are static text shaping attributes applied to the entire shaped text.
type Parameters struct {
// Font describes the preferred typeface.
@@ -32,6 +53,9 @@ type Parameters struct {
// truncated.
Truncator string
// WrapPolicy configures how line breaks will be chosen when wrapping text across lines.
WrapPolicy WrapPolicy
// MinWidth and MaxWidth provide the minimum and maximum horizontal space constraints
// for the shaped text.
MinWidth, MaxWidth int
@@ -318,6 +342,7 @@ func (l *Shaper) layoutParagraph(params Parameters, asStr string, asBytes []byte
locale: params.Locale,
font: params.Font,
forceTruncate: params.forceTruncate,
wrapPolicy: params.WrapPolicy,
str: asStr,
}
if l, ok := l.layoutCache.Get(lk); ok {
+3
View File
@@ -57,6 +57,8 @@ type Editor struct {
// Filter is the list of characters allowed in the Editor. If Filter is empty,
// all characters are allowed.
Filter string
// WrapPolicy configures how displayed text will be broken into lines.
WrapPolicy text.WrapPolicy
buffer *editBuffer
// scratch is a byte buffer that is reused to efficiently read portions of text
@@ -504,6 +506,7 @@ func (e *Editor) initBuffer() {
e.text.Alignment = e.Alignment
e.text.SingleLine = e.SingleLine
e.text.Mask = e.Mask
e.text.WrapPolicy = e.WrapPolicy
}
// Layout lays out the editor using the provided textMaterial as the paint material
+1
View File
@@ -409,6 +409,7 @@ func TestEditorRTL(t *testing.T) {
func TestEditorLigature(t *testing.T) {
e := new(Editor)
e.WrapPolicy = text.WrapWords
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
+11 -8
View File
@@ -28,6 +28,8 @@ type Label struct {
// 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
// WrapPolicy configures how displayed text will be broken into lines.
WrapPolicy text.WrapPolicy
}
// Layout the label with the given shaper, font, size, text, and material.
@@ -35,14 +37,15 @@ func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size
cs := gtx.Constraints
textSize := fixed.I(gtx.Sp(size))
lt.LayoutString(text.Parameters{
Font: font,
PxPerEm: textSize,
MaxLines: l.MaxLines,
Truncator: l.Truncator,
Alignment: l.Alignment,
MaxWidth: cs.Max.X,
MinWidth: cs.Min.X,
Locale: gtx.Locale,
Font: font,
PxPerEm: textSize,
MaxLines: l.MaxLines,
Truncator: l.Truncator,
Alignment: l.Alignment,
WrapPolicy: l.WrapPolicy,
MaxWidth: cs.Max.X,
MinWidth: cs.Min.X,
Locale: gtx.Locale,
}, txt)
m := op.Record(gtx.Ops)
viewport := image.Rectangle{Max: cs.Max}
+7 -3
View File
@@ -29,6 +29,8 @@ type LabelStyle struct {
Alignment text.Alignment
// MaxLines limits the number of lines. Zero means no limit.
MaxLines int
// WrapPolicy configures how displayed text will be broken into lines.
WrapPolicy text.WrapPolicy
// 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
@@ -127,12 +129,14 @@ func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
l.State.Alignment = l.Alignment
l.State.MaxLines = l.MaxLines
l.State.Truncator = l.Truncator
l.State.WrapPolicy = l.WrapPolicy
return l.State.Layout(gtx, l.Shaper, l.Font, l.TextSize, textColor, selectColor)
}
tl := widget.Label{
Alignment: l.Alignment,
MaxLines: l.MaxLines,
Truncator: l.Truncator,
Alignment: l.Alignment,
MaxLines: l.MaxLines,
Truncator: l.Truncator,
WrapPolicy: l.WrapPolicy,
}
return tl.Layout(gtx, l.Shaper, l.Font, l.TextSize, l.Text, textColor)
}
+4 -1
View File
@@ -57,7 +57,9 @@ type Selectable struct {
MaxLines int
// Truncator is the symbol to use at the end of the final line of text
// if text was cut off. Defaults to "…" if left empty.
Truncator string
Truncator string
// WrapPolicy configures how displayed text will be broken into lines.
WrapPolicy text.WrapPolicy
initialized bool
source stringSource
// scratch is a buffer reused to efficiently read text out of the
@@ -182,6 +184,7 @@ func (l *Selectable) Layout(gtx layout.Context, lt *text.Shaper, font font.Font,
l.text.Alignment = l.Alignment
l.text.MaxLines = l.MaxLines
l.text.Truncator = l.Truncator
l.text.WrapPolicy = l.WrapPolicy
l.text.Update(gtx, lt, font, size, l.handleEvents)
dims := l.text.Dimensions()
defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop()
+6
View File
@@ -53,6 +53,8 @@ type textView struct {
// 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
// WrapPolicy configures how displayed text will be broken into lines.
WrapPolicy text.WrapPolicy
// 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.
@@ -267,6 +269,10 @@ func (e *textView) Update(gtx layout.Context, lt *text.Shaper, font font.Font, s
e.params.MaxLines = e.MaxLines
e.invalidate()
}
if e.WrapPolicy != e.params.WrapPolicy {
e.params.WrapPolicy = e.WrapPolicy
e.invalidate()
}
e.makeValid()
if eventHandling != nil {