adam-gui/vendor/fyne.io/fyne/v2/widget/markdown.go

223 lines
5.8 KiB
Go
Raw Normal View History

2024-04-29 19:13:50 +02:00
package widget
import (
"io"
"net/url"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/storage"
)
// NewRichTextFromMarkdown configures a RichText widget by parsing the provided markdown content.
//
// Since: 2.1
func NewRichTextFromMarkdown(content string) *RichText {
return NewRichText(parseMarkdown(content)...)
}
// ParseMarkdown allows setting the content of this RichText widget from a markdown string.
// It will replace the content of this widget similarly to SetText, but with the appropriate formatting.
func (t *RichText) ParseMarkdown(content string) {
t.Segments = parseMarkdown(content)
t.Refresh()
}
type markdownRenderer struct {
blockquote bool
heading bool
nextSeg RichTextSegment
parentStack [][]RichTextSegment
segs []RichTextSegment
}
func (m *markdownRenderer) AddOptions(...renderer.Option) {}
func (m *markdownRenderer) Render(_ io.Writer, source []byte, n ast.Node) error {
m.nextSeg = &TextSegment{}
err := ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
if n.Kind().String() == "Heading" {
m.segs = append(m.segs, m.nextSeg)
m.heading = false
}
return ast.WalkContinue, m.handleExitNode(n)
}
switch n.Kind().String() {
case "List":
// prepare a new child level
m.parentStack = append(m.parentStack, m.segs)
m.segs = nil
case "ListItem":
// prepare a new item level
m.parentStack = append(m.parentStack, m.segs)
m.segs = nil
case "Heading":
m.heading = true
switch n.(*ast.Heading).Level {
case 1:
m.nextSeg = &TextSegment{
Style: RichTextStyleHeading,
}
case 2:
m.nextSeg = &TextSegment{
Style: RichTextStyleSubHeading,
}
default:
m.nextSeg = &TextSegment{
Style: RichTextStyleParagraph,
}
m.nextSeg.(*TextSegment).Style.TextStyle.Bold = true
}
case "HorizontalRule", "ThematicBreak":
m.segs = append(m.segs, &SeparatorSegment{})
case "Link":
m.nextSeg = makeLink(n.(*ast.Link))
case "Paragraph":
m.nextSeg = &TextSegment{
Style: RichTextStyleInline, // we make it a paragraph at the end if there are no more elements
}
if m.blockquote {
m.nextSeg.(*TextSegment).Style = RichTextStyleBlockquote
}
case "CodeSpan":
m.nextSeg = &TextSegment{
Style: RichTextStyleCodeInline,
}
case "CodeBlock", "FencedCodeBlock":
var data []byte
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
data = append(data, line.Value(source)...)
}
if len(data) == 0 {
return ast.WalkContinue, nil
}
if data[len(data)-1] == '\n' {
data = data[:len(data)-1]
}
m.segs = append(m.segs, &TextSegment{
Style: RichTextStyleCodeBlock,
Text: string(data),
})
case "Emph", "Emphasis":
switch n.(*ast.Emphasis).Level {
case 2:
m.nextSeg = &TextSegment{
Style: RichTextStyleStrong,
}
default:
m.nextSeg = &TextSegment{
Style: RichTextStyleEmphasis,
}
}
case "Strong":
m.nextSeg = &TextSegment{
Style: RichTextStyleStrong,
}
case "Text":
ret := addTextToSegment(string(n.Text(source)), m.nextSeg, n)
if ret != 0 {
return ret, nil
}
_, isImage := m.nextSeg.(*ImageSegment)
if !m.heading && !isImage {
m.segs = append(m.segs, m.nextSeg)
}
case "Blockquote":
m.blockquote = true
case "Image":
m.nextSeg = makeImage(n.(*ast.Image)) // remember this for applying title
m.segs = append(m.segs, m.nextSeg)
}
return ast.WalkContinue, nil
})
return err
}
func (m *markdownRenderer) handleExitNode(n ast.Node) error {
if n.Kind().String() == "Blockquote" {
m.blockquote = false
} else if n.Kind().String() == "List" {
listSegs := m.segs
m.segs = m.parentStack[len(m.parentStack)-1]
m.parentStack = m.parentStack[:len(m.parentStack)-1]
marker := n.(*ast.List).Marker
m.segs = append(m.segs, &ListSegment{Items: listSegs, Ordered: marker != '*' && marker != '-' && marker != '+'})
} else if n.Kind().String() == "ListItem" {
itemSegs := m.segs
m.segs = m.parentStack[len(m.parentStack)-1]
m.parentStack = m.parentStack[:len(m.parentStack)-1]
m.segs = append(m.segs, &ParagraphSegment{Texts: itemSegs})
} else if !m.blockquote && !m.heading {
if len(m.segs) > 0 {
if text, ok := m.segs[len(m.segs)-1].(*TextSegment); ok && n.Kind().String() == "Paragraph" {
text.Style.Inline = false
}
}
m.nextSeg = &TextSegment{
Style: RichTextStyleInline,
}
}
return nil
}
func addTextToSegment(text string, s RichTextSegment, node ast.Node) ast.WalkStatus {
trimmed := strings.ReplaceAll(text, "\n", " ") // newline inside paragraph is not newline
if trimmed == "" {
return ast.WalkContinue
}
if t, ok := s.(*TextSegment); ok {
next := node.(*ast.Text).NextSibling()
if next != nil {
if nextText, ok := next.(*ast.Text); ok {
if nextText.Segment.Start > node.(*ast.Text).Segment.Stop { // detect presence of a trailing newline
trimmed = trimmed + " "
}
}
}
t.Text = t.Text + trimmed
}
if link, ok := s.(*HyperlinkSegment); ok {
link.Text = link.Text + trimmed
}
return 0
}
func makeImage(n *ast.Image) *ImageSegment {
dest := string(n.Destination)
u, err := storage.ParseURI(dest)
if err != nil {
u = storage.NewFileURI(dest)
}
return &ImageSegment{Source: u, Title: string(n.Title), Alignment: fyne.TextAlignCenter}
}
func makeLink(n *ast.Link) *HyperlinkSegment {
link, _ := url.Parse(string(n.Destination))
return &HyperlinkSegment{fyne.TextAlignLeading, "", link, nil}
}
func parseMarkdown(content string) []RichTextSegment {
r := &markdownRenderer{}
if content == "" {
return r.segs
}
md := goldmark.New(goldmark.WithRenderer(r))
err := md.Convert([]byte(content), nil)
if err != nil {
fyne.LogError("Failed to parse markdown", err)
}
return r.segs
}