renderer.go 7.1 KB

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