feat(ui/chat): add syntax highlighting in codeblocks (#745)
This commit is contained in:
parent
4c61e7c67a
commit
c7e2ccecce
6 changed files with 166 additions and 28 deletions
2
go.mod
2
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
go.sum
10
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=
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue