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

fyne-io / fyne / 22071145412

16 Feb 2026 04:50PM UTC coverage: 61.083% (+0.02%) from 61.061%
22071145412

Pull #6115

github

MaxGyver83
Handle nested lists in Markdown renderer

- Keep track of list indentation level
- Indent sub-lists with 4 spaces per level
- Pull (sub) lists out of `ParagraphSegment`s
  -> Sub list becomes an additional list item (of the outer list)
Pull Request #6115: Handle nested lists in Markdown renderer

37 of 37 new or added lines in 2 files covered. (100.0%)

48 existing lines in 1 file now uncovered.

25899 of 42400 relevant lines covered (61.08%)

694.47 hits per line

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

63.4
/widget/richtext_objects.go
1
package widget
2

3
import (
4
        "image/color"
5
        "net/url"
6
        "strconv"
7
        "strings"
8

9
        "fyne.io/fyne/v2"
10
        "fyne.io/fyne/v2/canvas"
11
        "fyne.io/fyne/v2/internal/scale"
12
        "fyne.io/fyne/v2/theme"
13
)
14

15
var (
16
        // RichTextStyleBlockquote represents a quote presented in an indented block.
17
        //
18
        // Since: 2.1
19
        RichTextStyleBlockquote = RichTextStyle{
20
                ColorName: theme.ColorNameForeground,
21
                Inline:    false,
22
                SizeName:  theme.SizeNameText,
23
                TextStyle: fyne.TextStyle{Italic: true},
24
        }
25
        // RichTextStyleCodeBlock represents a code blog segment.
26
        //
27
        // Since: 2.1
28
        RichTextStyleCodeBlock = RichTextStyle{
29
                ColorName: theme.ColorNameForeground,
30
                Inline:    false,
31
                SizeName:  theme.SizeNameText,
32
                TextStyle: fyne.TextStyle{Monospace: true},
33
        }
34
        // RichTextStyleCodeInline represents an inline code segment.
35
        //
36
        // Since: 2.1
37
        RichTextStyleCodeInline = RichTextStyle{
38
                ColorName: theme.ColorNameForeground,
39
                Inline:    true,
40
                SizeName:  theme.SizeNameText,
41
                TextStyle: fyne.TextStyle{Monospace: true},
42
        }
43
        // RichTextStyleEmphasis represents regular text with emphasis.
44
        //
45
        // Since: 2.1
46
        RichTextStyleEmphasis = RichTextStyle{
47
                ColorName: theme.ColorNameForeground,
48
                Inline:    true,
49
                SizeName:  theme.SizeNameText,
50
                TextStyle: fyne.TextStyle{Italic: true},
51
        }
52
        // RichTextStyleHeading represents a heading text that stands on its own line.
53
        //
54
        // Since: 2.1
55
        RichTextStyleHeading = RichTextStyle{
56
                ColorName: theme.ColorNameForeground,
57
                Inline:    false,
58
                SizeName:  theme.SizeNameHeadingText,
59
                TextStyle: fyne.TextStyle{Bold: true},
60
        }
61
        // RichTextStyleInline represents standard text that can be surrounded by other elements.
62
        //
63
        // Since: 2.1
64
        RichTextStyleInline = RichTextStyle{
65
                ColorName: theme.ColorNameForeground,
66
                Inline:    true,
67
                SizeName:  theme.SizeNameText,
68
        }
69
        // RichTextStyleParagraph represents standard text that should appear separate from other text.
70
        //
71
        // Since: 2.1
72
        RichTextStyleParagraph = RichTextStyle{
73
                ColorName: theme.ColorNameForeground,
74
                Inline:    false,
75
                SizeName:  theme.SizeNameText,
76
        }
77
        // RichTextStylePassword represents standard sized text where the characters are obscured.
78
        //
79
        // Since: 2.1
80
        RichTextStylePassword = RichTextStyle{
81
                ColorName: theme.ColorNameForeground,
82
                Inline:    true,
83
                SizeName:  theme.SizeNameText,
84
                concealed: true,
85
        }
86
        // RichTextStyleStrong represents regular text with a strong emphasis.
87
        //
88
        // Since: 2.1
89
        RichTextStyleStrong = RichTextStyle{
90
                ColorName: theme.ColorNameForeground,
91
                Inline:    true,
92
                SizeName:  theme.SizeNameText,
93
                TextStyle: fyne.TextStyle{Bold: true},
94
        }
95
        // RichTextStyleSubHeading represents a sub-heading text that stands on its own line.
96
        //
97
        // Since: 2.1
98
        RichTextStyleSubHeading = RichTextStyle{
99
                ColorName: theme.ColorNameForeground,
100
                Inline:    false,
101
                SizeName:  theme.SizeNameSubHeadingText,
102
                TextStyle: fyne.TextStyle{Bold: true},
103
        }
104
)
105

