discordo/internal/markdown/renderer.go

201 lines
5.5 KiB
Go

package markdown
import (
"strconv"
"strings"
md "github.com/ayn2op/arikawa/v3/markdown"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/text"
"github.com/yuin/goldmark/v2/ast"
)
type Renderer struct {
cfg *config.Config
}
const codeBlockIndent = " "
func NewRenderer(cfg *config.Config) *Renderer {
return &Renderer{cfg: cfg}
}
// RenderText returns the spans of the markdown node. emoji returns the image of the custom emoji with an ID as a line of spans, or nil if it has none.
func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style, emoji func(id string) []text.Span) []text.Span {
builder := new(Builder)
styleStack := []tview.Style{base}
linkDepth := 0
listDepth := 0
currentStyle := func() tview.Style {
return styleStack[len(styleStack)-1]
}
pushStyle := func(style tview.Style) {
styleStack = append(styleStack, style)
}
popStyle := func() {
if len(styleStack) > 1 {
styleStack = styleStack[:len(styleStack)-1]
}
}
theme := r.cfg.Theme.MessagesList
_ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
switch node := node.(type) {
case *ast.Heading:
builder.NewLine()
case *ast.ListItem:
// A list that the item ends with already ended the line.
if _, ok := node.LastChild().(*ast.List); !ok {
builder.NewLine()
}
case *ast.Link:
linkDepth--
popStyle()
case *ast.List:
listDepth--
case *md.Inline:
if !r.shouldMask(node) {
popStyle()
}
}
return ast.WalkContinue, nil
}
switch node := node.(type) {
case *ast.Heading:
builder.Write(strings.Repeat("#", node.Level)+" ", currentStyle())
case *ast.Text:
builder.Write(string(node.Value.Bytes(source)), currentStyle())
switch {
case node.HardLineBreak():
builder.NewLine()
builder.NewLine()
case node.SoftLineBreak():
builder.NewLine()
}
case *ast.CodeBlock:
builder.NewLine()
r.renderCodeBlock(builder, source, node, currentStyle())
case *ast.AutoLink:
url := string(node.Destination.Bytes(source))
style := tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url)
builder.Write(url, style)
case *ast.Link:
url := string(node.Destination.Bytes(source))
linkDepth++
pushStyle(tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url))
case *ast.List:
builder.NewLine()
listDepth++
case *ast.ListItem:
builder.Write(strings.Repeat(" ", listDepth-1)+listMarker(node), currentStyle())
case *md.Inline:
if r.shouldMask(node) {
builder.Write("[spoiler]", currentStyle())
return ast.WalkSkipChildren, nil
}
pushStyle(applyInlineAttr(currentStyle(), node.Attr, linkDepth > 0))
case *md.Mention:
builder.Write(mentionText(node), tview.MergeStyle(currentStyle(), theme.MentionStyle.Style))
case *md.Emoji:
cfg := r.cfg.UI.MessagesList.Emojis
if image := emoji(node.ID); image != nil {
builder.WriteSpans(image...)
if !cfg.ShowEmojiName {
return ast.WalkContinue, nil
}
builder.Write(" ", currentStyle())
}
builder.Write(cfg.Name(node.Name), tview.MergeStyle(currentStyle(), theme.EmojiStyle.Style))
}
return ast.WalkContinue, nil
})
return builder.Finish()
}
func (r *Renderer) renderCodeBlock(builder *Builder, source []byte, node *ast.CodeBlock, base tview.Style) {
var code strings.Builder
for _, line := range node.Value.Segments() {
code.Write(line.Bytes(source))
}
language, _ := node.Language(source)
r.writeCodeBlock(builder, code.String(), strings.TrimSpace(language), base)
}
// writeCode writes s in style, starting each line after the first with the code block indent in base.
func writeCode(builder *Builder, s string, style, base tview.Style) {
for i, part := range strings.Split(s, "\n") {
if i > 0 {
builder.NewLine()
builder.Write(codeBlockIndent, base)
}
if part != "" {
builder.Write(part, style)
}
}
}
// shouldMask reports whether node is a spoiler shown as "[spoiler]" in place of what it hides.
func (r *Renderer) shouldMask(node *md.Inline) bool {
return r.cfg.Markdown.MaskSpoilers && node.Attr&md.AttrSpoiler != 0
}
// listMarker returns what goes before item: its number in an ordered list, counted from the list's start, or a dash.
func listMarker(item *ast.ListItem) string {
list := item.Parent().(*ast.List)
if !list.IsOrdered() {
return "- "
}
number := list.Start
for sibling := item.PreviousSibling(); sibling != nil; sibling = sibling.PreviousSibling() {
number++
}
return strconv.Itoa(number) + ". "
}
func mentionText(node *md.Mention) string {
switch {
case node.Channel != nil:
return "#" + node.Channel.Name
case node.GuildUser != nil:
name := node.GuildUser.DisplayOrUsername()
if member := node.GuildUser.Member; member != nil && member.Nick != "" {
name = member.Nick
}
return "@" + name
case node.GuildRole != nil:
return "@" + node.GuildRole.Name
default:
return ""
}
}
func applyInlineAttr(style tview.Style, attr md.Attribute, inLink bool) tview.Style {
if attr&md.AttrBold != 0 {
style = style.Bold(true)
}
if attr&md.AttrItalics != 0 {
style = style.Italic(true)
}
if attr&md.AttrUnderline != 0 {
style = style.Underline(true)
}
if attr&md.AttrStrikethrough != 0 {
style = style.StrikeThrough(true)
}
if attr&md.AttrMonospace != 0 {
// Avoid reverse-video inside links.
// Link labels like `hash` should still look like links, not highlighted blocks.
if !inLink {
style = style.Reverse(true)
}
}
return style
}