• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

fyne-io / fyne / 18807176232

25 Oct 2025 06:34PM UTC coverage: 61.057% (-0.004%) from 61.061%
18807176232

Pull #5989

github

Jacalz
Fix TODO regarding comparable map key
Pull Request #5989: RFC: Proof of concept for upgrading Go to 1.24

155 of 188 new or added lines in 62 files covered. (82.45%)

27 existing lines in 6 files now uncovered.

25609 of 41943 relevant lines covered (61.06%)

692.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.51
/widget/richtext.go
1
package widget
2

3
import (
4
        "image/color"
5
        "slices"
6
        "strings"
7
        "unicode"
8

9
        "github.com/go-text/typesetting/di"
10
        "github.com/go-text/typesetting/shaping"
11
        "golang.org/x/image/math/fixed"
12

13
        "fyne.io/fyne/v2"
14
        "fyne.io/fyne/v2/canvas"
15
        "fyne.io/fyne/v2/internal/cache"
16
        paint "fyne.io/fyne/v2/internal/painter"
17
        "fyne.io/fyne/v2/internal/widget"
18
        "fyne.io/fyne/v2/layout"
19
        "fyne.io/fyne/v2/theme"
20
)
21

22
const passwordChar = "•"
23

24
var _ fyne.Widget = (*RichText)(nil)
25

26
// RichText represents the base element for a rich text-based widget.
27
//
28
// Since: 2.1
29
type RichText struct {
30
        BaseWidget
31
        Segments []RichTextSegment
32
        Wrapping fyne.TextWrap
33
        Scroll   fyne.ScrollDirection
34

35
        // The truncation mode of the text
36
        //
37
        // Since: 2.4
38
        Truncation fyne.TextTruncation
39

40
        inset     fyne.Size     // this varies due to how the widget works (entry with scroller vs others with padding)
41
        rowBounds []rowBoundary // cache for boundaries
42
        scr       *widget.Scroll
43
        prop      *canvas.Rectangle // used to apply text minsize to the scroller `scr`, if present - TODO improve #2464
44

45
        visualCache map[RichTextSegment][]fyne.CanvasObject
46
        minCache    fyne.Size
47
}
48

49
// NewRichText returns a new RichText widget that renders the given text and segments.
50
// If no segments are specified it will be converted to a single segment using the default text settings.
51
//
52
// Since: 2.1
53
func NewRichText(segments ...RichTextSegment) *RichText {
2,113✔
54
        t := &RichText{Segments: segments}
2,113✔
55
        t.Scroll = widget.ScrollNone
2,113✔
56
        return t
2,113✔
57
}
2,113✔
58

59
// NewRichTextWithText returns a new RichText widget that renders the given text.
60
// The string will be converted to a single text segment using the default text settings.
61
//
62
// Since: 2.1
63
func NewRichTextWithText(text string) *RichText {
1,883✔
64
        return NewRichText(&TextSegment{
1,883✔
65
                Style: RichTextStyleInline,
1,883✔
66
                Text:  text,
1,883✔
67
        })
1,883✔
68
}
1,883✔
69

70
// CreateRenderer is a private method to Fyne which links this widget to its renderer
71
func (t *RichText) CreateRenderer() fyne.WidgetRenderer {
1,604✔
72
        t.prop = canvas.NewRectangle(color.Transparent)
1,604✔
73
        if t.scr == nil && t.Scroll != widget.ScrollNone {
1,607✔
74
                t.scr = widget.NewScroll(&fyne.Container{Layout: layout.NewStackLayout(), Objects: []fyne.CanvasObject{
3✔
75
                        t.prop, &fyne.Container{},
3✔
76
                }})
3✔
77
        }
3✔
78

79
        t.ExtendBaseWidget(t)
1,604✔
80
        r := &textRenderer{obj: t}
1,604✔
81

1,604✔
82
        t.updateRowBounds() // set up the initial text layout etc
1,604✔
83
        r.Refresh()
1,604✔
84
        return r
1,604✔
85
}
86

87
// MinSize calculates the minimum size of a rich text widget.
88
// This is based on the contained text with a standard amount of padding added.
89
func (t *RichText) MinSize() fyne.Size {
7,678✔
90
        // we don't return the minCache here, as any internal segments could have caused it to change...
7,678✔
91
        t.ExtendBaseWidget(t)
7,678✔
92

7,678✔
93
        min := t.BaseWidget.MinSize()
7,678✔
94
        t.minCache = min
7,678✔
95
        return min
7,678✔
96
}
7,678✔
97

98
// Refresh triggers a redraw of the rich text.
99
func (t *RichText) Refresh() {
10,813✔
100
        t.minCache = fyne.Size{}
10,813✔
101
        t.updateRowBounds()
10,813✔
102

10,813✔
103
        for _, s := range t.Segments {
21,636✔
104
                if txt, ok := s.(*TextSegment); ok {
21,641✔
105
                        txt.parent = t
10,818✔
106
                }
10,818✔
107
        }
108

109
        t.BaseWidget.Refresh()
10,813✔
110
}
111

112
// Resize sets a new size for the rich text.
113
// This should only be called if it is not in a container with a layout manager.
114
func (t *RichText) Resize(size fyne.Size) {
3,949✔
115
        if size == t.Size() {
4,824✔
116
                return
875✔
117
        }
875✔
118

119
        t.size = size
3,074✔
120

3,074✔
121
        skipResize := !t.minCache.IsZero() && size.Width >= t.minCache.Width && size.Height >= t.minCache.Height && t.Wrapping == fyne.TextWrapOff && t.Truncation == fyne.TextTruncateOff
3,074✔
122

3,074✔
123
        if skipResize {
4,009✔
124
                if len(t.Segments) < 2 { // we can simplify :)
1,868✔
125
                        cache.Renderer(t).Layout(size)
933✔
126
                        return
933✔
127
                }
933✔
128
        }
129
        t.updateRowBounds()
2,141✔
130

2,141✔
131
        t.Refresh()
2,141✔
132
}
133

134
// String returns the text widget buffer as string
135
func (t *RichText) String() string {
676✔
136
        ret := strings.Builder{}
676✔
137
        for _, seg := range t.Segments {
1,368✔
138
                ret.WriteString(seg.Textual())
692✔
139
        }
692✔
140
        return ret.String()
676✔
141
}
142

143
// charMinSize returns the average char size to use for internal computation
144
func (t *RichText) charMinSize(concealed bool, style fyne.TextStyle, textSize float32) fyne.Size {
9,548✔
145
        defaultChar := "M"
9,548✔
146
        if concealed {
9,627✔
147
                defaultChar = passwordChar
79✔
148
        }
79✔
149

150
        return fyne.MeasureText(defaultChar, textSize, style)
9,548✔
151
}
152

