/
styledtext.go
273 lines (238 loc) · 7.38 KB
/
styledtext.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
// Package styledtext provides rendering of text containing multiple fonts and styles.
package styledtext
import (
"image"
"image/color"
"unicode/utf8"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"golang.org/x/image/math/fixed"
)
// SpanStyle describes the appearance of a span of styled text.
type SpanStyle struct {
Font text.Font
Size unit.Sp
Color color.NRGBA
Content string
idx int
Img image.Image
}
// spanShape describes the text shaping of a single span.
type spanShape struct {
offset image.Point
call op.CallOp
size image.Point
ascent int
}
// Layout renders the span using the provided text shaping.
func (ss SpanStyle) Layout(gtx layout.Context, shape spanShape) layout.Dimensions {
if ss.Img != nil {
defer op.Offset(shape.offset).Push(gtx.Ops).Pop()
defer clip.Rect{Max: shape.size}.Op().Push(gtx.Ops).Pop()
w := &widget.Image{
Src: paint.NewImageOp(ss.Img),
Fit: widget.ScaleDown,
Position: layout.NW,
}
gtx.Constraints.Max.Y = shape.size.Y - 25
return w.Layout(gtx)
}
paint.ColorOp{Color: ss.Color}.Add(gtx.Ops)
defer op.Offset(shape.offset).Push(gtx.Ops).Pop()
shape.call.Add(gtx.Ops)
return layout.Dimensions{Size: shape.size}
}
// TextStyle presents rich text.
type TextStyle struct {
Styles []SpanStyle
Alignment text.Alignment
*text.Shaper
}
// Text constructs a TextStyle.
func Text(shaper *text.Shaper, styles ...SpanStyle) TextStyle {
return TextStyle{
Styles: styles,
Shaper: shaper,
}
}
// Layout renders the TextStyle.
//
// The spanFn function, if not nil, gets called for each span after it has been
// drawn, with the offset set to the span's top left corner. This can be used to
// set up input handling, for example.
//
// The context's maximum constraint is set to the span's dimensions, while the
// dims argument additionally provides the text's baseline. The idx argument is
// the span's index in TextStyle.Styles. The function may get called multiple
// times with the same index if a span has to be broken across multiple lines.
func (t TextStyle) Layout(gtx layout.Context, spanFn func(gtx layout.Context, idx int, dims layout.Dimensions)) layout.Dimensions {
spans := make([]SpanStyle, len(t.Styles))
copy(spans, t.Styles)
for i := range spans {
spans[i].idx = i
}
var (
lineDims image.Point
lineAscent int
overallSize image.Point
lineShapes []spanShape
lineStartIndex int
glyphs [32]text.Glyph
)
for i := 0; i < len(spans); i++ {
// grab the next span
span := spans[i]
var spanWidth, spanHeight, spanAscent, maxWidth, runesDisplayed int
var call op.CallOp
var multiLine bool
var ti textIterator
if span.Img == nil {
// constrain the width of the line to the remaining space
maxWidth = gtx.Constraints.Max.X - lineDims.X
// shape the text of the current span
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
spanWidth = ti.bounds.Dx()
spanHeight = ti.bounds.Dy()
spanAscent = ti.baseline
} else {
img := paint.NewImageOp(span.Img)
bounds := img.Size()
scale := float64(gtx.Constraints.Max.X) / float64(bounds.X)
tryScale := float64(1000) / float64(bounds.X)
if tryScale > 4 {
if bounds.X < 100 && bounds.Y < 100 {
scale = 2
} else {
scale = 1
}
}
height := int(float64(bounds.Y) * scale)
spanWidth = gtx.Constraints.Max.X
spanHeight = height
spanAscent = 0
}
// 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
lineShapes = append(lineShapes, spanShape{
offset: image.Point{X: lineDims.X},
size: image.Point{X: spanWidth, Y: spanHeight},
call: call,
ascent: spanAscent,
})
// 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
}
}
// if we are breaking the current span across lines, or we are on the
// last span, lay out all the spans for the line.
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
span.Layout(gtx, shape)
if spanFn == nil {
continue
}
offStack := op.Offset(shape.offset).Push(gtx.Ops)
fnGtx := gtx
fnGtx.Constraints.Min = image.Point{}
fnGtx.Constraints.Max = shape.size
spanFn(fnGtx, span.idx, layout.Dimensions{Size: shape.size, Baseline: shape.ascent})
offStack.Pop()
}
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 := lineShapes[len(lineShapes)-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
lineShapes = lineShapes[:0]
overallSize.Y += lineDims.Y
lineDims = image.Point{}
lineAscent = 0
}
// if the current span breaks across lines
if multiLine && !forceToNextLine {
// mark where the next line to be laid out starts
lineStartIndex = i + 1
// 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 < runesDisplayed; 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
i--
} else if ti.hasNewline {
// mark where the next line to be laid out starts
lineStartIndex = i + 1
}
}
return layout.Dimensions{Size: gtx.Constraints.Constrain(overallSize)}
}