~whereswaldon/gio-x

a2b41ad69d71c1d3e6cfacac0f802024d8bf487d — Chris Waldon 5 months ago 9da08d9
styledtext,richtext: adapt to new text shaping APIs

This commit restructures the internals of styledtext to be compatible with
Gio's new text shaping API. Unfortunately, styledtext isn't completely bidi-
safe. Spans containing bidi text should display correctly, but if a style
change occurs within runs of text that move against the primary text
direction, the runs will be displayed out of order. The only good way to
fix this is to enable Gio core to provide a richer styled text API, which
we should be able to do pretty soon.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
5 files changed, 199 insertions(+), 40 deletions(-)

M go.mod
M go.sum
M richtext/richtext.go
A styledtext/iterator.go
M styledtext/styledtext.go
M go.mod => go.mod +4 -4
@@ 3,13 3,14 @@ module gioui.org/x
go 1.18

require (
	gioui.org v0.0.0-20220830130127-276b7eefdd65
	gioui.org v0.0.0-20221216233230-5d1d1df2061c
	git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0
	github.com/andybalholm/stroke v0.0.0-20220316233208-2609e58d58a5
	github.com/esiqveland/notify v0.11.0
	github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4
	github.com/godbus/dbus/v5 v5.0.6
	github.com/yuin/goldmark v1.4.0
	golang.org/x/exp v0.0.0-20221012211006-4de253d81b95
	golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91
	golang.org/x/image v0.0.0-20220722155232-062f8c9fd539
	golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64


@@ 19,8 20,7 @@ require (
require (
	gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect
	gioui.org/shader v1.0.6 // indirect
	github.com/benoitkugler/textlayout v0.1.3 // indirect
	github.com/gioui/uax v0.2.1-0.20220819135011-cda973fac06d // indirect
	github.com/go-text/typesetting v0.0.0-20220411150340-35994bc27a7b // indirect
	github.com/benoitkugler/textlayout v0.3.0 // indirect
	github.com/go-text/typesetting v0.0.0-20221214153724-0399769901d5 // indirect
	github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
)

M go.sum => go.sum +10 -10
@@ 1,6 1,6 @@
eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3 h1:djFprmHZgrSepsHAIRMp5UJn3PzsoTg9drI+BDmif5Q=
gioui.org v0.0.0-20220830130127-276b7eefdd65 h1:mX+A86TwTyHZNqDxekUukiAmtYNUOq4CnrRZHxUrlo8=
gioui.org v0.0.0-20220830130127-276b7eefdd65/go.mod h1:GN091SCcGAfHfQiSOetXx7Abdy+8nmONj0ZN63Xxf7w=
gioui.org v0.0.0-20221216233230-5d1d1df2061c h1:5sweSGj5cjHLJgK4cGI5wNhghp/+JQF+MM0oen8HQA8=
gioui.org v0.0.0-20221216233230-5d1d1df2061c/go.mod h1:3lLo7xMHYnnHTrgKNNctBjEKKH3wQCO2Sn7ti5Jy8mU=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=


@@ 11,16 11,14 @@ git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73
github.com/andybalholm/stroke v0.0.0-20220316233208-2609e58d58a5 h1:xxGjYr3zFgmxumkDiXyxiDD1Pdv1kmO0hewvTEOMlzQ=
github.com/andybalholm/stroke v0.0.0-20220316233208-2609e58d58a5/go.mod h1:ccdDYaY5+gO+cbnQdFxEXqfy0RkoV25H3jLXUDNM3wg=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.0.5/go.mod h1:puH4v13Uz7uIhIH0XMk5jgc8U3MXcn5r3VlV9K8n0D8=
github.com/benoitkugler/textlayout v0.1.3 h1:Jv0E28xDkke3KrWle90yOLtBmZsUqXLBy70lZRfbKN0=
github.com/benoitkugler/textlayout v0.1.3/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout v0.3.0 h1:2ehWXEkgb6RUokTjXh1LzdGwG4dRP6X3dqhYYDYhUVk=
github.com/benoitkugler/textlayout v0.3.0/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout-testdata v0.1.1 h1:AvFxBxpfrQd8v55qH59mZOJOQjtD6K2SFe9/HvnIbJk=
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
github.com/esiqveland/notify v0.11.0 h1:0WJ/xW+3Ln8uRBYntG7f0XihXxnlOaQTdha1yyzXz30=
github.com/esiqveland/notify v0.11.0/go.mod h1:63UbVSaeJwF0LVJARHFuPgUAoM7o1BEvCZyknsuonBc=
github.com/gioui/uax v0.2.1-0.20220819135011-cda973fac06d h1:ro1W5kY1pVBLHy4GokZUfr9cl7ewZhAiT5WsXqFDYE4=
github.com/gioui/uax v0.2.1-0.20220819135011-cda973fac06d/go.mod h1:b6uGh9ySJPVQG/RdiI88bE5sUGDk6vzzRujv1BAeuJc=
github.com/go-text/typesetting v0.0.0-20220411150340-35994bc27a7b h1:WINlj3ANt+CVrO2B4NGDHRlPvEWZPxjhb7z+JKypwXI=
github.com/go-text/typesetting v0.0.0-20220411150340-35994bc27a7b/go.mod h1:ZNYu5saGoMOqtkVH5T8onTwhzenDUVszI+5WFHJRaxQ=
github.com/go-text/typesetting v0.0.0-20221214153724-0399769901d5 h1:iOA0HmtpANn48hX2nlDNMu0VVaNza35HJG0WeetBVzQ=
github.com/go-text/typesetting v0.0.0-20221214153724-0399769901d5/go.mod h1:/cmOXaoTiO+lbCwkTZBgCvevJpbFsZ5reXIpEJVh5MI=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=


@@ 30,10 28,12 @@ github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/yuin/goldmark v1.4.0 h1:OtISOGfH6sOWa1/qXqqAiOIAO6Z5J3AEAE18WAq6BiQ=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU=
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=

M richtext/richtext.go => richtext/richtext.go +2 -2
@@ 184,11 184,11 @@ type TextStyle struct {
	State     *InteractiveText
	Styles    []SpanStyle
	Alignment text.Alignment
	text.Shaper
	*text.Shaper
}

// Text constructs a TextStyle.
func Text(state *InteractiveText, shaper text.Shaper, styles ...SpanStyle) TextStyle {
func Text(state *InteractiveText, shaper *text.Shaper, styles ...SpanStyle) TextStyle {
	return TextStyle{
		State:  state,
		Styles: styles,

A styledtext/iterator.go => styledtext/iterator.go +141 -0
@@ 0,0 1,141 @@
package styledtext

import (
	"image"

	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
	"gioui.org/op/paint"
	"gioui.org/text"
	"golang.org/x/exp/constraints"
	"golang.org/x/image/math/fixed"
)

// textIterator computes the bounding box of and paints text. This iterator is
// specialized to laying out single lines of text.
type textIterator struct {
	// viewport is the rectangle of document coordinates that the iterator is
	// trying to fill with text.
	viewport image.Rectangle
	// maxLines tracks the maximum allowed number of glyphs with FlagLineBreak.
	maxLines int

	// linesSeen tracks the number of FlagLineBreak glyphs we have seen.
	linesSeen int
	// init tracks whether the iterator has processed any glyphs.
	init bool
	// firstX tracks the x offset of the first processed glyph. This is subtracted
	// from all glyph x offsets in order to ensure that the text is rendered at
	// x=0.
	firstX fixed.Int26_6
	// hasNewline tracks whether the processed glyphs contained a synthetic newline
	// character.
	hasNewline bool
	// lineOff tracks the origin for the glyphs in the current line.
	lineOff image.Point
	// padding is the space needed outside of the bounds of the text to ensure no
	// part of a glyph is clipped.
	padding image.Rectangle
	// bounds is the logical bounding box of the text.
	bounds image.Rectangle
	// runes is the count of runes represented by the processed glyphs.
	runes int
	// visible tracks whether the most recently iterated glyph is visible within
	// the viewport.
	visible bool
	// first tracks whether the iterator has processed a glyph yet.
	first bool
	// baseline tracks the location of the first line of text's baseline.
	baseline int
}

// processGlyph checks whether the glyph is visible within the iterator's configured
// viewport and (if so) updates the iterator's text dimensions to include the glyph.
func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visibleOrBefore bool) {
	it.runes += int(g.Runes)
	it.hasNewline = it.hasNewline || (g.Flags&text.FlagLineBreak > 0 && g.Flags&text.FlagParagraphBreak > 0)
	if it.maxLines > 0 {
		if g.Flags&text.FlagLineBreak != 0 {
			it.linesSeen++
		}
		if it.linesSeen == it.maxLines && g.Flags&text.FlagParagraphBreak != 0 {
			return g, false
		}
	}
	// Compute the maximum extent to which glyphs overhang on the horizontal
	// axis.
	if d := g.Bounds.Min.X.Floor(); d < it.padding.Min.X {
		it.padding.Min.X = d
	}
	if d := (g.Bounds.Max.X - g.Advance).Ceil(); d > it.padding.Max.X {
		it.padding.Max.X = d
	}
	logicalBounds := image.Rectangle{
		Min: image.Pt(g.X.Floor(), int(g.Y)-g.Ascent.Ceil()),
		Max: image.Pt((g.X + g.Advance).Ceil(), int(g.Y)+g.Descent.Ceil()),
	}
	if !it.first {
		it.first = true
		it.baseline = int(g.Y)
		it.bounds = logicalBounds
	}

	above := logicalBounds.Max.Y < it.viewport.Min.Y
	below := logicalBounds.Min.Y > it.viewport.Max.Y
	left := logicalBounds.Max.X < it.viewport.Min.X
	right := logicalBounds.Min.X > it.viewport.Max.X
	it.visible = !above && !below && !left && !right
	if it.visible {
		it.bounds.Min.X = min(it.bounds.Min.X, logicalBounds.Min.X)
		it.bounds.Min.Y = min(it.bounds.Min.Y, logicalBounds.Min.Y)
		it.bounds.Max.X = max(it.bounds.Max.X, logicalBounds.Max.X)
		it.bounds.Max.Y = max(it.bounds.Max.Y, logicalBounds.Max.Y)
	}
	return g, ok && !below

}

func min[T constraints.Ordered](a, b T) T {
	if a < b {
		return a
	}
	return b
}

func max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

// paintGlyph buffers up and paints text glyphs. It should be invoked iteratively upon each glyph
// until it returns false. The line parameter should be a slice with
// a backing array of sufficient size to buffer multiple glyphs.
// A modified slice will be returned with each invocation, and is
// expected to be passed back in on the following invocation.
// This design is awkward, but prevents the line slice from escaping
// to the heap.
func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyph text.Glyph, line []text.Glyph) ([]text.Glyph, bool) {
	_, visibleOrBefore := it.processGlyph(glyph, true)
	if it.visible {
		if !it.init {
			it.firstX = glyph.X
			it.init = true
		}
		if len(line) == 0 {
			it.lineOff = image.Point{X: (glyph.X - it.firstX).Floor(), Y: int(glyph.Y)}.Sub(it.viewport.Min)
		}
		line = append(line, glyph)
	}
	if glyph.Flags&text.FlagLineBreak > 0 || cap(line)-len(line) == 0 || !visibleOrBefore {
		t := op.Offset(it.lineOff).Push(gtx.Ops)
		op := clip.Outline{Path: shaper.Shape(line)}.Op().Push(gtx.Ops)
		paint.PaintOp{}.Add(gtx.Ops)
		op.Pop()
		t.Pop()
		line = line[:0]
	}
	return line, visibleOrBefore
}

M styledtext/styledtext.go => styledtext/styledtext.go +42 -24
@@ 8,7 8,6 @@ import (

	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
	"gioui.org/op/paint"
	"gioui.org/text"
	"gioui.org/unit"


@@ 28,17 27,16 @@ type SpanStyle struct {
// spanShape describes the text shaping of a single span.
type spanShape struct {
	offset image.Point
	layout text.Layout
	call   op.CallOp
	size   image.Point
	ascent int
}

// Layout renders the span using the provided text shaping.
func (ss SpanStyle) Layout(gtx layout.Context, s text.Shaper, shape spanShape) layout.Dimensions {
func (ss SpanStyle) Layout(gtx layout.Context, shape spanShape) layout.Dimensions {
	paint.ColorOp{Color: ss.Color}.Add(gtx.Ops)
	defer op.Offset(shape.offset).Push(gtx.Ops).Pop()
	defer clip.Outline{Path: s.Shape(ss.Font, fixed.I(gtx.Sp(ss.Size)), shape.layout)}.Op().Push(gtx.Ops).Pop()
	paint.PaintOp{}.Add(gtx.Ops)
	shape.call.Add(gtx.Ops)
	return layout.Dimensions{Size: shape.size}
}



@@ 46,11 44,11 @@ func (ss SpanStyle) Layout(gtx layout.Context, s text.Shaper, shape spanShape) l
type TextStyle struct {
	Styles    []SpanStyle
	Alignment text.Alignment
	text.Shaper
	*text.Shaper
}

// Text constructs a TextStyle.
func Text(shaper text.Shaper, styles ...SpanStyle) TextStyle {
func Text(shaper *text.Shaper, styles ...SpanStyle) TextStyle {
	return TextStyle{
		Styles: styles,
		Shaper: shaper,


@@ 80,6 78,7 @@ func (t TextStyle) Layout(gtx layout.Context, spanFn func(gtx layout.Context, id
		overallSize    image.Point
		lineShapes     []spanShape
		lineStartIndex int
		glyphs         [32]text.Glyph
	)

	for i := 0; i < len(spans); i++ {


@@ 90,13 89,33 @@ func (t TextStyle) Layout(gtx layout.Context, spanFn func(gtx layout.Context, id
		maxWidth := gtx.Constraints.Max.X - lineDims.X

		// shape the text of the current span
		lines := t.Shaper.LayoutString(span.Font, fixed.I(gtx.Sp(span.Size)), maxWidth, gtx.Locale, span.Content)
		macro := op.Record(gtx.Ops)
		paint.ColorOp{Color: span.Color}.Add(gtx.Ops)
		t.Shaper.LayoutString(text.Parameters{
			Font:     span.Font,
			PxPerEm:  fixed.I(gtx.Sp(span.Size)),
			MaxLines: 1,
		}, 0, maxWidth, gtx.Locale, span.Content)
		ti := textIterator{
			viewport: image.Rectangle{Max: gtx.Constraints.Max},
			maxLines: 1,
		}

		line := glyphs[:0]
		for g, ok := t.Shaper.NextGlyph(); ok; g, ok = t.Shaper.NextGlyph() {
			line, ok = ti.paintGlyph(gtx, t.Shaper, g, line)
			if !ok {
				break
			}
		}
		call := macro.Stop()
		runesDisplayed := ti.runes
		multiLine := runesDisplayed < utf8.RuneCountInString(span.Content)

		// grab the first line of the result and compute its dimensions
		firstLine := lines[0]
		spanWidth := firstLine.Width.Ceil()
		spanHeight := (firstLine.Ascent + firstLine.Descent).Ceil()
		spanAscent := firstLine.Ascent.Ceil()
		spanWidth := ti.bounds.Dx()
		spanHeight := ti.bounds.Dy()
		spanAscent := ti.baseline

		// forceToNextLine handles the case in which the first segment of the new span does not fit
		// AND there is already content on the current line. If there is no content on the line,


@@ 109,7 128,7 @@ func (t TextStyle) Layout(gtx layout.Context, spanFn func(gtx layout.Context, id
			lineShapes = append(lineShapes, spanShape{
				offset: image.Point{X: lineDims.X},
				size:   image.Point{X: spanWidth, Y: spanHeight},
				layout: firstLine.Layout,
				call:   call,
				ascent: spanAscent,
			})
			// update the dimensions of the current line


@@ 130,19 149,17 @@ func (t TextStyle) Layout(gtx layout.Context, spanFn func(gtx layout.Context, id

		// if we are breaking the current span across lines or we are on the
		// last span, lay out all of the spans for the line.
		if len(lines) > 1 || i == len(spans)-1 || forceToNextLine {
		if multiLine || ti.hasNewline || i == len(spans)-1 || forceToNextLine {
			lineMacro := op.Record(gtx.Ops)
			for i, shape := range lineShapes {
				// lay out this span
				span = spans[i+lineStartIndex]
				shape.offset.Y = overallSize.Y + lineAscent
				span.Layout(gtx, t.Shaper, shape)
				shape.offset.Y = overallSize.Y
				span.Layout(gtx, shape)

				if spanFn == nil {
					continue
				}
				// set this offset to the upper corner of the text, not the lower
				shape.offset.Y -= shape.ascent
				offStack := op.Offset(shape.offset).Push(gtx.Ops)
				fnGtx := gtx
				fnGtx.Constraints.Min = image.Point{}


@@ 175,14 192,14 @@ func (t TextStyle) Layout(gtx layout.Context, spanFn func(gtx layout.Context, id
			// reset line shaping data and update overall vertical dimensions
			lineShapes = lineShapes[:0]
			overallSize.Y += lineDims.Y
			lineDims = image.Point{}
			lineAscent = 0
		}

		// if the current span breaks across lines
		if len(lines) > 1 && !forceToNextLine {
		if multiLine && !forceToNextLine {
			// mark where the next line to be laid out starts
			lineStartIndex = i + 1
			lineDims = image.Point{}
			lineAscent = 0

			// ensure the spans slice has room for another span
			spans = append(spans, SpanStyle{})


@@ 192,7 209,7 @@ func (t TextStyle) Layout(gtx layout.Context, spanFn func(gtx layout.Context, id
			}
			// synthesize and insert a new span
			byteLen := 0
			for i := 0; i < firstLine.Layout.Runes.Count; i++ {
			for i := 0; i < runesDisplayed; i++ {
				_, n := utf8.DecodeRuneInString(span.Content[byteLen:])
				byteLen += n
			}


@@ 201,9 218,10 @@ func (t TextStyle) Layout(gtx layout.Context, spanFn func(gtx layout.Context, id
		} else if forceToNextLine {
			// mark where the next line to be laid out starts
			lineStartIndex = i
			lineDims = image.Point{}
			lineAscent = 0
			i--
		} else if ti.hasNewline {
			// mark where the next line to be laid out starts
			lineStartIndex = i + 1
		}
	}