153
// deleteFromTo removes the text between the specified positions
154
func (t *RichText) deleteFromTo(lowBound int, highBound int) []rune {
66✔
155
        if lowBound >= highBound {
66✔
156
                return []rune{}
×
157
        }
×
158

159
        start := 0
66✔
160
        ret := make([]rune, 0, highBound-lowBound)
66✔
161
        deleting := false
66✔
162
        var segs []RichTextSegment
66✔
163
        for i, seg := range t.Segments {
136✔
164
                if _, ok := seg.(*TextSegment); !ok {
71✔
165
                        if !deleting {
1✔
166
                                segs = append(segs, seg)
×
167
                        }
×
168
                        continue
1✔
169
                }
170
                end := start + len([]rune(seg.(*TextSegment).Text))
69✔
171
                if end < lowBound {
69✔
172
                        segs = append(segs, seg)
×
173
                        start = end
×
174
                        continue
×
175
                }
176

177
                startOff := max(lowBound-start, 0)
69✔
178
                endOff := min(end, highBound) - start
69✔
179
                r := ([]rune)(seg.(*TextSegment).Text)
69✔
180
                ret = append(ret, r[startOff:endOff]...)
69✔
181
                r2 := append(r[:startOff], r[endOff:]...)
69✔
182
                seg.(*TextSegment).Text = string(r2)
69✔
183
                segs = append(segs, seg)
69✔
184

69✔
185
                // prepare next iteration
69✔
186
                start = end
69✔
187
                if start >= highBound {
135✔
188
                        segs = append(segs, t.Segments[i+1:]...)
66✔
189
                        break
66✔
190
                } else if start >= lowBound {
6✔
191
                        deleting = true
3✔
192
                }
3✔
193
        }
194
        t.Segments = segs
66✔
195
        t.Refresh()
66✔
196
        return ret
66✔
197
}
198

199
// cachedSegmentVisual returns a cached segment visual representation.
200
// The offset value is > 0 if the segment had been split and so we need multiple objects.
201
func (t *RichText) cachedSegmentVisual(seg RichTextSegment, offset int) fyne.CanvasObject {
6,221✔
202
        if t.visualCache == nil {
7,821✔
203
                t.visualCache = make(map[RichTextSegment][]fyne.CanvasObject)
1,600✔
204
        }
1,600✔
205

206
        if vis, ok := t.visualCache[seg]; ok && offset < len(vis) {
10,656✔
207
                return vis[offset]
4,435✔
208
        }
4,435✔
209

210
        vis := seg.Visual()
1,786✔
211
        if offset < len(t.visualCache[seg]) {
1,786✔
212
                t.visualCache[seg][offset] = vis
×
213
        } else {
1,786✔
214
                t.visualCache[seg] = append(t.visualCache[seg], vis)
1,786✔
215
        }
1,786✔
216
        return vis
1,786✔
217
}
218

219
func (t *RichText) cleanVisualCache() {
5,207✔
220
        if len(t.visualCache) <= len(t.Segments) {
10,407✔
221
                return
5,200✔
222
        }
5,200✔
223
        var deletingSegs []RichTextSegment
7✔
224
        for seg1 := range t.visualCache {
31✔
225
                found := slices.Contains(t.Segments, seg1)
24✔
226
                if !found {
48✔
227
                        // cached segment is not currently in t.Segments, clear it
24✔
228
                        deletingSegs = append(deletingSegs, seg1)
24✔
229
                }
24✔
230
        }
231
        for _, seg := range deletingSegs {
31✔
232
                delete(t.visualCache, seg)
24✔
233
        }
24✔
234
}
235

236
// insertAt inserts the text at the specified position
237
func (t *RichText) insertAt(pos int, runes []rune) {
293✔
238
        index := 0
293✔
239
        start := 0
293✔
240
        var into *TextSegment
293✔
241
        for i, seg := range t.Segments {
586✔
242
                if _, ok := seg.(*TextSegment); !ok {
293✔
243
                        continue
×
244
                }
245
                end := start + len([]rune(seg.(*TextSegment).Text))
293✔
246
                into = seg.(*TextSegment)
293✔
247
                index = i
293✔
248
                if end > pos {
329✔
249
                        break
36✔
250
                }
251

252
                start = end
257✔
253
        }
254

255
        if into == nil {
293✔
256
                return
×
257
        }
×
258
        r := ([]rune)(into.Text)
293✔
259
        if pos > len(r) { // safety in case position is out of bounds for the segment
293✔
260
                pos = len(r)
×
261
        }
×
262
        r2 := append(r[:pos], append(runes, r[pos:]...)...)
293✔
263
        into.Text = string(r2)
293✔
264
        t.Segments[index] = into
293✔
265
}
266

267
// Len returns the text widget buffer length
268
func (t *RichText) len() int {
3,778✔
269
        ret := 0
3,778✔
270
        for _, seg := range t.Segments {
7,556✔
271
                ret += len([]rune(seg.Textual()))
3,778✔
272
        }
3,778✔
273
        return ret
3,778✔
274
}
275

276
// lineSizeToColumn returns the rendered size for the line specified by row up to the col position
277
func (t *RichText) lineSizeToColumn(col, row int, textSize, innerPad float32) fyne.Size {
2,107✔
278
        if row < 0 {
2,108✔
279
                row = 0
1✔
280
        }
1✔
281
        if col < 0 {
2,108✔
282
                col = 0
1✔
283
        }
1✔
284
        bound := t.rowBoundary(row)
2,107✔
285
        total := fyne.NewSize(0, 0)
2,107✔
286
        counted := 0
2,107✔
287
        last := false
2,107✔
288
        if bound == nil {
2,107✔
289
                return t.charMinSize(false, fyne.TextStyle{}, textSize)
×
290
        }
×
291
        for i, seg := range bound.segments {
4,214✔
292
                var size fyne.Size
2,107✔
293
                if text, ok := seg.(*TextSegment); ok {
4,214✔
294
                        start := 0
2,107✔
295
                        if i == 0 {
4,214✔
296
                                start = bound.begin
2,107✔
297
                        }
2,107✔
298
                        measureText := []rune(text.Text)[start:]
2,107✔
299
                        if col < counted+len(measureText) {
3,691✔
300
                                measureText = measureText[0 : col-counted]
1,584✔
301
                                last = true
1,584✔
302
                        }
1,584✔
303
                        if concealed(seg) {
2,192✔
304
                                measureText = []rune(strings.Repeat(passwordChar, len(measureText)))
85✔
305
                        }
85✔
306
                        counted += len(measureText)
2,107✔
307

2,107✔
308
                        label := canvas.NewText(string(measureText), color.Black)
2,107✔
309
                        label.TextStyle = text.Style.TextStyle
2,107✔
310
                        label.TextSize = text.size()
2,107✔
311

2,107✔
312
                        size = label.MinSize()
2,107✔
313
                } else {
×
314
                        size = t.cachedSegmentVisual(seg, 0).MinSize()
×
315
                }
×
316

317
                total.Width += size.Width
2,107✔
318
                total.Height = max(total.Height, size.Height)
2,107✔
319
                if last {
3,691✔
320
                        break
1,584✔
321
                }
322
        }
323
        return total.Add(fyne.NewSize(innerPad-t.inset.Width, 0))
2,107✔
324
}
325

