~eliasnaur/gio

c6e4eecf21c32baa28e006068540196e54681cea — Chris Waldon 3 months ago a252394
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>
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-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

M go.sum => go.sum +2 -2
@@ 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=

M text/gotext.go => text/gotext.go +13 -1
@@ 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

M text/gotext_test.go => text/gotext_test.go +18 -0
@@ 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
}

M text/lru.go => text/lru.go +1 -0
@@ 159,6 159,7 @@ type layoutKey struct {
	locale             system.Locale
	font               giofont.Font
	forceTruncate      bool
	wrapPolicy         WrapPolicy
}

type pathKey struct {

M text/shaper.go => text/shaper.go +25 -0
@@ 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 {

M widget/editor.go => widget/editor.go +3 -0
@@ 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

M widget/editor_test.go => widget/editor_test.go +1 -0
@@ 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)),

M widget/label.go => widget/label.go +11 -8
@@ 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}

M widget/material/label.go => widget/material/label.go +7 -3
@@ 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)
}

M widget/selectable.go => widget/selectable.go +4 -1
@@ 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()

M widget/text.go => widget/text.go +6 -0
@@ 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 {