106
// HyperlinkSegment represents a hyperlink within a rich text widget.
107
//
108
// Since: 2.1
109
type HyperlinkSegment struct {
110
        Alignment fyne.TextAlign
111
        Text      string
112
        URL       *url.URL
113

114
        // OnTapped overrides the default `fyne.OpenURL` call when the link is tapped
115
        //
116
        // Since: 2.4
117
        OnTapped func() `json:"-"`
118
}
119

120
// Inline returns true as hyperlinks are inside other elements.
121
func (h *HyperlinkSegment) Inline() bool {
11✔
122
        return true
11✔
123
}
11✔
124

125
// Textual returns the content of this segment rendered to plain text.
126
func (h *HyperlinkSegment) Textual() string {
3✔
127
        return h.Text
3✔
128
}
3✔
129

130
// Visual returns a new instance of a hyperlink widget required to render this segment.
131
func (h *HyperlinkSegment) Visual() fyne.CanvasObject {
2✔
132
        link := NewHyperlink(h.Text, h.URL)
2✔
133
        link.Alignment = h.Alignment
2✔
134
        link.OnTapped = h.OnTapped
2✔
135
        return &fyne.Container{Layout: &unpadTextWidgetLayout{parent: link}, Objects: []fyne.CanvasObject{link}}
2✔
136
}
2✔
137

138
// Update applies the current state of this hyperlink segment to an existing visual.
139
func (h *HyperlinkSegment) Update(o fyne.CanvasObject) {
3✔
140
        link := o.(*fyne.Container).Objects[0].(*Hyperlink)
3✔
141
        link.Text = h.Text
3✔
142
        link.URL = h.URL
3✔
143
        link.Alignment = h.Alignment
3✔
144
        link.OnTapped = h.OnTapped
3✔
145
        link.Refresh()
3✔
146
}
3✔
147

148
// Select tells the segment that the user is selecting the content between the two positions.
149
func (h *HyperlinkSegment) Select(begin, end fyne.Position) {
×
150
        // no-op: this will be added when we progress to editor
×
151
}
×
152

153
// SelectedText should return the text representation of any content currently selected through the Select call.
154
func (h *HyperlinkSegment) SelectedText() string {
×
155
        // no-op: this will be added when we progress to editor
×
156
        return ""
×
157
}
×
158

159
// Unselect tells the segment that the user is has cancelled the previous selection.
160
func (h *HyperlinkSegment) Unselect() {
×
161
        // no-op: this will be added when we progress to editor
×
162
}
×
163

164
// ImageSegment represents an image within a rich text widget.
165
//
166
// Since: 2.3
167
type ImageSegment struct {
168
        Source fyne.URI
169
        Title  string
170

171
        // Alignment specifies the horizontal alignment of this image segment
172
        // Since: 2.4
173
        Alignment fyne.TextAlign
174
}
175

176
// Inline returns false as images in rich text are blocks.
177
func (i *ImageSegment) Inline() bool {
9✔
178
        return false
9✔
179
}
9✔
180

181
// Textual returns the content of this segment rendered to plain text.
182
func (i *ImageSegment) Textual() string {
×
183
        return "Image " + i.Title
×
184
}
×
185