326
// Row returns the characters in the row specified.
327
// The row parameter should be between 0 and t.Rows()-1.
328
func (t *RichText) row(row int) []rune {
2,409✔
329
        if row < 0 || row >= t.rows() {
2,411✔
330
                return nil
2✔
331
        }
2✔
332
        bound := t.rowBounds[row]
2,407✔
333
        var ret []rune
2,407✔
334
        for i, seg := range bound.segments {
4,814✔
335
                if text, ok := seg.(*TextSegment); ok {
4,814✔
336
                        if i == 0 {
4,814✔
337
                                if len(bound.segments) == 1 {
4,814✔
338
                                        ret = append(ret, []rune(text.Text)[bound.begin:bound.end]...)
2,407✔
339
                                } else {
2,407✔
340
                                        ret = append(ret, []rune(text.Text)[bound.begin:]...)
×
341
                                }
×
342
                        } else if i == len(bound.segments)-1 && len(bound.segments) > 1 && bound.end != 0 {
×
343
                                ret = append(ret, []rune(text.Text)[:bound.end]...)
×
344
                        }
×
345
                }
346
        }
347
        return ret
2,407✔
348
}
349

350
// RowBoundary returns the boundary of the row specified.
351
// The row parameter should be between 0 and t.Rows()-1.
352
func (t *RichText) rowBoundary(row int) *rowBoundary {
4,349✔
353
        if row < 0 || row >= t.rows() {
4,349✔
354
                return nil
×
355
        }
×
356
        return &t.rowBounds[row]
4,349✔
357
}
358

359
// RowLength returns the number of visible characters in the row specified.
360
// The row parameter should be between 0 and t.Rows()-1.
361
func (t *RichText) rowLength(row int) int {
2,223✔
362
        return len(t.row(row))
2,223✔
363
}
2,223✔
364

365
// rows returns the number of text rows in this text entry.
366
// The entry may be longer than required to show this amount of content.
367
func (t *RichText) rows() int {
9,341✔
368
        if t.rowBounds == nil { // if the widget API is used before it is shown
9,473✔
369
                t.updateRowBounds()
132✔
370
        }
132✔
371
        return len(t.rowBounds)
9,341✔
372
}
373

374
// updateRowBounds updates the row bounds used to render properly the text widget.
375
// updateRowBounds should be invoked every time a segment Text, widget Wrapping or size changes.
376
func (t *RichText) updateRowBounds() {
17,496✔
377
        th := t.Theme()
17,496✔
378
        innerPadding := th.Size(theme.SizeNameInnerPadding)
17,496✔
379
        fitSize := t.Size()
17,496✔
380
        if t.scr != nil {
17,501✔
381
                fitSize = t.scr.Content.MinSize()
5✔
382
        }
5✔
383
        fitSize.Height -= (innerPadding + t.inset.Height) * 2
17,496✔
384

17,496✔
385
        var bounds []rowBoundary
17,496✔
386
        maxWidth := t.Size().Width - 2*innerPadding + 2*t.inset.Width
17,496✔
387
        wrapWidth := maxWidth
17,496✔
388

17,496✔
389
        var currentBound *rowBoundary
17,496✔
390
        var iterateSegments func(segList []RichTextSegment)
17,496✔
391
        iterateSegments = func(segList []RichTextSegment) {
35,015✔
392
                for _, seg := range segList {
35,073✔
393
                        if parent, ok := seg.(RichTextBlock); ok {
17,577✔
394
                                segs := parent.Segments()
23✔
395
                                iterateSegments(segs)
23✔
396
                                if len(segs) > 0 && !segs[len(segs)-1].Inline() {
43✔
397
                                        wrapWidth = maxWidth
20✔
398
                                        currentBound = nil
20✔
399
                                }
20✔
400
                                continue
23✔
401
                        }
402
                        if _, ok := seg.(*TextSegment); !ok {
17,540✔
403
                                if currentBound == nil {
15✔
404
                                        bound := rowBoundary{segments: []RichTextSegment{seg}}
6✔
405
                                        bounds = append(bounds, bound)
6✔
406
                                        currentBound = &bound
6✔
407
                                } else {
9✔
408
                                        bounds[len(bounds)-1].segments = append(bounds[len(bounds)-1].segments, seg)
3✔
409
                                }
3✔
410

411
                                itemMin := t.cachedSegmentVisual(seg, 0).MinSize()
9✔
412
                                if seg.Inline() {
13✔
413
                                        wrapWidth -= itemMin.Width
4✔
414
                                } else {
9✔
415
                                        wrapWidth = maxWidth
5✔
416
                                        currentBound = nil
5✔
417
                                        fitSize.Height -= itemMin.Height + th.Size(theme.SizeNameLineSpacing)
5✔
418
                                }
5✔
419
                                continue
9✔
420
                        }
421
                        textSeg := seg.(*TextSegment)
17,522✔
422
                        textStyle := textSeg.Style.TextStyle
17,522✔
423
                        textSize := textSeg.size()
17,522✔
424

17,522✔
425
                        leftPad := float32(0)
17,522✔
426
                        if textSeg.Style == RichTextStyleBlockquote {
17,522✔
427
                                leftPad = innerPadding * 2
×
428
                        }
×
429
                        retBounds, height := lineBounds(textSeg, t.Wrapping, t.Truncation, wrapWidth-leftPad, fyne.NewSize(maxWidth, fitSize.Height), func(text []rune) fyne.Size {
24,111✔
430
                                return fyne.MeasureText(string(text), textSize, textStyle)
6,589✔
431
                        })
6,589✔
432
                        if currentBound != nil {
17,544✔
433
                                if len(retBounds) > 0 {
44✔
434
                                        bounds[len(bounds)-1].end = retBounds[0].end // invalidate row ending as we have more content
22✔
435
                                        bounds[len(bounds)-1].segments = append(bounds[len(bounds)-1].segments, seg)
22✔
436
                                        bounds = append(bounds, retBounds[1:]...)
22✔
437

22✔
438
                                        fitSize.Height -= height
22✔
439
                                }
22✔
440
                        } else {
17,500✔
441
                                bounds = append(bounds, retBounds...)
17,500✔
442

17,500✔
443
                                fitSize.Height -= height
17,500✔
444
                        }
17,500✔
445
                        currentBound = &bounds[len(bounds)-1]
17,522✔
446
                        if seg.Inline() {
35,018✔
447
                                last := bounds[len(bounds)-1]
17,496✔
448
                                begin := 0
17,496✔
449
                                if len(last.segments) == 1 {
34,989✔
450
                                        begin = last.begin
17,493✔
451
                                }
17,493✔
452
                                runes := []rune(textSeg.Text)
17,496✔
453
                                // check ranges - as we resize it can be wrong?
17,496✔
454
                                if begin > len(runes) {
17,496✔
455
                                        begin = len(runes)
×
456
                                }
×
457
                                end := min(last.end, len(runes))
17,496✔
458
                                text := string(runes[begin:end])
17,496✔
459
                                measured := fyne.MeasureText(text, textSeg.size(), textSeg.Style.TextStyle)
17,496✔
460
                                lastWidth := measured.Width
17,496✔
461
                                if len(retBounds) == 1 {
33,542✔
462
                                        wrapWidth -= lastWidth
16,046✔
463
                                } else {
17,496✔
464
                                        wrapWidth = maxWidth - lastWidth
1,450✔
465
                                }
1,450✔
466
                        } else {
26✔
467
                                currentBound = nil
26✔
468
                                wrapWidth = maxWidth
26✔
469
                        }
26✔
470
                }
471
        }
472

473
        iterateSegments(t.Segments)
17,496✔
474
        t.rowBounds = bounds
17,496✔
475
}
476

