feat(ui/chat): add syntax highlighting in codeblocks (#745)

This commit is contained in:
Ayyan 2026-02-12 19:24:44 -05:00 • committed by GitHub
parent 4c61e7c67a
commit c7e2ccecce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 166 additions and 28 deletions

2
go.mod
View file

@ -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

10
go.sum
View file

@ -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=

View file

@ -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"`

View file

@ -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

View file

@ -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:

View file

@ -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)