186
// Visual returns a new instance of an image widget required to render this segment.
187
func (i *ImageSegment) Visual() fyne.CanvasObject {
1✔
188
        return newRichImage(i.Source, i.Alignment)
1✔
189
}
1✔
190

191
// Update applies the current state of this image segment to an existing visual.
192
func (i *ImageSegment) Update(o fyne.CanvasObject) {
4✔
193
        newer := canvas.NewImageFromURI(i.Source)
4✔
194
        img := o.(*richImage)
4✔
195

4✔
196
        // one of the following will be used
4✔
197
        img.img.File = newer.File
4✔
198
        img.img.Resource = newer.Resource
4✔
199
        img.setAlign(i.Alignment)
4✔
200

4✔
201
        img.Refresh()
4✔
202
}
4✔
203

204
// Select tells the segment that the user is selecting the content between the two positions.
205
func (i *ImageSegment) Select(begin, end fyne.Position) {
×
206
        // no-op: this will be added when we progress to editor
×
207
}
×
208

209
// SelectedText should return the text representation of any content currently selected through the Select call.
210
func (i *ImageSegment) SelectedText() string {
×
211
        // no-op: images have no text rendering
×
212
        return ""
×
213
}
×
214

215
// Unselect tells the segment that the user is has cancelled the previous selection.
216
func (i *ImageSegment) Unselect() {
×
217
        // no-op: this will be added when we progress to editor
×
218
}
×
219

220
// ListSegment includes an itemised list with the content set using the Items field.
221
//
222
// Since: 2.1
223
type ListSegment struct {
224
        Items   []RichTextSegment
225
        Ordered bool
226

227
        // startIndex is the starting number - 1 (If it is ordered). Unordered lists
228
        // ignore startIndex.
229
        //
230
        // startIndex is set to start - 1 to allow the empty value of ListSegment to have a starting
231
        // number of 1, while also allowing the caller to override the starting
232
        // number to any int, including 0.
233
        startIndex       int
234
        indentationLevel int
235
}
236

237
// SetStartNumber sets the starting number for an ordered list.
238
// Unordered lists are not affected.
239
//
240
// Since: 2.7
241
func (l *ListSegment) SetStartNumber(s int) {
4✔
242
        l.startIndex = s - 1
4✔
243
}
4✔
244

245
// StartNumber return the starting number for an ordered list.
246
//
247
// Since: 2.7
248
func (l *ListSegment) StartNumber() int {
3✔
249
        return l.startIndex + 1
3✔
250
}
3✔
251

252
// Inline returns false as a list should be in a block.
253
func (l *ListSegment) Inline() bool {
1✔
254
        return false
1✔
255
}
1✔
256

257
// Segments returns the segments required to draw bullets before each item
258
func (l *ListSegment) Segments() []RichTextSegment {
9✔
259
        out := make([]RichTextSegment, len(l.Items))
9✔
260
        for i, in := range l.Items {
26✔
261
                txt := "• "
17✔
262
                if l.Ordered {
32✔
263
                        txt = strconv.Itoa(i+l.startIndex+1) + "."
15✔
264
                }
15✔
265
                var texts []RichTextSegment
17✔
266
                if _, ok := in.(*ListSegment); !ok {
33✔
267
                        indentation := strings.Repeat(" ", l.indentationLevel*4)
16✔
268
                        bullet := &TextSegment{Text: indentation + txt + " ", Style: RichTextStyleStrong}
16✔
269
                        texts = append(texts, bullet)
16✔
270
                }
16✔
271
                texts = append(texts, in)
17✔
272
                out[i] = &ParagraphSegment{Texts: texts}
17✔
273
        }
274
        return out
9✔
275
}
276

277
// Textual returns no content for a list as the content is in sub-segments.
UNCOV
278
func (l *ListSegment) Textual() string {
×
UNCOV
279
        return ""
×
280
}
×
281

