renderer.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. // Package markdown converts a goldmark AST (parsed by discordmd) into styled
  2. // tview.Line slices for terminal rendering. It walks the AST node tree,
  3. // applying tcell styles for inline formatting (bold, italic, code, spoilers),
  4. // block elements (code blocks with chroma syntax highlighting, blockquotes,
  5. // lists), and Discord-specific nodes (mentions, emoji, links with OSC 8 URLs).
  6. package markdown
  7. import (
  8. "strconv"
  9. "strings"
  10. "github.com/alecthomas/chroma/v2"
  11. "github.com/alecthomas/chroma/v2/lexers"
  12. "github.com/alecthomas/chroma/v2/styles"
  13. "github.com/ayn2op/discordo/internal/config"
  14. "github.com/ayn2op/discordo/internal/ui"
  15. "github.com/ayn2op/tview"
  16. "github.com/diamondburned/ningen/v3/discordmd"
  17. "github.com/gdamore/tcell/v3"
  18. "github.com/yuin/goldmark/ast"
  19. )
  20. type Renderer struct {
  21. cfg *config.Config
  22. listIx *int
  23. listNested int
  24. }
  25. const codeBlockIndent = " "
  26. func NewRenderer(cfg *config.Config) *Renderer {
  27. return &Renderer{cfg: cfg}
  28. }
  29. func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) []tview.Line {
  30. r.listIx = nil
  31. r.listNested = 0
  32. builder := tview.NewLineBuilder()
  33. styleStack := []tcell.Style{base}
  34. linkDepth := 0
  35. currentStyle := func() tcell.Style {
  36. return styleStack[len(styleStack)-1]
  37. }
  38. pushStyle := func(style tcell.Style) {
  39. styleStack = append(styleStack, style)
  40. }
  41. popStyle := func() {
  42. if len(styleStack) > 1 {
  43. styleStack = styleStack[:len(styleStack)-1]
  44. }
  45. }
  46. theme := r.cfg.Theme.MessagesList
  47. _ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
  48. switch node := node.(type) {
  49. case *ast.Document:
  50. // noop
  51. case *ast.Heading:
  52. if entering {
  53. builder.Write(strings.Repeat("#", node.Level)+" ", currentStyle())
  54. } else {
  55. builder.NewLine()
  56. }
  57. case *ast.Text:
  58. if entering {
  59. builder.Write(string(node.Segment.Value(source)), currentStyle())
  60. switch {
  61. case node.HardLineBreak():
  62. builder.NewLine()
  63. builder.NewLine()
  64. case node.SoftLineBreak():
  65. builder.NewLine()
  66. }
  67. }
  68. case *ast.FencedCodeBlock:
  69. if entering {
  70. builder.NewLine()
  71. r.renderFencedCodeBlock(builder, source, node, currentStyle())
  72. }
  73. case *ast.AutoLink:
  74. if entering {
  75. url := string(node.URL(source))
  76. style := ui.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url)
  77. builder.Write(url, style)
  78. }
  79. case *ast.Link:
  80. if entering {
  81. url := string(node.Destination)
  82. linkDepth++
  83. pushStyle(ui.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url))
  84. } else {
  85. if linkDepth > 0 {
  86. linkDepth--
  87. }
  88. popStyle()
  89. }
  90. case *ast.List:
  91. if node.IsOrdered() {
  92. start := node.Start
  93. r.listIx = &start
  94. } else {
  95. r.listIx = nil
  96. }
  97. if entering {
  98. builder.NewLine()
  99. r.listNested++
  100. } else {
  101. r.listNested--
  102. }
  103. case *ast.ListItem:
  104. if entering {
  105. builder.Write(strings.Repeat(" ", r.listNested-1), currentStyle())
  106. if r.listIx != nil {
  107. builder.Write(strconv.Itoa(*r.listIx)+". ", currentStyle())
  108. *r.listIx++
  109. } else {
  110. builder.Write("- ", currentStyle())
  111. }
  112. } else {
  113. builder.NewLine()
  114. }
  115. case *discordmd.Inline:
  116. if entering {
  117. pushStyle(applyInlineAttr(currentStyle(), node.Attr, linkDepth > 0))
  118. } else {
  119. popStyle()
  120. }
  121. case *discordmd.Mention:
  122. if entering {
  123. builder.Write(mentionText(node), ui.MergeStyle(currentStyle(), theme.MentionStyle.Style))
  124. }
  125. case *discordmd.Emoji:
  126. if entering {
  127. builder.Write(":"+node.Name+":", ui.MergeStyle(currentStyle(), theme.EmojiStyle.Style))
  128. }
  129. }
  130. return ast.WalkContinue, nil
  131. })
  132. return builder.Finish()
  133. }
  134. func (r *Renderer) renderFencedCodeBlock(builder *tview.LineBuilder, source []byte, node *ast.FencedCodeBlock, base tcell.Style) {
  135. var code strings.Builder
  136. lines := node.Lines()
  137. for i := range lines.Len() {
  138. line := lines.At(i)
  139. code.Write(line.Value(source))
  140. }
  141. language := strings.TrimSpace(string(node.Language(source)))
  142. lexer := lexers.Get(language)
  143. declaredLanguageSupported := lexer != nil
  144. // Detect the language from its content.
  145. var analyzed bool
  146. if lexer == nil {
  147. lexer = lexers.Analyse(code.String())
  148. analyzed = lexer != nil
  149. }
  150. if lexer == nil {
  151. lexer = lexers.Fallback
  152. }
  153. // At this point, it should be noted that some lexers can be extremely chatty.
  154. // To mitigate this, use the coalescing lexer to coalesce runs of identical token types into a single token.
  155. lexer = chroma.Coalesce(lexer)
  156. // Show a fallback header when the language is omitted or unknown.
  157. headerStyle := base.Dim(true)
  158. if analyzed {
  159. builder.Write(codeBlockIndent+"code: analyzed", headerStyle)
  160. builder.NewLine()
  161. } else if language == "" {
  162. builder.Write(codeBlockIndent+"code", headerStyle)
  163. builder.NewLine()
  164. } else if !declaredLanguageSupported {
  165. builder.Write(codeBlockIndent+"code: "+language, headerStyle)
  166. builder.NewLine()
  167. }
  168. iterator, err := lexer.Tokenise(nil, code.String())
  169. if err != nil {
  170. for i := range lines.Len() {
  171. line := lines.At(i)
  172. builder.Write(codeBlockIndent+string(line.Value(source)), base)
  173. }
  174. return
  175. }
  176. theme := styles.Get(r.cfg.Markdown.Theme)
  177. if theme == nil {
  178. theme = styles.Fallback
  179. }
  180. builder.Write(codeBlockIndent, base)
  181. for token := iterator(); token != chroma.EOF; token = iterator() {
  182. style := applyChromaStyle(base, theme.Get(token.Type))
  183. // Chroma tokens may include embedded newlines, so split and re-emit with indentation on each visual line.
  184. parts := strings.Split(token.Value, "\n")
  185. for i, part := range parts {
  186. if i > 0 {
  187. builder.NewLine()
  188. builder.Write(codeBlockIndent, base)
  189. }
  190. if part != "" {
  191. builder.Write(part, style)
  192. }
  193. }
  194. }
  195. }
  196. func applyChromaStyle(base tcell.Style, entry chroma.StyleEntry) tcell.Style {
  197. style := base
  198. if entry.Colour.IsSet() {
  199. style = style.Foreground(tcell.NewRGBColor(
  200. int32(entry.Colour.Red()),
  201. int32(entry.Colour.Green()),
  202. int32(entry.Colour.Blue()),
  203. ))
  204. }
  205. // Intentionally do not apply token background colors so code blocks keep the user's terminal/chat background.
  206. // if entry.Background.IsSet() {
  207. // style = style.Background(tcell.NewRGBColor(
  208. // int32(entry.Background.Red()),
  209. // int32(entry.Background.Green()),
  210. // int32(entry.Background.Blue()),
  211. // ))
  212. // }
  213. switch entry.Bold {
  214. case chroma.Yes:
  215. style = style.Bold(true)
  216. case chroma.No:
  217. style = style.Bold(false)
  218. }
  219. switch entry.Italic {
  220. case chroma.Yes:
  221. style = style.Italic(true)
  222. case chroma.No:
  223. style = style.Italic(false)
  224. }
  225. switch entry.Underline {
  226. case chroma.Yes:
  227. style = style.Underline(true)
  228. case chroma.No:
  229. style = style.Underline(false)
  230. }
  231. return style
  232. }
  233. func mentionText(node *discordmd.Mention) string {
  234. switch {
  235. case node.Channel != nil:
  236. return "#" + node.Channel.Name
  237. case node.GuildUser != nil:
  238. name := node.GuildUser.DisplayOrUsername()
  239. if member := node.GuildUser.Member; member != nil && member.Nick != "" {
  240. name = member.Nick
  241. }
  242. return "@" + name
  243. case node.GuildRole != nil:
  244. return "@" + node.GuildRole.Name
  245. default:
  246. return ""
  247. }
  248. }
  249. func applyInlineAttr(style tcell.Style, attr discordmd.Attribute, inLink bool) tcell.Style {
  250. switch attr {
  251. case discordmd.AttrBold:
  252. return style.Bold(true)
  253. case discordmd.AttrItalics:
  254. return style.Italic(true)
  255. case discordmd.AttrUnderline:
  256. return style.Underline(true)
  257. case discordmd.AttrStrikethrough:
  258. return style.StrikeThrough(true)
  259. case discordmd.AttrMonospace:
  260. // Avoid reverse-video inside links. Link labels like `hash` should still
  261. // look like links, not highlighted blocks.
  262. if inLink {
  263. return style
  264. }
  265. return style.Reverse(true)
  266. }
  267. return style
  268. }