diff --git a/go.mod b/go.mod index e3dc7b4..2baa658 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/alecthomas/chroma/v2 v2.24.1 github.com/andybalholm/brotli v1.2.1 github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97 - github.com/ayn2op/tview v0.0.0-20260505203159-135c30590e98 + github.com/ayn2op/tview v0.0.0-20260518023109-03ba9fc985b3 github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb github.com/diamondburned/arikawa/v3 v3.6.1-0.20260311205148-176ad9b9440f github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4 diff --git a/go.sum b/go.sum index 4e9980f..8e75d8c 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97 h1:WujETUV+v0DEJyZgjeLzQvihWyL80c0Tg4qf0dDo+Io= github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97/go.mod h1:3kFnpNCa3dF6WryzOMCDao7PfZ7DTCh+pievlfuwV80= -github.com/ayn2op/tview v0.0.0-20260505203159-135c30590e98 h1:C1b9nZC/O5v1pg46NAQJaudWA9xKk7FWGcylfxaDL7s= -github.com/ayn2op/tview v0.0.0-20260505203159-135c30590e98/go.mod h1:K4qMjKIPIYbOM7aP7dXFRdTrAtVbpWPPT8Q93+DBfKI= +github.com/ayn2op/tview v0.0.0-20260518023109-03ba9fc985b3 h1:C0TqOk1z+HozivyiXEvZsD1mpztI15thXFZzxHumTEo= +github.com/ayn2op/tview v0.0.0-20260518023109-03ba9fc985b3/go.mod h1:iOI0nEHAzgBFbED6yCJ5k8ID1Zd2R9QSXW6S2JE27Nk= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= diff --git a/internal/ui/chat/mentions_list.go b/internal/ui/chat/mentions_list.go index 788a916..ac31749 100644 --- a/internal/ui/chat/mentions_list.go +++ b/internal/ui/chat/mentions_list.go @@ -23,6 +23,7 @@ func newMentionsList(cfg *config.Config) *mentionsList { m := &mentionsList{ Model: list.NewModel(), } + m.SetSelectedStyle(tcell.StyleDefault.Reverse(true)) m.SetKeybinds(list.Keybinds{ SelectUp: cfg.Keybinds.MentionsList.Up.Keybind, SelectDown: cfg.Keybinds.MentionsList.Down.Keybind, @@ -50,16 +51,13 @@ func (m *mentionsList) clear() { } func (m *mentionsList) rebuild() { - m.SetBuilder(func(index int, cursor int) list.Item { + m.SetBuilder(func(index int) list.Item { if index < 0 || index >= len(m.items) { return nil } item := m.items[index] style := item.style - if index == cursor { - style = style.Reverse(true) - } line := tview.NewLine(tview.NewSegment(item.displayText, style)) return tview.NewTextView(). diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index f0ff7a2..79d0ce8 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -52,7 +52,7 @@ type messagesList struct { rowsDirty bool renderer *markdown.Renderer - // itemByID caches unselected message TextViews. + // itemByID caches rendered message TextViews. itemByID map[discord.MessageID]*tview.TextView attachmentsPicker *attachmentsPicker @@ -95,6 +95,7 @@ func newMessagesList(cfg *config.Config, chat *Model) *messagesList { ml.SetBuilder(ml.buildItem) ml.SetChangedFunc(ml.onRowCursorChanged) ml.SetTrackEnd(true) + ml.SetSelectedStyle(cfg.Theme.MessagesList.SelectedMessageStyle.Style) ml.SetKeybinds(list.Keybinds{ ScrollUp: cfg.Keybinds.MessagesList.ScrollUp.Keybind, ScrollDown: cfg.Keybinds.MessagesList.ScrollDown.Keybind, @@ -169,7 +170,7 @@ func (ml *messagesList) clearSelection() { ml.SetCursor(-1) } -func (ml *messagesList) buildItem(index int, cursor int) list.Item { +func (ml *messagesList) buildItem(index int) list.Item { ml.ensureRows() if index < 0 || index >= len(ml.rows) { @@ -181,14 +182,10 @@ func (ml *messagesList) buildItem(index int, cursor int) list.Item { return ml.buildSeparatorItem(row.timestamp) } + // The list applies the selection style at draw time, so a message is + // rendered once and reused for every cursor position. Cursor moves no + // longer re-parse markdown or re-run syntax highlighting. message := ml.messages[row.messageIndex] - if index == cursor { - return tview.NewTextView(). - SetWrap(true). - SetWordWrap(true). - SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style)) - } - item, ok := ml.itemByID[message.ID] if !ok { item = tview.NewTextView(). @@ -416,8 +413,12 @@ func (ml *messagesList) memberForMessage(message discord.Message) *discord.Membe return member } -func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) { - lines, root := ml.renderContentLines(message, baseStyle) +// drawContent renders the message body and returns the parsed markdown AST +// together with the source bytes it indexes into, so callers can reuse them +// instead of re-parsing the same content (see drawEmbeds). root is nil when +// markdown rendering is disabled. +func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) (ast.Node, []byte) { + lines, root, source := ml.renderContentLines(message, baseStyle) if ml.cfg.Markdown.Enabled && builder.HasCurrentLine() { startsWithCodeBlock := false if root != nil { @@ -439,23 +440,24 @@ func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord. } } builder.AppendLines(lines) + return root, source } -func (ml *messagesList) renderContentLines(message discord.Message, baseStyle tcell.Style) ([]tview.Line, ast.Node) { +func (ml *messagesList) renderContentLines(message discord.Message, baseStyle tcell.Style) ([]tview.Line, ast.Node, []byte) { return ml.renderContentLinesWithMarkdown(message, baseStyle, false) } -func (ml *messagesList) renderContentLinesWithMarkdown(message discord.Message, baseStyle tcell.Style, forceMarkdown bool) ([]tview.Line, ast.Node) { +func (ml *messagesList) renderContentLinesWithMarkdown(message discord.Message, baseStyle tcell.Style, forceMarkdown bool) ([]tview.Line, ast.Node, []byte) { // Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both. if forceMarkdown || ml.cfg.Markdown.Enabled { c := []byte(message.Content) root := discordmd.ParseWithMessage(c, *ml.chat.state.Cabinet, &message, false) - return ml.renderer.RenderLines(c, root, baseStyle), root + return ml.renderer.RenderLines(c, root, baseStyle), root, c } b := tview.NewLineBuilder() b.Write(message.Content, baseStyle) - return b.Finish(), nil + return b.Finish(), nil, nil } func (ml *messagesList) drawSnapshotContent(builder *tview.LineBuilder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tcell.Style) { @@ -484,14 +486,14 @@ func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message d } ml.drawAuthor(builder, message, baseStyle) - ml.drawContent(builder, message, baseStyle) + contentRoot, contentSource := ml.drawContent(builder, message, baseStyle) if message.EditedTimestamp.IsValid() { dimStyle := baseStyle.Dim(true) builder.Write(" (edited)", dimStyle) } - ml.drawEmbeds(builder, message, baseStyle) + ml.drawEmbeds(builder, message, baseStyle, contentRoot, contentSource) attachmentStyle := ui.MergeStyle(baseStyle, ml.cfg.Theme.MessagesList.AttachmentStyle.Style) for _, a := range message.Attachments { @@ -504,12 +506,20 @@ func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message d } } -func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) { +func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style, contentRoot ast.Node, contentSource []byte) { if len(message.Embeds) == 0 { return } - contentListURLs := extractURLs(message.Content) + // Embed URLs are deduplicated against links already shown in the message + // body. Reuse the body's parsed AST when markdown is enabled; only the + // markdown-disabled path (no AST) has to parse the content here. + var contentListURLs []string + if contentRoot != nil { + contentListURLs = urlsFromAST(contentRoot, contentSource) + } else { + contentListURLs = extractURLs(message.Content) + } contentURLs := make(map[string]struct{}, len(contentListURLs)) for _, u := range contentListURLs { contentURLs[u] = struct{}{} @@ -544,7 +554,7 @@ func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.M msg.Content = line.Text lineStyle := lineStyles[line.Kind] // Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled. - rendered, _ := ml.renderContentLinesWithMarkdown(msg, lineStyle, line.Kind == embedLineDescription) + rendered, _, _ := ml.renderContentLinesWithMarkdown(msg, lineStyle, line.Kind == embedLineDescription) for _, renderedLine := range rendered { if line.URL != "" { renderedLine = lineWithURL(renderedLine, line.URL) @@ -1096,7 +1106,13 @@ func extractURLs(content string) []string { parser.WithBlockParsers(discordmd.BlockParsers()...), parser.WithInlineParsers(discordmd.InlineParserWithLink()...), ).Parse(text.NewReader(src)) + return urlsFromAST(node, src) +} +// urlsFromAST collects link destinations from an already-parsed markdown AST. +// src must be the byte slice the node was parsed from (AutoLink resolves its +// URL against it). +func urlsFromAST(node ast.Node, src []byte) []string { var urls []string ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if entering {