282
// Visual returns no additional elements for this segment.
UNCOV
283
func (l *ListSegment) Visual() fyne.CanvasObject {
×
UNCOV
284
        return nil
×
285
}
×
286

287
// Update doesn't need to change a list visual.
UNCOV
288
func (l *ListSegment) Update(fyne.CanvasObject) {
×
UNCOV
289
}
×
290

291
// Select does nothing for a list container.
UNCOV
292
func (l *ListSegment) Select(_, _ fyne.Position) {
×
UNCOV
293
}
×
294

295
// SelectedText returns the empty string for this list.
UNCOV
296
func (l *ListSegment) SelectedText() string {
×
UNCOV
297
        return ""
×
298
}
×
299

300
// Unselect does nothing for a list container.
UNCOV
301
func (l *ListSegment) Unselect() {
×
UNCOV
302
}
×
303

304
// ParagraphSegment wraps a number of text elements in a paragraph.
305
// It is similar to using a list of text elements when the final style is RichTextStyleParagraph.
306
//
307
// Since: 2.1
308
type ParagraphSegment struct {
309
        Texts []RichTextSegment
310
}
311

312
// Inline returns false as a paragraph should be in a block.
313
func (p *ParagraphSegment) Inline() bool {
14✔
314
        return false
14✔
315
}
14✔
316

317
// Segments returns the list of text elements in this paragraph.
318
func (p *ParagraphSegment) Segments() []RichTextSegment {
23✔
319
        return p.Texts
23✔
320
}
23✔
321

322
// Textual returns no content for a paragraph container.
UNCOV
323
func (p *ParagraphSegment) Textual() string {
×
UNCOV
324
        return ""
×
325
}
×
326

327
// Visual returns the no extra elements.
UNCOV
328
func (p *ParagraphSegment) Visual() fyne.CanvasObject {
×
UNCOV
329
        return nil
×
330
}
×
331

332
// Update doesn't need to change a paragraph container.
UNCOV
333
func (p *ParagraphSegment) Update(fyne.CanvasObject) {
×
UNCOV
334
}
×
335

336
// Select does nothing for a paragraph container.
UNCOV
337
func (p *ParagraphSegment) Select(_, _ fyne.Position) {
×
UNCOV
338
}
×
339

340
// SelectedText returns the empty string for this paragraph container.
UNCOV
341
func (p *ParagraphSegment) SelectedText() string {
×
UNCOV
342
        return ""
×
343
}
×
344

345
// Unselect does nothing for a paragraph container.
UNCOV
346
func (p *ParagraphSegment) Unselect() {
×
UNCOV
347
}
×
348

349
// SeparatorSegment includes a horizontal separator in a rich text widget.
350
//
351
// Since: 2.1
352
type SeparatorSegment struct {
353
        _ bool // Without this a pointer to SeparatorSegment will always be the same.
354
}
355

356
// Inline returns false as a separator should be full width.
UNCOV
357
func (s *SeparatorSegment) Inline() bool {
×
UNCOV
358
        return false
×
359
}
×
360

361
// Textual returns no content for a separator element.
UNCOV
362
func (s *SeparatorSegment) Textual() string {
×
UNCOV
363
        return ""
×
364
}
×
365

366
// Visual returns a new instance of a separator widget for this segment.
UNCOV
367
func (s *SeparatorSegment) Visual() fyne.CanvasObject {
×
UNCOV
368
        return NewSeparator()
×
369
}
×
370

371
// Update doesn't need to change a separator visual.
UNCOV
372
func (s *SeparatorSegment) Update(fyne.CanvasObject) {
×
UNCOV
373
}
×
374

375
// Select does nothing for a separator.
UNCOV
376
func (s *SeparatorSegment) Select(_, _ fyne.Position) {
×
UNCOV
377
}
×
378

379
// SelectedText returns the empty string for this separator.
UNCOV
380
func (s *SeparatorSegment) SelectedText() string {
×
UNCOV
381
        return "" // TODO maybe return "---\n"?
×
382
}
×
383

384
// Unselect does nothing for a separator.
UNCOV
385
func (s *SeparatorSegment) Unselect() {
×
UNCOV
386
}
×
387