477
// RichTextBlock is an extension of a text segment that contains other segments
478
//
479
// Since: 2.1
480
type RichTextBlock interface {
481
        Segments() []RichTextSegment
482
}
483

484
// Renderer
485
type textRenderer struct {
486
        widget.BaseRenderer
487
        obj *RichText
488
}
489

490
func (r *textRenderer) Layout(size fyne.Size) {
6,203✔
491
        th := r.obj.Theme()
6,203✔
492
        bounds := r.obj.rowBounds
6,203✔
493
        objs := r.Objects()
6,203✔
494
        if r.obj.scr != nil {
6,207✔
495
                r.obj.scr.Resize(size)
4✔
496
                objs = r.obj.scr.Content.(*fyne.Container).Objects[1].(*fyne.Container).Objects
4✔
497
        }
4✔
498

499
        // Accessing theme here is slow, so we cache the value
500
        innerPadding := th.Size(theme.SizeNameInnerPadding)
6,203✔
501
        lineSpacing := th.Size(theme.SizeNameLineSpacing)
6,203✔
502

6,203✔
503
        xInset := innerPadding - r.obj.inset.Width
6,203✔
504
        left := xInset
6,203✔
505
        yPos := innerPadding - r.obj.inset.Height
6,203✔
506
        lineWidth := size.Width - left*2
6,203✔
507
        var rowItems []fyne.CanvasObject
6,203✔
508
        rowAlign := fyne.TextAlignLeading
6,203✔
509
        i := 0
6,203✔
510
        for row, bound := range bounds {
13,459✔
511
                for segI := range bound.segments {
14,528✔
512
                        if i == len(objs) {
7,272✔
513
                                break // Refresh may not have created all objects for all rows yet...
×
514
                        }
515
                        inline := segI < len(bound.segments)-1
7,272✔
516
                        obj := objs[i]
7,272✔
517
                        i++
7,272✔
518
                        _, isText := obj.(*canvas.Text)
7,272✔
519
                        if !isText && !inline {
7,280✔
520
                                if len(rowItems) != 0 {
10✔
521
                                        width, _ := r.layoutRow(rowItems, rowAlign, left, yPos, lineWidth)
2✔
522
                                        left += width
2✔
523
                                        rowItems = nil
2✔
524
                                }
2✔
525
                                height := obj.MinSize().Height
8✔
526

8✔
527
                                obj.Move(fyne.NewPos(left, yPos))
8✔
528
                                obj.Resize(fyne.NewSize(lineWidth, height))
8✔
529
                                yPos += height
8✔
530
                                left = xInset
8✔
531
                                continue
8✔
532
                        }
533
                        rowItems = append(rowItems, obj)
7,264✔
534
                        if inline {
7,280✔
535
                                continue
16✔
536
                        }
537

538
                        leftPad := float32(0)
7,248✔
539
                        if text, ok := bound.segments[0].(*TextSegment); ok {
14,496✔
540
                                rowAlign = text.Style.Alignment
7,248✔
541
                                if text.Style == RichTextStyleBlockquote {
7,248✔
542
                                        leftPad = lineSpacing * 4
×
543
                                }
×
544
                        } else if link, ok := bound.segments[0].(*HyperlinkSegment); ok {
×
545
                                rowAlign = link.Alignment
×
546
                        }
×
547
                        _, y := r.layoutRow(rowItems, rowAlign, left+leftPad, yPos, lineWidth-leftPad)
7,248✔
548
                        yPos += y
7,248✔
549
                        rowItems = nil
7,248✔
550
                }
551

552
                lastSeg := bound.segments[len(bound.segments)-1]
7,256✔
553
                if !lastSeg.Inline() && row < len(bounds)-1 && bounds[row+1].segments[0] != lastSeg { // ignore wrapped lines etc
7,263✔
554
                        yPos += lineSpacing
7✔
555
                }
7✔
556
        }
557
}
558

