From c7e2ccecce7ad9bc97175de7dc9b7e35fc04d582 Mon Sep 17 00:00:00 2001 From: Ayyan Date: Thu, 12 Feb 2026 19:24:44 -0500 Subject: [PATCH] feat(ui/chat): add syntax highlighting in codeblocks (#745) --- go.mod | 2 + go.sum | 10 +++ internal/config/config.go | 14 +-- internal/config/config.toml | 8 +- internal/markdown/renderer.go | 139 ++++++++++++++++++++++++++---- internal/ui/chat/messages_list.go | 21 ++++- 6 files changed, 166 insertions(+), 28 deletions(-) diff --git a/go.mod b/go.mod index ed102af..f1befc2 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ go 1.25.3 require ( github.com/BurntSushi/toml v1.6.0 + github.com/alecthomas/chroma/v2 v2.23.1 github.com/andybalholm/brotli v1.2.0 github.com/ayn2op/tview v0.0.0-20260208071935-3324c648db69 github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb @@ -32,6 +33,7 @@ require ( github.com/akavel/rsrc v0.10.2 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dchest/jsmin v1.0.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/esiqveland/notify v0.13.3 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect diff --git a/go.sum b/go.sum index 864081e..22cbc06 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,12 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/ayn2op/tview v0.0.0-20260208071935-3324c648db69 h1:DU0vNRmldUajM4tY5feFG/WsxaLhiPUHuWaQotmCLH8= @@ -23,6 +29,8 @@ github.com/diamondburned/arikawa/v3 v3.6.1-0.20250928004212-a891a653eb26 h1:B6Kn github.com/diamondburned/arikawa/v3 v3.6.1-0.20250928004212-a891a653eb26/go.mod h1:thocAM2X8lRDHuEZR5vWYaT4w+tb/vOKa1qm+r0gs5A= github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d h1:wS6HWl86RFItw38sUSdXlulj0VarV4G+9Rlv8j+p3oQ= github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d/go.mod h1:9PWnJlArEASrZQI89KQiBJBYOEDcsDyfhFcxRTL8eO4= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= @@ -46,6 +54,8 @@ github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3PniJ91OxIH6e7Zg= diff --git a/internal/config/config.go b/internal/config/config.go index 28ce225..33fc526 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,21 +56,25 @@ type ( Height int `toml:"height"` } + MarkdownConfig struct { + Enabled bool `toml:"enabled"` + Theme string `toml:"theme"` + } + Config struct { AutoFocus bool `toml:"auto_focus"` Mouse bool `toml:"mouse"` Editor string `toml:"editor"` - Status discord.Status `toml:"status"` - - Markdown bool `toml:"markdown"` - HideBlockedUsers bool `toml:"hide_blocked_users"` - ShowAttachmentLinks bool `toml:"show_attachment_links"` + Status discord.Status `toml:"status"` + HideBlockedUsers bool `toml:"hide_blocked_users"` + ShowAttachmentLinks bool `toml:"show_attachment_links"` // Use 0 to disable AutocompleteLimit uint8 `toml:"autocomplete_limit"` MessagesLimit uint8 `toml:"messages_limit"` + Markdown MarkdownConfig `toml:"markdown"` Picker PickerConfig `toml:"picker"` Timestamps Timestamps `toml:"timestamps"` Notifications Notifications `toml:"notifications"` diff --git a/internal/config/config.toml b/internal/config/config.toml index 27e5745..1b91c86 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -10,8 +10,6 @@ editor = "default" # "default" (unknown), "online", "dnd", "idle", "invisible", "offline" status = "default" -# Whether to parse and render markdown in messages or not. -markdown = true hide_blocked_users = true show_attachment_links = true @@ -22,6 +20,12 @@ autocomplete_limit = 20 # The number of messages to fetch when a text-based channel is selected from guilds tree. The minimum and maximum value is 1 and 100, respectively. messages_limit = 50 +[markdown] +# Whether to parse and render markdown in messages or not. +enabled = true +# Theme for fenced code blocks. Available themes: https://xyproto.github.io/splash/docs +theme = "monokai" + [picker] width = 60 height = 20 diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 991a0d0..d30c920 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -4,6 +4,9 @@ import ( "strconv" "strings" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" @@ -13,14 +16,16 @@ import ( ) type Renderer struct { - theme config.MessagesListTheme + cfg *config.Config listIx *int listNested int } -func NewRenderer(theme config.MessagesListTheme) *Renderer { - return &Renderer{theme: theme} +const codeBlockIndent = " " + +func NewRenderer(cfg *config.Config) *Renderer { + return &Renderer{cfg: cfg} } func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) []tview.Line { @@ -42,6 +47,7 @@ func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) [ } } + theme := r.cfg.Theme.MessagesList _ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) { switch node := node.(type) { case *ast.Document: @@ -66,25 +72,16 @@ func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) [ case *ast.FencedCodeBlock: if entering { builder.NewLine() - if language := node.Language(source); language != nil { - builder.Write("|=> "+string(language), currentStyle()) - builder.NewLine() - } - - lines := node.Lines() - for i := range lines.Len() { - line := lines.At(i) - builder.Write("| "+string(line.Value(source)), currentStyle()) - } + r.renderFencedCodeBlock(builder, source, node, currentStyle()) } case *ast.AutoLink: if entering { - style := ui.MergeStyle(currentStyle(), r.theme.URLStyle.Style) + style := ui.MergeStyle(currentStyle(), theme.URLStyle.Style) builder.Write(string(node.URL(source)), style) } case *ast.Link: if entering { - pushStyle(ui.MergeStyle(currentStyle(), r.theme.URLStyle.Style)) + pushStyle(ui.MergeStyle(currentStyle(), theme.URLStyle.Style)) } else { popStyle() } @@ -122,13 +119,13 @@ func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) [ } case *discordmd.Mention: if entering { - style := ui.MergeStyle(currentStyle(), r.theme.MentionStyle.Style) + style := ui.MergeStyle(currentStyle(), theme.MentionStyle.Style) style = style.Bold(true) builder.Write(mentionText(node), style) } case *discordmd.Emoji: if entering { - style := ui.MergeStyle(currentStyle(), r.theme.EmojiStyle.Style) + style := ui.MergeStyle(currentStyle(), theme.EmojiStyle.Style) builder.Write(":"+node.Name+":", style) } } @@ -138,6 +135,114 @@ func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) [ return builder.Finish() } +func (r *Renderer) renderFencedCodeBlock(builder *tview.LineBuilder, source []byte, node *ast.FencedCodeBlock, base tcell.Style) { + var code strings.Builder + lines := node.Lines() + for i := range lines.Len() { + line := lines.At(i) + code.Write(line.Value(source)) + } + + language := strings.TrimSpace(string(node.Language(source))) + lexer := lexers.Get(language) + declaredLanguageSupported := lexer != nil + + // Detect the language from its content. + var analyzed bool + if lexer == nil { + lexer = lexers.Analyse(code.String()) + analyzed = lexer != nil + } + if lexer == nil { + lexer = lexers.Fallback + } + + // At this point, it should be noted that some lexers can be extremely chatty. + // To mitigate this, use the coalescing lexer to coalesce runs of identical token types into a single token. + lexer = chroma.Coalesce(lexer) + + // Show a fallback header when the language is omitted or unknown. + headerStyle := base.Dim(true) + if analyzed { + builder.Write(codeBlockIndent+"code: analyzed", headerStyle) + builder.NewLine() + } else if language == "" { + builder.Write(codeBlockIndent+"code", headerStyle) + builder.NewLine() + } else if !declaredLanguageSupported { + builder.Write(codeBlockIndent+"code: "+language, headerStyle) + builder.NewLine() + } + + iterator, err := lexer.Tokenise(nil, code.String()) + if err != nil { + for i := range lines.Len() { + line := lines.At(i) + builder.Write(codeBlockIndent+string(line.Value(source)), base) + } + return + } + + theme := styles.Get(r.cfg.Markdown.Theme) + if theme == nil { + theme = styles.Fallback + } + + builder.Write(codeBlockIndent, base) + for token := iterator(); token != chroma.EOF; token = iterator() { + style := applyChromaStyle(base, theme.Get(token.Type)) + // Chroma tokens may include embedded newlines, so split and re-emit with indentation on each visual line. + parts := strings.Split(token.Value, "\n") + for i, part := range parts { + if i > 0 { + builder.NewLine() + builder.Write(codeBlockIndent, base) + } + if part != "" { + builder.Write(part, style) + } + } + } +} + +func applyChromaStyle(base tcell.Style, entry chroma.StyleEntry) tcell.Style { + style := base + if entry.Colour.IsSet() { + style = style.Foreground(tcell.NewRGBColor( + int32(entry.Colour.Red()), + int32(entry.Colour.Green()), + int32(entry.Colour.Blue()), + )) + } + // Intentionally do not apply token background colors so code blocks keep the user's terminal/chat background. + // if entry.Background.IsSet() { + // style = style.Background(tcell.NewRGBColor( + // int32(entry.Background.Red()), + // int32(entry.Background.Green()), + // int32(entry.Background.Blue()), + // )) + // } + switch entry.Bold { + case chroma.Yes: + style = style.Bold(true) + case chroma.No: + style = style.Bold(false) + } + switch entry.Italic { + case chroma.Yes: + style = style.Italic(true) + case chroma.No: + style = style.Italic(false) + } + switch entry.Underline { + case chroma.Yes: + style = style.Underline(true) + case chroma.No: + style = style.Underline(false) + } + return style +} + func mentionText(node *discordmd.Mention) string { switch { case node.Channel != nil: diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 6605343..75fc47d 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -58,7 +58,7 @@ func newMessagesList(cfg *config.Config, chatView *View) *messagesList { List: tview.NewList(), cfg: cfg, chatView: chatView, - renderer: markdown.NewRenderer(cfg.Theme.MessagesList), + renderer: markdown.NewRenderer(cfg), itemByID: make(map[discord.MessageID]*tview.TextView), } @@ -227,12 +227,25 @@ func (ml *messagesList) drawAuthor(builder *tview.LineBuilder, message discord.M func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) { c := []byte(message.Content) - if ml.chatView.cfg.Markdown { + if ml.chatView.cfg.Markdown.Enabled { root := discordmd.ParseWithMessage(c, *ml.chatView.state.Cabinet, &message, false) lines := ml.renderer.RenderLines(c, root, baseStyle) if builder.HasCurrentLine() { - for len(lines) > 1 && len(lines[0]) == 0 { - lines = lines[1:] + startsWithCodeBlock := false + if first := root.FirstChild(); first != nil { + _, startsWithCodeBlock = first.(*ast.FencedCodeBlock) + } + + if startsWithCodeBlock { + // Keep code blocks visually separate from "timestamp + author". + builder.NewLine() + for len(lines) > 0 && len(lines[0]) == 0 { + lines = lines[1:] + } + } else { + for len(lines) > 1 && len(lines[0]) == 0 { + lines = lines[1:] + } } } builder.AppendLines(lines)