From c6e4eecf21c32baa28e006068540196e54681cea Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Wed, 7 Jun 2023 16:56:32 -0400 Subject: [PATCH] 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 --- go.mod | 2 +- go.sum | 4 ++-- text/gotext.go | 14 +++++++++++++- text/gotext_test.go | 18 ++++++++++++++++++ text/lru.go | 1 + text/shaper.go | 25 +++++++++++++++++++++++++ widget/editor.go | 3 +++ widget/editor_test.go | 1 + widget/label.go | 19 +++++++++++-------- widget/material/label.go | 10 +++++++--- widget/selectable.go | 5 ++++- widget/text.go | 6 ++++++ 12 files changed, 92 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 16a1fc30..5dfc7230 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-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 diff --git a/go.sum b/go.sum index dcc58bea..935e3963 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/text/gotext.go b/text/gotext.go index 4baed57b..429a502d 100644 --- a/text/gotext.go +++ b/text/gotext.go @@ -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 diff --git a/text/gotext_test.go b/text/gotext_test.go index fac94b97..aec3af5a 100644 --- a/text/gotext_test.go +++ b/text/gotext_test.go @@ -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 } diff --git a/text/lru.go b/text/lru.go index 78200eac..35dd4b4f 100644 --- a/text/lru.go +++ b/text/lru.go @@ -159,6 +159,7 @@ type layoutKey struct { locale system.Locale font giofont.Font forceTruncate bool + wrapPolicy WrapPolicy } type pathKey struct { diff --git a/text/shaper.go b/text/shaper.go index 114b174e..b2297501 100644 --- a/text/shaper.go +++ b/text/shaper.go @@ -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 { diff --git a/widget/editor.go b/widget/editor.go index 1a6bfba1..69820e8d 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -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 diff --git a/widget/editor_test.go b/widget/editor_test.go index 016e410d..119f21ff 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -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)), diff --git a/widget/label.go b/widget/label.go index 7c6eecba..36534ec2 100644 --- a/widget/label.go +++ b/widget/label.go @@ -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} diff --git a/widget/material/label.go b/widget/material/label.go index c606fe86..be0abdd0 100644 --- a/widget/material/label.go +++ b/widget/material/label.go @@ -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) } diff --git a/widget/selectable.go b/widget/selectable.go index 40a58eb6..aa4c9da5 100644 --- a/widget/selectable.go +++ b/widget/selectable.go @@ -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() diff --git a/widget/text.go b/widget/text.go index 335f5d89..852182dd 100644 --- a/widget/text.go +++ b/widget/text.go @@ -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 {