559
// MinSize calculates the minimum size of a rich text widget.
560
// This is based on the contained text with a standard amount of padding added.
561
func (r *textRenderer) MinSize() fyne.Size {
7,678✔
562
        th := r.obj.Theme()
7,678✔
563
        textSize := th.Size(theme.SizeNameText)
7,678✔
564
        innerPad := th.Size(theme.SizeNameInnerPadding)
7,678✔
565

7,678✔
566
        bounds := r.obj.rowBounds
7,678✔
567
        wrap := r.obj.Wrapping
7,678✔
568
        trunc := r.obj.Truncation
7,678✔
569
        scroll := r.obj.Scroll
7,678✔
570
        objs := r.Objects()
7,678✔
571
        if r.obj.scr != nil {
7,686✔
572
                objs = r.obj.scr.Content.(*fyne.Container).Objects[1].(*fyne.Container).Objects
8✔
573
        }
8✔
574

575
        charMinSize := r.obj.charMinSize(false, fyne.TextStyle{}, textSize)
7,678✔
576
        min := r.calculateMin(bounds, wrap, objs, charMinSize, th)
7,678✔
577
        if r.obj.scr != nil {
7,686✔
578
                r.obj.prop.SetMinSize(min)
8✔
579
        }
8✔
580

581
        if trunc != fyne.TextTruncateOff && r.obj.Scroll == widget.ScrollNone {
7,925✔
582
                minBounds := charMinSize
247✔
583
                if wrap == fyne.TextWrapOff {
494✔
584
                        minBounds.Height = min.Height
247✔
585
                } else {
247✔
586
                        minBounds = minBounds.Add(fyne.NewSquareSize(innerPad * 2).Subtract(r.obj.inset).Subtract(r.obj.inset))
×
587
                }
×
588
                if trunc == fyne.TextTruncateClip {
247✔
589
                        return minBounds
×
590
                } else if trunc == fyne.TextTruncateEllipsis {
494✔
591
                        ellipsisSize := fyne.MeasureText("…", th.Size(theme.SizeNameText), fyne.TextStyle{})
247✔
592
                        return minBounds.AddWidthHeight(ellipsisSize.Width, 0)
247✔
593
                }
247✔
594
        }
595

596
        switch scroll {
7,431✔
597
        case widget.ScrollBoth:
2✔
598
                return fyne.NewSize(32, 32)
2✔
599
        case widget.ScrollHorizontalOnly:
×
600
                return fyne.NewSize(32, min.Height)
×
601
        case widget.ScrollVerticalOnly:
6✔
602
                return fyne.NewSize(min.Width, 32)
6✔
603
        default:
7,423✔
604
                return min
7,423✔
605
        }
606
}
607

608
func (r *textRenderer) calculateMin(bounds []rowBoundary, wrap fyne.TextWrap, objs []fyne.CanvasObject,
609
        charMinSize fyne.Size, th fyne.Theme,
610
) fyne.Size {
7,678✔
611
        height := float32(0)
7,678✔
612
        width := float32(0)
7,678✔
613
        rowHeight := float32(0)
7,678✔
614
        rowWidth := float32(0)
7,678✔
615
        trunc := r.obj.Truncation
7,678✔
616
        innerPad := th.Size(theme.SizeNameInnerPadding)
7,678✔
617

7,678✔
618
        // Accessing the theme here is slow, so we cache the value
7,678✔
619
        lineSpacing := th.Size(theme.SizeNameLineSpacing)
7,678✔
620

7,678✔
621
        i := 0
7,678✔
622
        for row, bound := range bounds {
16,376✔
623
                for range bound.segments {
17,398✔
624
                        if i == len(objs) {
8,700✔
625
                                break // Refresh may not have created all objects for all rows yet...
×
626
                        }
627
                        obj := objs[i]
8,700✔
628
                        i++
8,700✔
629

8,700✔
630
                        min := obj.MinSize()
8,700✔
631
                        if img, ok := obj.(*richImage); ok {
8,700✔
632
                                if newMin := img.MinSize(); newMin != img.oldMin {
×
633
                                        img.oldMin = newMin
×
634

×
635
                                        min := r.calculateMin(bounds, wrap, objs, charMinSize, th)
×
636
                                        if r.obj.scr != nil {
×
637
                                                r.obj.prop.SetMinSize(min)
×
638
                                        }
×
639
                                        r.Refresh() // TODO resolve this in a similar way to #2991
×
640
                                }
641
                        }
642
                        rowHeight = max(rowHeight, min.Height)
8,700✔
643
                        rowWidth += min.Width
8,700✔
644
                }
645

646
                if wrap == fyne.TextWrapOff && trunc == fyne.TextTruncateOff {
15,329✔
647
                        width = max(width, rowWidth)
6,631✔
648
                }
6,631✔
649
                height += rowHeight
8,698✔
650
                rowHeight = 0
8,698✔
651
                rowWidth = 0
8,698✔
652

8,698✔
653
                lastSeg := bound.segments[len(bound.segments)-1]
8,698✔
654
                if !lastSeg.Inline() && row < len(bounds)-1 && bounds[row+1].segments[0] != lastSeg { // ignore wrapped lines etc
8,699✔
655
                        height += lineSpacing
1✔
656
                }
1✔
657
        }
658

659
        if height == 0 {
7,678✔
660
                height = charMinSize.Height
×
661
        }
×
662
        return fyne.NewSize(width, height).
7,678✔
663
                Add(fyne.NewSquareSize(innerPad * 2).Subtract(r.obj.inset).Subtract(r.obj.inset))
7,678✔
664
}
665

666
func (r *textRenderer) Refresh() {
5,207✔
667
        bounds := r.obj.rowBounds
5,207✔
668
        scroll := r.obj.Scroll
5,207✔
669

5,207✔
670
        var objs []fyne.CanvasObject
5,207✔
671
        for _, bound := range bounds {
11,403✔
672
                for i, seg := range bound.segments {
12,408✔
673
                        if _, ok := seg.(*TextSegment); !ok {
6,219✔
674
                                obj := r.obj.cachedSegmentVisual(seg, 0)
7✔
675
                                seg.Update(obj)
7✔
676
                                objs = append(objs, obj)
7✔
677
                                continue
7✔
678
                        }
679

680
                        reuse := 0
6,205✔
681
                        if i == 0 {
12,396✔
682
                                reuse = bound.firstSegmentReuse
6,191✔
683
                        }
6,191✔
684
                        obj := r.obj.cachedSegmentVisual(seg, reuse)
6,205✔
685
                        seg.Update(obj)
6,205✔
686
                        txt := obj.(*canvas.Text)
6,205✔
687
                        textSeg := seg.(*TextSegment)
6,205✔
688
                        runes := []rune(textSeg.Text)
6,205✔
689

6,205✔
690
                        if i == 0 {
12,396✔
691
                                if len(bound.segments) == 1 {
12,366✔
692
                                        txt.Text = string(runes[bound.begin:bound.end])
6,175✔
693
                                } else {
6,191✔
694
                                        txt.Text = string(runes[bound.begin:])
16✔
695
                                }
16✔
696
                        } else if i == len(bound.segments)-1 && len(bound.segments) > 1 {
28✔
697
                                txt.Text = string(runes[:bound.end])
14✔
698
                        }
14✔
699
                        if bound.ellipsis && i == len(bound.segments)-1 {
6,211✔
700
                                txt.Text = txt.Text + "…"
6✔
701
                        }
6✔
702

703
                        if concealed(seg) {
6,268✔
704
                                txt.Text = strings.Repeat(passwordChar, len(runes))
63✔
705
                        }
63✔
706

707
                        objs = append(objs, txt)
6,205✔
708
                }
709
        }
710

711
        if r.obj.scr != nil {
5,211✔
712
                r.obj.scr.Content = &fyne.Container{Layout: layout.NewStackLayout(), Objects: []fyne.CanvasObject{
4✔
713
                        r.obj.prop, &fyne.Container{Objects: objs},
4✔
714
                }}
4✔
715
                r.obj.scr.Direction = scroll
4✔
716
                r.SetObjects([]fyne.CanvasObject{r.obj.scr})
4✔
717
                r.obj.scr.Refresh()
4✔
718
        } else {
5,207✔
719
                r.SetObjects(objs)
5,203✔
720
        }
5,203✔
721

722
        r.Layout(r.obj.Size())
5,207✔
723
        canvas.Refresh(r.obj.super())
5,207✔
724

5,207✔
725
        r.obj.cleanVisualCache()
5,207✔
726
}
727

