~whereswaldon/gio-x

8fad0626e912370942a3bd5a7d84477d841e7120 — Dominik Honnef 8 months ago 966239d
richtext: reimplement on top of styledtext

Signed-off-by: Dominik Honnef <dominik@honnef.co>
1 files changed, 47 insertions(+), 203 deletions(-)

M richtext/richtext.go
M richtext/richtext.go => richtext/richtext.go +47 -203
@@ 1,23 1,18 @@
/*
Package richtext provides rendering of text containing multiple fonts, styles, and levels of interactivity.
*/
// Package richtext provides rendering of text containing multiple fonts, styles, and levels of interactivity.
package richtext

import (
	"image"
	"image/color"
	"time"
	"unicode/utf8"

	"gioui.org/gesture"
	"gioui.org/io/pointer"
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
	"gioui.org/op/paint"
	"gioui.org/text"
	"gioui.org/unit"
	"golang.org/x/image/math/fixed"
	"gioui.org/x/styledtext"
)

// LongPressDuration is the default duration of a long press gesture.


@@ 56,6 51,9 @@ type InteractiveSpan struct {
// Layout adds the pointer input op for this interactive span and updates its
// state. It uses the most recent pointer.AreaOp as its input area.
func (i *InteractiveSpan) Layout(gtx layout.Context) layout.Dimensions {
	defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()

	pointer.CursorPointer.Add(gtx.Ops)
	i.click.Add(gtx.Ops)
	for _, e := range i.click.Events(gtx) {
		switch e.Type {


@@ 110,27 108,19 @@ func (i *InteractiveSpan) Get(key string) interface{} {
// InteractiveText holds persistent state for a block of text containing
// spans that may be interactive.
type InteractiveText struct {
	Spans   []InteractiveSpan
	current int
	Spans []InteractiveSpan
}

// next returns an InteractiveSpan that hasn't been used since the last
// call to reset().
func (i *InteractiveText) next() *InteractiveSpan {
	if i.current >= len(i.Spans) {
		i.Spans = append(i.Spans, InteractiveSpan{})
// resize makes sure that there are exactly n interactive spans.
func (i *InteractiveText) resize(n int) {
	if n == 0 && i == nil {
		return
	}
	span := &i.Spans[i.current]
	i.current++
	return span
}

// reset moves the internal iteration cursor back the start of the spans,
// allowing them to be reused. This should be called at the start of every
// layout.
func (i *InteractiveText) reset() {
	if i != nil {
		i.current = 0
	if cap(i.Spans) >= n {
		i.Spans = i.Spans[:n]
	} else {
		i.Spans = make([]InteractiveSpan, n)
	}
}



@@ 148,19 138,13 @@ func (i *InteractiveText) Events() (*InteractiveSpan, []Event) {

// SpanStyle describes the appearance of a span of styled text.
type SpanStyle struct {
	Font        text.Font
	Size        unit.Sp
	Color       color.NRGBA
	Content     string
	Interactive bool
	metadata    map[string]interface{}
}

// spanShape describes the text shaping of a single span.
type spanShape struct {
	offset image.Point
	layout text.Layout
	size   image.Point
	Font           text.Font
	Size           unit.Sp
	Color          color.NRGBA
	Content        string
	Interactive    bool
	metadata       map[string]interface{}
	interactiveIdx int
}

// Set configures a metadata key-value pair on the span that can be


@@ 182,15 166,6 @@ func (ss *SpanStyle) Set(key string, value interface{}) {
	ss.metadata[key] = value
}

// Layout renders the span using the provided text shaping.
func (ss SpanStyle) Layout(gtx layout.Context, s text.Shaper, 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)
	return layout.Dimensions{Size: shape.size}
}

// DeepCopy returns an identical SpanStyle with its own copy of its metadata.
func (ss SpanStyle) DeepCopy() SpanStyle {
	out := ss


@@ 223,165 198,34 @@ func Text(state *InteractiveText, shaper text.Shaper, styles ...SpanStyle) TextS

// Layout renders the TextStyle.
func (t TextStyle) Layout(gtx layout.Context) layout.Dimensions {
	spans := make([]SpanStyle, len(t.Styles))
	copy(spans, t.Styles)
	t.State.reset()

	var (
		lineDims       image.Point
		lineAscent     int
		overallSize    image.Point
		spanShapes     []spanShape
		lineStartIndex int
		state          *InteractiveSpan
	)

	// We cannot simply lay out spans from front to back in a single pass, because multiple spans on the same line may
	// have different line heights. A taller span following a narrower span will retroactively affect the narrower
	// span's baseline. Instead, we collect spans for a line until we know that the line is full before rendering it.
	for i := 0; i < len(spans); i++ {
		// grab the next span
		span := spans[i]

		// constrain the width of the line to the remaining space
		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)

		// 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()

		// 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,
		// we should display the content that doesn't fit anyway, as it won't fit on the next
		// line either.
		forceToNextLine := lineDims.X > 0 && spanWidth > maxWidth

		if !forceToNextLine {
			// store the text shaping results for the line
			spanShapes = append(spanShapes, spanShape{
				offset: image.Point{X: lineDims.X},
				size:   image.Point{X: spanWidth, Y: spanHeight},
				layout: firstLine.Layout,
			})
			// update the dimensions of the current line
			lineDims.X += spanWidth
			if lineDims.Y < spanHeight {
				lineDims.Y = spanHeight
			}
			if lineAscent < spanAscent {
				lineAscent = spanAscent
			}

			// update the width of the overall text
			if overallSize.X < lineDims.X {
				overallSize.X = lineDims.X
			}

	// OPT(dh): it'd be nice to avoid this allocation
	styles := make([]styledtext.SpanStyle, len(t.Styles))
	numInteractive := 0
	for i, st := range t.Styles {
		if st.Interactive {
			st.interactiveIdx = numInteractive
			numInteractive++
		}

		// 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 {
			lineMacro := op.Record(gtx.Ops)
			for i, shape := range spanShapes {
				// lay out this span
				span = spans[i+lineStartIndex]
				shape.offset.Y = overallSize.Y + lineAscent
				span.Layout(gtx, t.Shaper, shape)

				if !span.Interactive {
					state = nil
					continue
				}
				// grab an interactive state and lay it out atop the text.
				// If we still have a state, this line is a continuation of
				// the previous span and we should use the same state.
				if state == nil {
					state = t.State.next()
					state.contents = span.Content
					state.metadata = span.metadata
				}
				// set this offset to the upper corner of the text, not the lower
				shape.offset.Y -= lineDims.Y
				offStack := op.Offset(shape.offset).Push(gtx.Ops)
				pr := clip.Rect(image.Rectangle{Max: shape.size}).Push(gtx.Ops)
				state.Layout(gtx)
				pointer.CursorPointer.Add(gtx.Ops)
				pr.Pop()
				offStack.Pop()
				// ensure that we request new state for each interactive text
				// that isn't breaking across a line.
				if i < len(spanShapes)-1 {
					state = nil
				}
			}
			lineCall := lineMacro.Stop()

			// Compute padding to align line. If the line is longer than can be displayed then padding is implicitly
			// limited to zero.
			finalShape := spanShapes[len(spanShapes)-1]
			lineWidth := finalShape.offset.X + finalShape.size.X
			var pad int
			if lineWidth < gtx.Constraints.Max.X {
				switch t.Alignment {
				case text.Start:
					pad = 0
				case text.Middle:
					pad = (gtx.Constraints.Max.X - lineWidth) / 2
				case text.End:
					pad = gtx.Constraints.Max.X - lineWidth
				}
			}

			stack := op.Offset(image.Pt(pad, 0)).Push(gtx.Ops)
			lineCall.Add(gtx.Ops)
			stack.Pop()

			// reset line shaping data and update overall vertical dimensions
			spanShapes = spanShapes[:0]
			overallSize.Y += lineDims.Y
		}

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

			// if this span isn't interactive, don't use the same interaction
			// state on the next line.
			if !span.Interactive {
				state = nil
			}

			// ensure the spans slice has room for another span
			spans = append(spans, SpanStyle{})
			// shift existing spans further
			for k := len(spans) - 1; k > i+1; k-- {
				spans[k] = spans[k-1]
			}
			// synthesize and insert a new span
			byteLen := 0
			for i := 0; i < firstLine.Layout.Runes.Count; i++ {
				_, n := utf8.DecodeRuneInString(span.Content[byteLen:])
				byteLen += n
			}
			span.Content = span.Content[byteLen:]
			spans[i+1] = span
		} else if forceToNextLine {
			// mark where the next line to be laid out starts
			lineStartIndex = i
			lineDims = image.Point{}
			lineAscent = 0
			i--
		styles[i] = styledtext.SpanStyle{
			Font:    st.Font,
			Size:    st.Size,
			Color:   st.Color,
			Content: st.Content,
		}
	}
	t.State.resize(numInteractive)

	text := styledtext.Text(t.Shaper, styles...)
	text.Alignment = t.Alignment
	return text.Layout(gtx, func(_ layout.Context, i int, _ layout.Dimensions) {
		span := &t.Styles[i]
		if !span.Interactive {
			return
		}

	return layout.Dimensions{Size: gtx.Constraints.Constrain(overallSize)}
		state := &t.State.Spans[span.interactiveIdx]
		state.contents = span.Content
		state.metadata = span.metadata
		state.Layout(gtx)
	})
}