388
// RichTextStyle describes the details of a text object inside a RichText widget.
389
//
390
// Since: 2.1
391
type RichTextStyle struct {
392
        Alignment fyne.TextAlign
393
        ColorName fyne.ThemeColorName
394
        Inline    bool
395
        SizeName  fyne.ThemeSizeName // The theme name of the text size to use, if blank will be the standard text size
396
        TextStyle fyne.TextStyle
397

398
        // an internal detail where we obscure password fields
399
        concealed bool
400
}
401

402
// RichTextSegment describes any element that can be rendered in a RichText widget.
403
//
404
// Since: 2.1
405
type RichTextSegment interface {
406
        Inline() bool
407
        Textual() string
408
        Update(fyne.CanvasObject)
409
        Visual() fyne.CanvasObject
410

411
        Select(pos1, pos2 fyne.Position)
412
        SelectedText() string
413
        Unselect()
414
}
415

416
// TextSegment represents the styling for a segment of rich text.
417
//
418
// Since: 2.1
419
type TextSegment struct {
420
        Style RichTextStyle
421
        Text  string
422

423
        parent *RichText
424
}
425

426
// Inline should return true if this text can be included within other elements, or false if it creates a new block.
427
func (t *TextSegment) Inline() bool {
35,527✔
428
        return t.Style.Inline
35,527✔
429
}
35,527✔
430

431
// Textual returns the content of this segment rendered to plain text.
432
func (t *TextSegment) Textual() string {
4,490✔
433
        return t.Text
4,490✔
434
}
4,490✔
435

436
// Visual returns a new instance of a graphical element required to render this segment.
437
func (t *TextSegment) Visual() fyne.CanvasObject {
1,991✔
438
        obj := canvas.NewText(t.Text, t.color())
1,991✔
439

1,991✔
440
        t.Update(obj)
1,991✔
441
        return obj
1,991✔
442
}
1,991✔
443

444
// Update applies the current state of this text segment to an existing visual.
445
func (t *TextSegment) Update(o fyne.CanvasObject) {
8,687✔
446
        obj := o.(*canvas.Text)
8,687✔
447
        obj.Text = t.Text
8,687✔
448
        obj.Color = t.color()
8,687✔
449
        obj.Alignment = t.Style.Alignment
8,687✔
450
        obj.TextStyle = t.Style.TextStyle
8,687✔
451
        obj.TextSize = t.size()
8,687✔
452
        obj.Refresh()
8,687✔
453
}
8,687✔
454

455
// Select tells the segment that the user is selecting the content between the two positions.
UNCOV
456
func (t *TextSegment) Select(begin, end fyne.Position) {
×
UNCOV
457
        // no-op: this will be added when we progress to editor
×
458
}
×
459

460
// SelectedText should return the text representation of any content currently selected through the Select call.
UNCOV
461
func (t *TextSegment) SelectedText() string {
×
UNCOV
462
        // no-op: this will be added when we progress to editor
×
463
        return ""
×
464
}
×
465

466
// Unselect tells the segment that the user is has cancelled the previous selection.
UNCOV
467
func (t *TextSegment) Unselect() {
×
UNCOV
468
        // no-op: this will be added when we progress to editor
×
469
}
×
470

471
func (t *TextSegment) color() color.Color {
10,678✔
472
        if t.Style.ColorName != "" {
21,268✔
473
                return theme.ColorForWidget(t.Style.ColorName, t.parent)
10,590✔
474
        }
10,590✔
475

476
        return theme.ColorForWidget(theme.ColorNameForeground, t.parent)
88✔
477
}
478

479
func (t *TextSegment) size() float32 {
47,005✔
480
        if t.Style.SizeName != "" {
93,891✔
481
                i := theme.SizeForWidget(t.Style.SizeName, t.parent)
46,886✔
482
                return i
46,886✔
483
        }
46,886✔
484

485
        i := theme.SizeForWidget(theme.SizeNameText, t.parent)
119✔
486
        return i
119✔
487
}
488