728
func (r *textRenderer) layoutRow(texts []fyne.CanvasObject, align fyne.TextAlign, xPos, yPos, lineWidth float32) (float32, float32) {
7,250✔
729
        initialX := xPos
7,250✔
730
        if len(texts) == 1 {
14,486✔
731
                min := texts[0].MinSize()
7,236✔
732
                if text, ok := texts[0].(*canvas.Text); ok {
14,472✔
733
                        texts[0].Resize(min)
7,236✔
734
                        xPad := float32(0)
7,236✔
735
                        switch text.Alignment {
7,236✔
736
                        case fyne.TextAlignLeading:
6,278✔
737
                        case fyne.TextAlignTrailing:
7✔
738
                                xPad = lineWidth - min.Width
7✔
739
                        case fyne.TextAlignCenter:
951✔
740
                                xPad = (lineWidth - min.Width) / 2
951✔
741
                        }
742
                        texts[0].Move(fyne.NewPos(xPos+xPad, yPos))
7,236✔
743
                } else {
×
744
                        texts[0].Resize(fyne.NewSize(lineWidth, min.Height))
×
745
                        texts[0].Move(fyne.NewPos(xPos, yPos))
×
746
                }
×
747
                return min.Width, min.Height
7,236✔
748
        }
749
        height := float32(0)
14✔
750
        tallestBaseline := float32(0)
14✔
751
        realign := false
14✔
752
        baselines := make([]float32, len(texts))
14✔
753

14✔
754
        // Access to theme is slow, so we cache the text size
14✔
755
        textSize := theme.SizeForWidget(theme.SizeNameText, r.obj)
14✔
756

14✔
757
        driver := fyne.CurrentApp().Driver()
14✔
758
        for i, text := range texts {
42✔
759
                var size fyne.Size
28✔
760
                if txt, ok := text.(*canvas.Text); ok {
56✔
761
                        s, base := driver.RenderedTextSize(txt.Text, txt.TextSize, txt.TextStyle, txt.FontSource)
28✔
762
                        if base > tallestBaseline {
42✔
763
                                if tallestBaseline > 0 {
14✔
764
                                        realign = true
×
765
                                }
×
766
                                tallestBaseline = base
14✔
767
                        }
768
                        size = s
28✔
769
                        baselines[i] = base
28✔
770
                } else if c, ok := text.(*fyne.Container); ok {
×
771
                        wid := c.Objects[0]
×
772
                        if link, ok := wid.(*Hyperlink); ok {
×
773
                                s, base := driver.RenderedTextSize(link.Text, textSize, link.TextStyle, nil)
×
774
                                if base > tallestBaseline {
×
775
                                        if tallestBaseline > 0 {
×
776
                                                realign = true
×
777
                                        }
×
778
                                        tallestBaseline = base
×
779
                                }
780
                                size = s
×
781
                                baselines[i] = base
×
782
                        }
783
                }
784
                if size.IsZero() {
28✔
785
                        size = text.MinSize()
×
786
                }
×
787
                text.Resize(size)
28✔
788
                text.Move(fyne.NewPos(xPos, yPos))
28✔
789

28✔
790
                xPos += size.Width
28✔
791
                if height == 0 {
42✔
792
                        height = size.Height
14✔
793
                } else if height != size.Height {
28✔
NEW
794
                        height = max(height, size.Height)
×
795
                        realign = true
×
796
                }
×
797
        }
798

799
        if realign {
14✔
800
                for i, text := range texts {
×
801
                        delta := tallestBaseline - baselines[i]
×
802
                        text.Move(fyne.NewPos(text.Position().X, yPos+delta))
×
803
                }
×
804
        }
805

806
        spare := lineWidth - xPos
14✔
807
        switch align {
14✔
808
        case fyne.TextAlignTrailing:
×
809
                first := texts[0]
×
810
                first.Resize(fyne.NewSize(first.Size().Width+spare, height))
×
811
                setAlign(first, fyne.TextAlignTrailing)
×
812

×
813
                for _, text := range texts[1:] {
×
814
                        text.Move(text.Position().Add(fyne.NewPos(spare, 0)))
×
815
                }
×
816
        case fyne.TextAlignCenter:
×
817
                pad := spare / 2
×
818
                first := texts[0]
×
819
                first.Resize(fyne.NewSize(first.Size().Width+pad, height))
×
820
                setAlign(first, fyne.TextAlignTrailing)
×
821
                last := texts[len(texts)-1]
×
822
                last.Resize(fyne.NewSize(last.Size().Width+pad, height))
×
823
                setAlign(last, fyne.TextAlignLeading)
×
824

×
825
                for _, text := range texts[1:] {
×
826
                        text.Move(text.Position().Add(fyne.NewPos(pad, 0)))
×
827
                }
×
828
        default:
14✔
829
                last := texts[len(texts)-1]
14✔
830
                last.Resize(fyne.NewSize(last.Size().Width+spare, height))
14✔
831
                setAlign(last, fyne.TextAlignLeading)
14✔
832
        }
833

834
        return xPos - initialX, height
14✔
835
}
836

837
// binarySearch accepts a function that checks if the text width less the maximum width and the start and end rune index
838
// binarySearch returns the index of rune located as close to the maximum line width as possible
839
func binarySearch(lessMaxWidth func(int, int) bool, low int, maxHigh int) int {
512✔
840
        if low >= maxHigh {
531✔
841
                return low
19✔
842
        }
19✔
843
        if lessMaxWidth(low, maxHigh) {
537✔
844
                return maxHigh
44✔
845
        }
44✔
846
        high := low
449✔
847
        delta := maxHigh - low
449✔
848
        for delta > 0 {
2,626✔
849
                delta /= 2
2,177✔
850
                if lessMaxWidth(low, high+delta) {
3,721✔
851
                        high += delta
1,544✔
852
                }
1,544✔
853
        }
854
        for (high < maxHigh) && lessMaxWidth(low, high+1) {
543✔
855
                high++
94✔
856
        }
94✔
857
        return high
449✔
858
}
859

