~whereswaldon/gio-x

58f5cac28e82fb43c55a7f6dd335c9146cc10905 — Chris Waldon 1 year, 2 months ago 9da08d9 new-text-api
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>
3 files changed, 185 insertions(+), 26 deletions(-)

M richtext/richtext.go
A styledtext/iterator.go
M styledtext/styledtext.go
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
		}
	}