Ver código fonte

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

Ayyan 2 meses atrás
pai
commit
c7e2ccecce

+ 2 - 0
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

+ 10 - 0
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=

+ 9 - 5
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"`

+ 6 - 2
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

+ 122 - 17
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:

+ 17 - 4
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)