860
// concealed returns true if the segment represents a password, meaning the text should be obscured.
861
func concealed(seg RichTextSegment) bool {
8,312✔
862
        if text, ok := seg.(*TextSegment); ok {
16,624✔
863
                return text.Style.concealed
8,312✔
864
        }
8,312✔
865

866
        return false
×
867
}
868

869
func ellipsisPriorBound(bounds []rowBoundary, trunc fyne.TextTruncation, width float32, measurer func([]rune) fyne.Size) []rowBoundary {
3✔
870
        if trunc != fyne.TextTruncateEllipsis || len(bounds) == 0 {
4✔
871
                return bounds
1✔
872
        }
1✔
873

874
        prior := bounds[len(bounds)-1]
2✔
875
        seg := prior.segments[0].(*TextSegment)
2✔
876
        ellipsisSize := fyne.MeasureText("…", seg.size(), seg.Style.TextStyle)
2✔
877

2✔
878
        widthChecker := func(low int, high int) bool {
10✔
879
                return measurer([]rune(seg.Text)[low:high]).Width <= width-ellipsisSize.Width
8✔
880
        }
8✔
881

882
        limit := binarySearch(widthChecker, prior.begin, prior.end)
2✔
883
        prior.end = limit
2✔
884

2✔
885
        prior.ellipsis = true
2✔
886
        bounds[len(bounds)-1] = prior
2✔
887
        return bounds
2✔
888
}
889

890
// findSpaceIndex accepts a slice of runes and a fallback index
891
// findSpaceIndex returns the index of the last space in the text, or fallback if there are no spaces
892
func findSpaceIndex(text []rune, fallback int) int {
390✔
893
        curIndex := fallback
390✔
894
        for ; curIndex >= 0; curIndex-- {
2,012✔
895
                if unicode.IsSpace(text[curIndex]) {
1,984✔
896
                        break
362✔
897
                }
898
        }
899
        if curIndex < 0 {
418✔
900
                return fallback
28✔
901
        }
28✔
902
        return curIndex
362✔
903
}
904

905
func float32ToFixed266(f float32) fixed.Int26_6 {
176✔
906
        return fixed.Int26_6(float64(f) * (1 << 6))
176✔
907
}
176✔
908

909
// lineBounds accepts a slice of Segments, a wrapping mode, a maximum size available to display and a function to
910
// measure text size.
911
// It will return a slice containing the boundary metadata of each line with the given wrapping applied and the
912
// total height required to render the boundaries at the given width/height constraints
913
func lineBounds(seg *TextSegment, wrap fyne.TextWrap, trunc fyne.TextTruncation, firstWidth float32, max fyne.Size, measurer func([]rune) fyne.Size) ([]rowBoundary, float32) {
17,581✔
914
        lines := splitLines(seg)
17,581✔
915

17,581✔
916
        if wrap == fyne.TextWrap(fyne.TextTruncateClip) {
17,590✔
917
                if trunc == fyne.TextTruncateOff {
18✔
918
                        trunc = fyne.TextTruncateClip
9✔
919
                }
9✔
920
                wrap = fyne.TextWrapOff
9✔
921
        }
922

923
        if max.Width < 0 || wrap == fyne.TextWrapOff && trunc == fyne.TextTruncateOff {
32,431✔
924
                return lines, 0 // don't bother returning a calculated height, our MinSize is going to cover it
14,850✔
925
        }
14,850✔
926

927
        measureWidth := float32(min(firstWidth, max.Width))
2,731✔
928
        text := []rune(seg.Text)
2,731✔
929
        widthChecker := func(low int, high int) bool {
5,893✔
930
                return measurer(text[low:high]).Width <= measureWidth
3,162✔
931
        }
3,162✔
932

933
        reuse := 0
2,731✔
934
        yPos := float32(0)
2,731✔
935
        var bounds []rowBoundary
2,731✔
936
        for _, l := range lines {
7,287✔
937
                low := l.begin
4,556✔
938
                high := l.end
4,556✔
939
                if low == high {
5,973✔
940
                        l.firstSegmentReuse = reuse
1,417✔
941
                        reuse++
1,417✔
942
                        bounds = append(bounds, l)
1,417✔
943
                        continue
1,417✔
944
                }
945

946
                switch wrap {
3,139✔
947
                case fyne.TextWrapBreak:
43✔
948
                        for low < high {
168✔
949
                                measured := measurer(text[low:high])
125✔
950
                                if yPos+measured.Height > max.Height && trunc != fyne.TextTruncateOff {
127✔
951
                                        return ellipsisPriorBound(bounds, trunc, measureWidth, measurer), yPos
2✔
952
                                }
2✔
953

954
                                if measured.Width <= measureWidth {
187✔
955
                                        bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, high, false})
64✔
956
                                        reuse++
64✔
957
                                        low = high
64✔
958
                                        high = l.end
64✔
959
                                        measureWidth = max.Width
64✔
960

64✔
961
                                        yPos += measured.Height
64✔
962
                                } else {
123✔
963
                                        newHigh := binarySearch(widthChecker, low, high)
59✔
964
                                        if newHigh <= low {
89✔
965
                                                bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, low + 1, false})
30✔
966
                                                reuse++
30✔
967
                                                low++
30✔
968

30✔
969
                                                yPos += measured.Height
30✔
970
                                        } else {
59✔
971
                                                high = newHigh
29✔
972
                                        }
29✔
973
                                }
974
                        }
975
                case fyne.TextWrapWord:
2,884✔
976
                        for low < high {
6,556✔
977
                                sub := text[low:high]
3,672✔
978
                                measured := measurer(sub)
3,672✔
979
                                if yPos+measured.Height > max.Height && trunc != fyne.TextTruncateOff {
3,673✔
980
                                        return ellipsisPriorBound(bounds, trunc, measureWidth, measurer), yPos
1✔
981
                                }
1✔
982

983
                                subWidth := measured.Width
3,671✔
984
                                if subWidth <= measureWidth {
6,935✔
985
                                        bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, high, false})
3,264✔
986
                                        reuse++
3,264✔
987
                                        low = high
3,264✔
988
                                        high = l.end
3,264✔
989
                                        if low < high && unicode.IsSpace(text[low]) {
3,622✔
990
                                                low++
358✔
991
                                        }