489
type richImage struct {
490
        BaseWidget
491
        align  fyne.TextAlign
492
        img    *canvas.Image
493
        oldMin fyne.Size
494
        layout *fyne.Container
495
        min    fyne.Size
496
}
497

498
func newRichImage(u fyne.URI, align fyne.TextAlign) *richImage {
1✔
499
        img := canvas.NewImageFromURI(u)
1✔
500
        img.FillMode = canvas.ImageFillOriginal
1✔
501
        i := &richImage{img: img, align: align}
1✔
502
        i.ExtendBaseWidget(i)
1✔
503
        return i
1✔
504
}
1✔
505

506
func (r *richImage) CreateRenderer() fyne.WidgetRenderer {
1✔
507
        r.layout = &fyne.Container{Layout: &richImageLayout{r}, Objects: []fyne.CanvasObject{r.img}}
1✔
508
        return NewSimpleRenderer(r.layout)
1✔
509
}
1✔
510

511
func (r *richImage) MinSize() fyne.Size {
9✔
512
        orig := r.img.MinSize()
9✔
513
        c := fyne.CurrentApp().Driver().CanvasForObject(r)
9✔
514
        if c == nil {
9✔
UNCOV
515
                return r.oldMin // not yet rendered
×
UNCOV
516
        }
×
517

518
        // unscale the image so it is not varying based on canvas
519
        w := scale.ToScreenCoordinate(c, orig.Width)
9✔
520
        h := scale.ToScreenCoordinate(c, orig.Height)
9✔
521
        // we return size / 2 as this assumes a HiDPI / 2x image scaling
9✔
522
        r.min = fyne.NewSize(float32(w)/2, float32(h)/2)
9✔
523
        return r.min
9✔
524
}
525

526
func (r *richImage) setAlign(a fyne.TextAlign) {
4✔
527
        if r.layout != nil {
7✔
528
                r.layout.Refresh()
3✔
529
        }
3✔
530
        r.align = a
4✔
531
}
532

533
type richImageLayout struct {
534
        r *richImage
535
}
536

537
func (r *richImageLayout) Layout(_ []fyne.CanvasObject, s fyne.Size) {
9✔
538
        r.r.img.Resize(r.r.min)
9✔
539
        gap := float32(0)
9✔
540

9✔
541
        switch r.r.align {
9✔
542
        case fyne.TextAlignCenter:
2✔
543
                gap = (s.Width - r.r.min.Width) / 2
2✔
544
        case fyne.TextAlignTrailing:
1✔
545
                gap = s.Width - r.r.min.Width
1✔
546
        }
547

548
        r.r.img.Move(fyne.NewPos(gap, 0))
9✔
549
}
550

UNCOV
551
func (r *richImageLayout) MinSize(_ []fyne.CanvasObject) fyne.Size {
×
UNCOV
552
        return r.r.min
×
553
}
×
554

555
type unpadTextWidgetLayout struct {
556
        parent fyne.Widget
557
}
558

559
func (u *unpadTextWidgetLayout) Layout(o []fyne.CanvasObject, s fyne.Size) {
4✔
560
        innerPad := theme.SizeForWidget(theme.SizeNameInnerPadding, u.parent)
4✔
561
        pad := innerPad * -1
4✔
562
        pad2 := pad * -2
4✔
563

4✔
564
        o[0].Move(fyne.NewPos(pad, pad))
4✔
565
        o[0].Resize(s.Add(fyne.NewSize(pad2, pad2)))
4✔
566
}
4✔
567

568
func (u *unpadTextWidgetLayout) MinSize(o []fyne.CanvasObject) fyne.Size {
10✔
569
        innerPad := theme.SizeForWidget(theme.SizeNameInnerPadding, u.parent)
10✔
570
        pad := innerPad * 2
10✔
571
        return o[0].MinSize().Subtract(fyne.NewSize(pad, pad))
10✔
572
}
10✔
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

© 2026 Coveralls, Inc