358✔
992
                                        measureWidth = max.Width
3,264✔
993

3,264✔
994
                                        yPos += measured.Height
3,264✔
995
                                } else {
407✔
996
                                        oldHigh := high
407✔
997
                                        last := low + len(sub) - 1
407✔
998
                                        fallback := binarySearch(widthChecker, low, last) - low
407✔
999

407✔
1000
                                        if fallback < 1 { // even a character won't fit
429✔
1001
                                                include := 1
22✔
1002
                                                ellipsis := false
22✔
1003
                                                if trunc == fyne.TextTruncateEllipsis {
22✔
1004
                                                        include = 0
×
1005
                                                        ellipsis = true
×
1006
                                                }
×
1007
                                                bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, low + include, ellipsis})
22✔
1008
                                                low++
22✔
1009
                                                high = low + 1
22✔
1010
                                                reuse++
22✔
1011

22✔
1012
                                                yPos += measured.Height
22✔
1013
                                                if high > l.end {
26✔
1014
                                                        return bounds, yPos
4✔
1015
                                                }
4✔
1016
                                        } else {
385✔
1017
                                                spaceIndex := findSpaceIndex(sub, fallback)
385✔
1018
                                                if spaceIndex == 0 {
385✔
1019
                                                        spaceIndex = 1
×
1020
                                                }
×
1021

1022
                                                high = low + spaceIndex
385✔
1023
                                        }
1024
                                        if high == fallback && subWidth <= max.Width { // add a newline as there is more space on next
403✔
1025
                                                bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, low, false})
×
1026
                                                reuse++
×
1027
                                                high = oldHigh
×
1028
                                                measureWidth = max.Width
×
1029

×
1030
                                                yPos += measured.Height
×
1031
                                                continue
×
1032
                                        }
1033
                                }
1034
                        }
1035
                default:
212✔
1036
                        if trunc == fyne.TextTruncateEllipsis {
388✔
1037
                                txt := []rune(seg.Text)[low:high]
176✔
1038
                                end, full := truncateLimit(string(txt), seg.Visual().(*canvas.Text), int(measureWidth), []rune{'…'})
176✔
1039
                                high = low + end
176✔
1040
                                bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, high, !full})
176✔
1041
                                reuse++
176✔
1042
                        } else if trunc == fyne.TextTruncateClip {
248✔
1043
                                high = binarySearch(widthChecker, low, high)
36✔
1044
                                bounds = append(bounds, rowBoundary{[]RichTextSegment{seg}, reuse, low, high, false})
36✔
1045
                                reuse++
36✔
1046
                        }
36✔
1047
                }
1048
        }
1049
        return bounds, yPos
2,724✔
1050
}
1051

1052
func setAlign(obj fyne.CanvasObject, align fyne.TextAlign) {
14✔
1053
        if text, ok := obj.(*canvas.Text); ok {
28✔
1054
                text.Alignment = align
14✔
1055
                return
14✔
1056
        }
14✔
1057
        if c, ok := obj.(*fyne.Container); ok {
×
1058
                wid := c.Objects[0]
×
1059
                if link, ok := wid.(*Hyperlink); ok {
×
1060
                        link.Alignment = align
×
1061
                        link.Refresh()
×
1062
                }
×
1063
        }
1064
}
1065

1066
// splitLines accepts a text segment and returns a slice of boundary metadata denoting the
1067
// start and end indices of each line delimited by the newline character.
1068
func splitLines(seg *TextSegment) []rowBoundary {
17,585✔
1069
        var low, high int
17,585✔
1070
        var lines []rowBoundary
17,585✔
1071
        text := []rune(seg.Text)
17,585✔
1072
        length := len(text)
17,585✔
1073
        for i := range length {
148,088✔
1074
                if text[i] == '\n' {
132,852✔
1075
                        high = i
2,349✔
1076
                        lines = append(lines, rowBoundary{[]RichTextSegment{seg}, len(lines), low, high, false})
2,349✔
1077
                        low = i + 1
2,349✔
1078
                }
2,349✔
1079
        }
1080
        return append(lines, rowBoundary{[]RichTextSegment{seg}, len(lines), low, length, false})
17,585✔
1081
}
1082

1083
func truncateLimit(s string, text *canvas.Text, limit int, ellipsis []rune) (int, bool) {
176✔
1084
        face := paint.CachedFontFace(text.TextStyle, text.FontSource, text)
176✔
1085

176✔
1086
        runes := []rune(s)
176✔
1087
        in := shaping.Input{
176✔
1088
                Text:      ellipsis,
176✔
1089
                RunStart:  0,
176✔
1090
                RunEnd:    len(ellipsis),
176✔
1091
                Direction: di.DirectionLTR,
176✔
1092
                Face:      face.Fonts.ResolveFace(ellipsis[0]),
176✔
1093
                Size:      float32ToFixed266(text.TextSize),
176✔
1094
        }
176✔
1095
        shaper := &shaping.HarfbuzzShaper{}
176✔
1096
        segmenter := &shaping.Segmenter{}
176✔
1097

176✔
1098
        conf := shaping.WrapConfig{}
176✔
1099
        conf = conf.WithTruncator(shaper, in)
176✔
1100
        conf.BreakPolicy = shaping.WhenNecessary
176✔
1101
        conf.TruncateAfterLines = 1
176✔
1102
        l := shaping.LineWrapper{}
176✔
1103

176✔
1104
        in.Text = runes
176✔
1105
        in.RunEnd = len(runes)
176✔
1106
        ins := segmenter.Split(in, face.Fonts)
176✔
1107
        outs := make([]shaping.Output, len(ins))
176✔
1108
        for i, in := range ins {
353✔
1109
                outs[i] = shaper.Shape(in)
177✔
1110
        }
177✔
1111

1112
        l.Prepare(conf, runes, shaping.NewSliceIterator(outs))
176✔
1113
        wrapped, done := l.WrapNextLine(limit)
176✔
1114

176✔
1115
        count := len(runes)
176✔
1116
        if wrapped.Truncated != 0 {
192✔
1117
                count -= wrapped.Truncated
16✔
1118
                count += len(ellipsis)
16✔
1119
        }
16✔
1120

1121
        full := done && count == len(runes)
176✔
1122
        if !full && len(ellipsis) > 0 {
192✔
1123
                count--
16✔
1124
        }
16✔
1125
        return count, full
176✔
1126
}
1127

1128
type rowBoundary struct {
1129
        segments          []RichTextSegment
1130
        firstSegmentReuse int
1131
        begin, end        int
1132
        ellipsis          bool
1133
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc