Răsfoiți Sursa

refactor(ui): switch to segment-based api

ayn2op 2 luni în urmă
părinte
comite
d86b2f6e60

+ 1 - 1
go.mod

@@ -7,7 +7,7 @@ go 1.25.3
 require (
 	github.com/BurntSushi/toml v1.6.0
 	github.com/andybalholm/brotli v1.2.0
-	github.com/ayn2op/tview v0.0.0-20260206094054-e7bcc869f99d
+	github.com/ayn2op/tview v0.0.0-20260208060151-4a4f3e6ac892
 	github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb
 	github.com/diamondburned/arikawa/v3 v3.6.1-0.20250928004212-a891a653eb26
 	github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d

+ 2 - 2
go.sum

@@ -8,8 +8,8 @@ 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/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-20260206094054-e7bcc869f99d h1:tU85uJou6aDDeTOM+2bqnnqWHbh81eEvIMPKqmkCWpo=
-github.com/ayn2op/tview v0.0.0-20260206094054-e7bcc869f99d/go.mod h1:g6IOdF9SlnVZMDnRABANP8I0LFseKyYxWqEkzBSL5ho=
+github.com/ayn2op/tview v0.0.0-20260208060151-4a4f3e6ac892 h1:lQHJobFgud7g57eKr7ODWRqW0CWuuPwLtITSmVYbbhQ=
+github.com/ayn2op/tview v0.0.0-20260208060151-4a4f3e6ac892/go.mod h1:g6IOdF9SlnVZMDnRABANP8I0LFseKyYxWqEkzBSL5ho=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

+ 120 - 164
internal/markdown/renderer.go

@@ -1,16 +1,15 @@
-// Package markdown defines a renderer for tview style tags.
 package markdown
 
 import (
-	"fmt"
-	"io"
 	"strconv"
 	"strings"
 
 	"github.com/ayn2op/discordo/internal/config"
+	"github.com/ayn2op/discordo/internal/ui"
+	"github.com/ayn2op/tview"
 	"github.com/diamondburned/ningen/v3/discordmd"
+	"github.com/gdamore/tcell/v3"
 	"github.com/yuin/goldmark/ast"
-	gmr "github.com/yuin/goldmark/renderer"
 )
 
 type Renderer struct {
@@ -24,194 +23,151 @@ func NewRenderer(theme config.MessagesListTheme) *Renderer {
 	return &Renderer{theme: theme}
 }
 
-func (r *Renderer) AddOptions(opts ...gmr.Option) {}
+func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) []tview.Line {
+	r.listIx = nil
+	r.listNested = 0
 
-func (r *Renderer) Render(w io.Writer, source []byte, node ast.Node) error {
-	return ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
+	builder := tview.NewLineBuilder()
+	styleStack := []tcell.Style{base}
+
+	currentStyle := func() tcell.Style {
+		return styleStack[len(styleStack)-1]
+	}
+	pushStyle := func(style tcell.Style) {
+		styleStack = append(styleStack, style)
+	}
+	popStyle := func() {
+		if len(styleStack) > 1 {
+			styleStack = styleStack[:len(styleStack)-1]
+		}
+	}
+
+	_ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
 		switch node := node.(type) {
 		case *ast.Document:
-		// noop
+			// noop
 		case *ast.Heading:
-			r.renderHeading(w, node, entering)
+			if entering {
+				builder.Write(strings.Repeat("#", node.Level)+" ", currentStyle())
+			} else {
+				builder.NewLine()
+			}
 		case *ast.Text:
-			r.renderText(w, node, entering, source)
+			if entering {
+				builder.Write(string(node.Segment.Value(source)), currentStyle())
+				switch {
+				case node.HardLineBreak():
+					builder.NewLine()
+					builder.NewLine()
+				case node.SoftLineBreak():
+					builder.NewLine()
+				}
+			}
 		case *ast.FencedCodeBlock:
-			r.renderFencedCodeBlock(w, node, entering, source)
+			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())
+				}
+			}
 		case *ast.AutoLink:
-			r.renderAutoLink(w, node, entering, source)
+			if entering {
+				style := ui.MergeStyle(currentStyle(), r.theme.URLStyle.Style)
+				builder.Write(string(node.URL(source)), style)
+			}
 		case *ast.Link:
-			r.renderLink(w, node, entering)
+			if entering {
+				pushStyle(ui.MergeStyle(currentStyle(), r.theme.URLStyle.Style))
+			} else {
+				popStyle()
+			}
 		case *ast.List:
-			r.renderList(w, node, entering)
-		case *ast.ListItem:
-			r.renderListItem(w, entering)
+			if node.IsOrdered() {
+				start := node.Start
+				r.listIx = &start
+			} else {
+				r.listIx = nil
+			}
 
+			if entering {
+				builder.NewLine()
+				r.listNested++
+			} else {
+				r.listNested--
+			}
+		case *ast.ListItem:
+			if entering {
+				builder.Write(strings.Repeat("  ", r.listNested-1), currentStyle())
+				if r.listIx != nil {
+					builder.Write(strconv.Itoa(*r.listIx)+". ", currentStyle())
+					*r.listIx++
+				} else {
+					builder.Write("- ", currentStyle())
+				}
+			} else {
+				builder.NewLine()
+			}
 		case *discordmd.Inline:
-			r.renderInline(w, node, entering)
+			if entering {
+				pushStyle(applyInlineAttr(currentStyle(), node.Attr))
+			} else {
+				popStyle()
+			}
 		case *discordmd.Mention:
-			r.renderMention(w, node, entering)
+			if entering {
+				style := ui.MergeStyle(currentStyle(), r.theme.MentionStyle.Style)
+				style = style.Bold(true)
+				builder.Write(mentionText(node), style)
+			}
 		case *discordmd.Emoji:
-			r.renderEmoji(w, node, entering)
+			if entering {
+				style := ui.MergeStyle(currentStyle(), r.theme.EmojiStyle.Style)
+				builder.Write(":"+node.Name+":", style)
+			}
 		}
-
 		return ast.WalkContinue, nil
 	})
-}
-
-func (r *Renderer) renderHeading(w io.Writer, node *ast.Heading, entering bool) {
-	if entering {
-		io.WriteString(w, strings.Repeat("#", node.Level))
-		io.WriteString(w, " ")
-	} else {
-		io.WriteString(w, "\n")
-	}
-}
-
-func (r *Renderer) renderFencedCodeBlock(w io.Writer, node *ast.FencedCodeBlock, entering bool, source []byte) {
-	io.WriteString(w, "\n")
-
-	if entering {
-		// language
-		if l := node.Language(source); l != nil {
-			io.WriteString(w, "|=> ")
-			w.Write(l)
-			io.WriteString(w, "\n")
-		}
-
-		// body
-		lines := node.Lines()
-		for i := range lines.Len() {
-			line := lines.At(i)
-			io.WriteString(w, "| ")
-			w.Write(line.Value(source))
-		}
-	}
-}
-
-func (r *Renderer) renderAutoLink(w io.Writer, node *ast.AutoLink, entering bool, source []byte) {
-	urlStyle := r.theme.URLStyle
-
-	if entering {
-		fg := urlStyle.GetForeground()
-		bg := urlStyle.GetBackground()
-		fmt.Fprintf(w, "[%s:%s]", fg, bg)
-		w.Write(node.URL(source))
-	} else {
-		io.WriteString(w, "[-:-]")
-	}
-}
-
-func (r *Renderer) renderLink(w io.Writer, node *ast.Link, entering bool) {
-	urlStyle := r.theme.URLStyle
-	if entering {
-		fg := urlStyle.GetForeground()
-		bg := urlStyle.GetBackground()
-		fmt.Fprintf(w, "[%s:%s::%s]", fg, bg, node.Destination)
-	} else {
-		io.WriteString(w, "[-:-::-]")
-	}
-}
-
-func (r *Renderer) renderList(w io.Writer, node *ast.List, entering bool) {
-	if node.IsOrdered() {
-		r.listIx = &node.Start
-	} else {
-		r.listIx = nil
-	}
-
-	if entering {
-		io.WriteString(w, "\n")
-		r.listNested++
-	} else {
-		r.listNested--
-	}
-}
-
-func (r *Renderer) renderListItem(w io.Writer, entering bool) {
-	if entering {
-		io.WriteString(w, strings.Repeat("  ", r.listNested-1))
-
-		if r.listIx != nil {
-			io.WriteString(w, strconv.Itoa(*r.listIx))
-			io.WriteString(w, ". ")
-			*r.listIx++
-		} else {
-			io.WriteString(w, "- ")
-		}
-	} else {
-		io.WriteString(w, "\n")
-	}
-}
 
-func (r *Renderer) renderText(w io.Writer, node *ast.Text, entering bool, source []byte) {
-	if entering {
-		w.Write(node.Segment.Value(source))
-		switch {
-		case node.HardLineBreak():
-			io.WriteString(w, "\n\n")
-		case node.SoftLineBreak():
-			io.WriteString(w, "\n")
-		}
-	}
-}
-func (r *Renderer) renderInline(w io.Writer, node *discordmd.Inline, entering bool) {
-	if start, end := attrToTag(node.Attr); entering && start != "" {
-		io.WriteString(w, start)
-	} else {
-		io.WriteString(w, end)
-	}
+	return builder.Finish()
 }
 
-func (r *Renderer) renderMention(w io.Writer, node *discordmd.Mention, entering bool) {
-	mentionStyle := r.theme.MentionStyle
-	if entering {
-		fg := mentionStyle.GetForeground()
-		bg := mentionStyle.GetBackground()
-		fmt.Fprintf(w, "[%s:%s:b]", fg, bg)
-
-		switch {
-		case node.Channel != nil:
-			io.WriteString(w, "#"+node.Channel.Name)
-		case node.GuildUser != nil:
-			name := node.GuildUser.DisplayOrUsername()
-			if member := node.GuildUser.Member; member != nil && member.Nick != "" {
-				name = member.Nick
-			}
-
-			io.WriteString(w, "@"+name)
-		case node.GuildRole != nil:
-			io.WriteString(w, "@"+node.GuildRole.Name)
+func mentionText(node *discordmd.Mention) string {
+	switch {
+	case node.Channel != nil:
+		return "#" + node.Channel.Name
+	case node.GuildUser != nil:
+		name := node.GuildUser.DisplayOrUsername()
+		if member := node.GuildUser.Member; member != nil && member.Nick != "" {
+			name = member.Nick
 		}
-	} else {
-		io.WriteString(w, "[-:-:B]")
-	}
-}
-
-func (r *Renderer) renderEmoji(w io.Writer, node *discordmd.Emoji, entering bool) {
-	if entering {
-		emojiStyle := r.theme.EmojiStyle
-		fg := emojiStyle.GetForeground()
-		bg := emojiStyle.GetBackground()
-		fmt.Fprintf(w, "[%s:%s]", fg, bg)
-		io.WriteString(w, ":"+node.Name+":")
-	} else {
-		io.WriteString(w, "[-:-]")
+		return "@" + name
+	case node.GuildRole != nil:
+		return "@" + node.GuildRole.Name
+	default:
+		return ""
 	}
 }
 
-func attrToTag(attr discordmd.Attribute) (string, string) {
+func applyInlineAttr(style tcell.Style, attr discordmd.Attribute) tcell.Style {
 	switch attr {
 	case discordmd.AttrBold:
-		return "[::b]", "[::B]"
+		return style.Bold(true)
 	case discordmd.AttrItalics:
-		return "[::i]", "[::I]"
+		return style.Italic(true)
 	case discordmd.AttrUnderline:
-		return "[::u]", "[::U]"
+		// tcell v3 in this project does not expose underline attrs.
+		return style
 	case discordmd.AttrStrikethrough:
-		return "[::s]", "[::S]"
+		return style.StrikeThrough(true)
 	case discordmd.AttrMonospace:
-		return "[::r]", "[::R]"
-	default:
-		return "", ""
+		return style.Reverse(true)
 	}
+	return style
 }

+ 5 - 1
internal/ui/chat/guilds_tree.go

@@ -62,10 +62,14 @@ func (gt *guildsTree) resetNodeIndex() {
 func (gt *guildsTree) createFolderNode(folder gateway.GuildFolder) {
 	name := "Folder"
 	if folder.Name != "" {
-		name = fmt.Sprintf("[%s]%s[-]", folder.Color, folder.Name)
+		name = folder.Name
 	}
 
 	folderNode := tview.NewTreeNode(name).SetExpanded(gt.cfg.Theme.GuildsTree.AutoExpandFolders)
+	if folder.Color != 0 {
+		folderStyle := tcell.StyleDefault.Foreground(tcell.NewHexColor(int32(folder.Color)))
+		folderNode.SetTextStyle(folderStyle)
+	}
 	gt.GetRoot().AddChild(folderNode)
 
 	for _, gID := range folder.GuildIDs {

+ 90 - 0
internal/ui/chat/mentions_list.go

@@ -0,0 +1,90 @@
+package chat
+
+import (
+	"github.com/ayn2op/discordo/internal/config"
+	"github.com/ayn2op/discordo/internal/ui"
+	"github.com/ayn2op/tview"
+	"github.com/gdamore/tcell/v3"
+)
+
+type mentionsListItem struct {
+	insertText  string
+	displayText string
+	style       tcell.Style
+}
+
+type mentionsList struct {
+	*tview.List
+	items []mentionsListItem
+}
+
+func newMentionsList(cfg *config.Config) *mentionsList {
+	m := &mentionsList{
+		List: tview.NewList(),
+	}
+
+	m.Box = ui.ConfigureBox(m.Box, &cfg.Theme)
+	m.SetSnapToItems(true).SetTitle("Mentions")
+
+	b := m.GetBorderSet()
+	b.BottomLeft, b.BottomRight = b.BottomT, b.BottomT
+	m.SetBorderSet(b)
+
+	return m
+}
+
+func (m *mentionsList) append(item mentionsListItem) {
+	m.items = append(m.items, item)
+}
+
+func (m *mentionsList) clear() {
+	m.items = nil
+	m.List.Clear()
+}
+
+func (m *mentionsList) rebuild() {
+	m.SetBuilder(func(index int, cursor int) tview.ListItem {
+		if index < 0 || index >= len(m.items) {
+			return nil
+		}
+
+		item := m.items[index]
+		style := item.style
+		if index == cursor {
+			style = style.Foreground(tview.Styles.PrimitiveBackgroundColor).Background(tview.Styles.PrimaryTextColor)
+		}
+
+		return tview.NewTextView().
+			SetScrollable(false).
+			SetWrap(false).
+			SetWordWrap(false).
+			SetTextStyle(style).
+			SetLines([]tview.Line{{{Text: item.displayText, Style: style}}})
+	})
+
+	if len(m.items) == 0 {
+		m.SetCursor(-1)
+		return
+	}
+	m.SetCursor(0)
+}
+
+func (m *mentionsList) itemCount() int {
+	return len(m.items)
+}
+
+func (m *mentionsList) selectedInsertText() (string, bool) {
+	index := m.Cursor()
+	if index < 0 || index >= len(m.items) {
+		return "", false
+	}
+	return m.items[index].insertText, true
+}
+
+func (m *mentionsList) maxDisplayWidth() int {
+	width := 0
+	for _, item := range m.items {
+		width = max(width, tview.TaggedStringWidth(item.displayText))
+	}
+	return width
+}

+ 33 - 31
internal/ui/chat/message_input.go

@@ -2,7 +2,6 @@ package chat
 
 import (
 	"bytes"
-	"fmt"
 	"github.com/ayn2op/tview/layers"
 	"io"
 	"log/slog"
@@ -47,7 +46,7 @@ type messageInput struct {
 	edit            bool
 	sendMessageData *api.SendMessageData
 	cache           *cache.Cache
-	mentionsList    *tview.List
+	mentionsList    *mentionsList
 	lastSearch      time.Time
 
 	typingTimerMu sync.Mutex
@@ -61,7 +60,7 @@ func newMessageInput(cfg *config.Config, chatView *View) *messageInput {
 		chatView:        chatView,
 		sendMessageData: &api.SendMessageData{},
 		cache:           cache.NewCache(),
-		mentionsList:    tview.NewList(),
+		mentionsList:    newMentionsList(cfg),
 	}
 	mi.Box = ui.ConfigureBox(mi.Box, &cfg.Theme)
 	mi.SetInputCapture(mi.onInputCapture)
@@ -74,15 +73,6 @@ func newMessageInput(cfg *config.Config, chatView *View) *messageInput {
 		).
 		SetDisabled(true)
 
-	mi.mentionsList.Box = ui.ConfigureBox(mi.mentionsList.Box, &mi.cfg.Theme)
-	mi.mentionsList.
-		ShowSecondaryText(false).
-		SetTitle("Mentions")
-
-	b := mi.mentionsList.GetBorderSet()
-	b.BottomLeft, b.BottomRight = b.BottomT, b.BottomT
-	mi.mentionsList.SetBorderSet(b)
-
 	return mi
 }
 
@@ -108,7 +98,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 		return tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)
 
 	case mi.cfg.Keybinds.MessageInput.Send:
-		if mi.chatView.GetVisibile(mentionsListLayerName) {
+		if mi.chatView.GetVisible(mentionsListLayerName) {
 			mi.tabComplete()
 			return nil
 		}
@@ -124,7 +114,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 		mi.openFilePicker()
 		return nil
 	case mi.cfg.Keybinds.MessageInput.Cancel:
-		if mi.chatView.GetVisibile(mentionsListLayerName) {
+		if mi.chatView.GetVisible(mentionsListLayerName) {
 			mi.stopTabCompletion()
 		} else {
 			mi.reset()
@@ -149,7 +139,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	}
 
 	if mi.cfg.AutocompleteLimit > 0 {
-		if mi.chatView.GetVisibile(mentionsListLayerName) {
+		if mi.chatView.GetVisible(mentionsListLayerName) {
 			handler := mi.mentionsList.InputHandler()
 			switch event.Name() {
 			case mi.cfg.Keybinds.MentionsList.Up:
@@ -344,10 +334,13 @@ func (mi *messageInput) tabComplete() {
 		}
 		return
 	}
-	if mi.mentionsList.GetItemCount() == 0 {
+	if mi.mentionsList.itemCount() == 0 {
+		return
+	}
+	name, ok := mi.mentionsList.selectedInsertText()
+	if !ok {
 		return
 	}
-	_, name = mi.mentionsList.GetItemText(mi.mentionsList.GetCurrentItem())
 	mi.Replace(pos, posEnd, "@"+name+" ")
 	mi.stopTabCompletion()
 }
@@ -366,7 +359,7 @@ func (mi *messageInput) tabSuggestion() {
 	}
 	gID := selected.GuildID
 	cID := selected.ID
-	mi.mentionsList.Clear()
+	mi.mentionsList.clear()
 
 	var shown map[string]struct{}
 	var userDone struct{}
@@ -436,11 +429,12 @@ func (mi *messageInput) tabSuggestion() {
 		}
 	}
 
-	if mi.mentionsList.GetItemCount() == 0 {
+	if mi.mentionsList.itemCount() == 0 {
 		mi.stopTabCompletion()
 		return
 	}
 
+	mi.mentionsList.rebuild()
 	mi.showMentionList()
 }
 
@@ -513,17 +507,14 @@ func (mi *messageInput) showMentionList() {
 	if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 {
 		maxH = min(maxH, t)
 	}
-	count := l.GetItemCount() + borders
+	count := mi.mentionsList.itemCount() + borders
 	h := min(count, maxH) + borders + mi.cfg.Theme.Border.Padding[1]
 	y -= h
 	w := int(mi.cfg.Theme.MentionsList.MinWidth)
 	if w == 0 {
 		w = maxW
 	} else {
-		for i := range count - 1 {
-			t, _ := mi.mentionsList.GetItemText(i)
-			w = max(w, tview.TaggedStringWidth(t))
-		}
+		w = max(w, mi.mentionsList.maxDisplayWidth())
 
 		w = min(w+borders*2, maxW)
 		_, col, _, _ := mi.GetCursor()
@@ -552,24 +543,30 @@ func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member)
 		name = m.Nick
 	}
 
+	style := tcell.StyleDefault
+
 	// This avoids a slower member color lookup path.
 	color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role {
 		r, _ := mi.chatView.state.Cabinet.Role(gID, id)
 		return r
 	})
 	if ok {
-		name = fmt.Sprintf("[%s]%s[-]", color, name)
+		style = style.Foreground(tcell.NewHexColor(int32(color)))
 	}
 
 	presence, err := mi.chatView.state.Cabinet.Presence(gID, m.User.ID)
 	if err != nil {
 		slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err)
 	} else if presence.Status == discord.OfflineStatus {
-		name = fmt.Sprintf("[::d]%s[::D]", name)
+		style = style.Dim(true)
 	}
 
-	mi.mentionsList.AddItem(name, m.User.Username, 0, nil)
-	return mi.mentionsList.GetItemCount() > int(mi.cfg.AutocompleteLimit)
+	mi.mentionsList.append(mentionsListItem{
+		insertText:  name,
+		displayText: name,
+		style:       style,
+	})
+	return mi.mentionsList.itemCount() > int(mi.cfg.AutocompleteLimit)
 }
 
 func (mi *messageInput) addMentionUser(user *discord.User) {
@@ -578,14 +575,19 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
 	}
 
 	name := user.DisplayOrUsername()
+	style := tcell.StyleDefault
 	presence, err := mi.chatView.state.Cabinet.Presence(discord.NullGuildID, user.ID)
 	if err != nil {
 		slog.Info("failed to get presence from state", "user_id", user.ID, "err", err)
 	} else if presence.Status == discord.OfflineStatus {
-		name = fmt.Sprintf("[::d]%s[::D]", name)
+		style = style.Dim(true)
 	}
 
-	mi.mentionsList.AddItem(name, user.Username, 0, nil)
+	mi.mentionsList.append(mentionsListItem{
+		insertText:  name,
+		displayText: name,
+		style:       style,
+	})
 }
 
 // used by chatView
@@ -596,7 +598,7 @@ func (mi *messageInput) removeMentionsList() {
 
 func (mi *messageInput) stopTabCompletion() {
 	if mi.cfg.AutocompleteLimit > 0 {
-		mi.mentionsList.Clear()
+		mi.mentionsList.clear()
 		mi.removeMentionsList()
 		mi.chatView.app.SetFocus(mi)
 	}

+ 181 - 114
internal/ui/chat/messages_list.go

@@ -3,7 +3,6 @@ package chat
 import (
 	"context"
 	"errors"
-	"fmt"
 	"github.com/ayn2op/tview/layers"
 	"io"
 	"log/slog"
@@ -35,15 +34,13 @@ import (
 )
 
 type messagesList struct {
-	*tview.ScrollList
+	*tview.List
 	cfg      *config.Config
 	chatView *View
 	messages []discord.Message
 
 	renderer *markdown.Renderer
-	// itemByID caches fully built message TextViews. ScrollList may ask the
-	// builder repeatedly during drawing, so we avoid reparsing markdown and
-	// reconstructing TextViews unless a message actually changed.
+	// itemByID caches unselected message TextViews.
 	itemByID map[discord.MessageID]*tview.TextView
 
 	fetchingMembers struct {
@@ -56,11 +53,11 @@ type messagesList struct {
 
 func newMessagesList(cfg *config.Config, chatView *View) *messagesList {
 	ml := &messagesList{
-		ScrollList: tview.NewScrollList(),
-		cfg:        cfg,
-		chatView:   chatView,
-		renderer:   markdown.NewRenderer(cfg.Theme.MessagesList),
-		itemByID:   make(map[discord.MessageID]*tview.TextView),
+		List:     tview.NewList(),
+		cfg:      cfg,
+		chatView: chatView,
+		renderer: markdown.NewRenderer(cfg.Theme.MessagesList),
+		itemByID: make(map[discord.MessageID]*tview.TextView),
 	}
 
 	ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme)
@@ -129,66 +126,63 @@ func (ml *messagesList) clearSelection() {
 	ml.SetCursor(-1)
 }
 
-func (ml *messagesList) buildItem(index int, cursor int) tview.ScrollListItem {
+func (ml *messagesList) buildItem(index int, cursor int) tview.ListItem {
 	if index < 0 || index >= len(ml.messages) {
 		return nil
 	}
 
 	message := ml.messages[index]
-	tv, ok := ml.itemByID[message.ID]
-	if !ok {
-		tv = tview.NewTextView().
+	if index == cursor {
+		return tview.NewTextView().
 			SetWrap(true).
 			SetWordWrap(true).
-			SetDynamicColors(true).
-			SetText(ml.renderMessage(message))
-		ml.itemByID[message.ID] = tv
+			SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style))
 	}
-	// Selection state is visual only; we mutate style on the cached view.
-	if index == cursor {
-		tv.SetTextStyle(ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style)
-	} else {
-		tv.SetTextStyle(ml.cfg.Theme.MessagesList.MessageStyle.Style)
+
+	item, ok := ml.itemByID[message.ID]
+	if !ok {
+		item = tview.NewTextView().
+			SetWrap(true).
+			SetWordWrap(true).
+			SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style))
+		ml.itemByID[message.ID] = item
 	}
-	return tv
+	return item
 }
 
-func (ml *messagesList) renderMessage(message discord.Message) string {
-	var b strings.Builder
-	ml.writeMessage(&b, message)
-	return b.String()
+func (ml *messagesList) renderMessage(message discord.Message, baseStyle tcell.Style) []tview.Line {
+	builder := tview.NewLineBuilder()
+	ml.writeMessage(builder, message, baseStyle)
+	return builder.Finish()
 }
 
-func (ml *messagesList) writeMessage(writer io.Writer, message discord.Message) {
+func (ml *messagesList) writeMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
 	if ml.cfg.HideBlockedUsers {
 		isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID)
 		if isBlocked {
-			io.WriteString(writer, "[:red:b]Blocked message[:-:-]")
+			builder.Write("Blocked message", baseStyle.Foreground(tcell.ColorRed).Bold(true))
 			return
 		}
 	}
 
-	// reset
-	io.WriteString(writer, "[-:-:-]")
-
 	switch message.Type {
 	case discord.DefaultMessage:
 		if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
-			ml.drawForwardedMessage(writer, message)
+			ml.drawForwardedMessage(builder, message, baseStyle)
 		} else {
-			ml.drawDefaultMessage(writer, message)
+			ml.drawDefaultMessage(builder, message, baseStyle)
 		}
 	case discord.GuildMemberJoinMessage:
-		ml.drawTimestamps(writer, message.Timestamp)
-		ml.drawAuthor(writer, message)
-		io.WriteString(writer, "joined the server.")
+		ml.drawTimestamps(builder, message.Timestamp, baseStyle)
+		ml.drawAuthor(builder, message, baseStyle)
+		builder.Write("joined the server.", baseStyle)
 	case discord.InlinedReplyMessage:
-		ml.drawReplyMessage(writer, message)
+		ml.drawReplyMessage(builder, message, baseStyle)
 	case discord.ChannelPinnedMessage:
-		ml.drawPinnedMessage(writer, message)
+		ml.drawPinnedMessage(builder, message, baseStyle)
 	default:
-		ml.drawTimestamps(writer, message.Timestamp)
-		ml.drawAuthor(writer, message)
+		ml.drawTimestamps(builder, message.Timestamp, baseStyle)
+		ml.drawAuthor(builder, message, baseStyle)
 	}
 }
 
@@ -196,13 +190,12 @@ func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string {
 	return ts.Time().In(time.Local).Format(ml.cfg.Timestamps.Format)
 }
 
-func (ml *messagesList) drawTimestamps(w io.Writer, ts discord.Timestamp) {
-	io.WriteString(w, "[::d]")
-	io.WriteString(w, ml.formatTimestamp(ts))
-	io.WriteString(w, "[::D] ")
+func (ml *messagesList) drawTimestamps(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) {
+	dimStyle := baseStyle.Dim(true)
+	builder.Write(ml.formatTimestamp(ts)+" ", dimStyle)
 }
 
-func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) {
+func (ml *messagesList) drawAuthor(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
 	name := message.Author.DisplayOrUsername()
 	foreground := tcell.ColorDefault
 
@@ -226,80 +219,86 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) {
 		}
 	}
 
-	fmt.Fprintf(w, "[%s::b]%s[-::B] ", foreground, name)
+	style := baseStyle.Foreground(foreground).Bold(true)
+	builder.Write(name+" ", style)
 }
 
-func (ml *messagesList) drawContent(w io.Writer, message discord.Message) {
-	c := []byte(tview.Escape(message.Content))
+func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
+	c := []byte(message.Content)
 	if ml.chatView.cfg.Markdown {
-		ast := discordmd.ParseWithMessage(c, *ml.chatView.state.Cabinet, &message, false)
-		ml.renderer.Render(w, c, ast)
+		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:]
+			}
+		}
+		builder.AppendLines(lines)
 	} else {
-		w.Write(c) // write the content as is
+		builder.Write(message.Content, baseStyle)
 	}
 }
 
-func (ml *messagesList) drawSnapshotContent(w io.Writer, message discord.MessageSnapshotMessage) {
-	c := []byte(tview.Escape(message.Content))
+func (ml *messagesList) drawSnapshotContent(builder *tview.LineBuilder, message discord.MessageSnapshotMessage, baseStyle tcell.Style) {
+	c := []byte(message.Content)
 	// discordmd doesn't support MessageSnapshotMessage, so we just use write it as is. todo?
-	w.Write(c)
+	builder.Write(string(c), baseStyle)
 }
 
-func (ml *messagesList) drawDefaultMessage(w io.Writer, message discord.Message) {
+func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
 	if ml.cfg.Timestamps.Enabled {
-		ml.drawTimestamps(w, message.Timestamp)
+		ml.drawTimestamps(builder, message.Timestamp, baseStyle)
 	}
 
-	ml.drawAuthor(w, message)
-	ml.drawContent(w, message)
+	ml.drawAuthor(builder, message, baseStyle)
+	ml.drawContent(builder, message, baseStyle)
 
 	if message.EditedTimestamp.IsValid() {
-		io.WriteString(w, " [::d](edited)[::D]")
+		dimStyle := baseStyle.Dim(true)
+		builder.Write(" (edited)", dimStyle)
 	}
 
+	attachmentStyle := ui.MergeStyle(baseStyle, ml.cfg.Theme.MessagesList.AttachmentStyle.Style)
 	for _, a := range message.Attachments {
-		io.WriteString(w, "\n")
-
-		fg := ml.cfg.Theme.MessagesList.AttachmentStyle.GetForeground()
-		bg := ml.cfg.Theme.MessagesList.AttachmentStyle.GetBackground()
+		builder.NewLine()
 		if ml.cfg.ShowAttachmentLinks {
-			fmt.Fprintf(w, "[%s:%s]%s:\n%s[-:-]", fg, bg, a.Filename, a.URL)
+			builder.Write(a.Filename+":\n"+a.URL, attachmentStyle)
 		} else {
-			fmt.Fprintf(w, "[%s:%s]%s[-:-]", fg, bg, a.Filename)
+			builder.Write(a.Filename, attachmentStyle)
 		}
 	}
 }
 
-func (ml *messagesList) drawForwardedMessage(w io.Writer, message discord.Message) {
-	ml.drawTimestamps(w, message.Timestamp)
-	ml.drawAuthor(w, message)
-	fmt.Fprintf(w, "[::d]%s [::-]", ml.cfg.Theme.MessagesList.ForwardedIndicator)
-	ml.drawSnapshotContent(w, message.MessageSnapshots[0].Message)
-	fmt.Fprintf(w, " [::d](%s)[-:-:-] ", ml.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp))
+func (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
+	dimStyle := baseStyle.Dim(true)
+	ml.drawTimestamps(builder, message.Timestamp, baseStyle)
+	ml.drawAuthor(builder, message, baseStyle)
+	builder.Write(ml.cfg.Theme.MessagesList.ForwardedIndicator+" ", dimStyle)
+	ml.drawSnapshotContent(builder, message.MessageSnapshots[0].Message, baseStyle)
+	builder.Write(" ("+ml.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle)
 }
 
-func (ml *messagesList) drawReplyMessage(w io.Writer, message discord.Message) {
+func (ml *messagesList) drawReplyMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
+	dimStyle := baseStyle.Dim(true)
 	// indicator
-	io.WriteString(w, "[::d]")
-	io.WriteString(w, ml.cfg.Theme.MessagesList.ReplyIndicator)
-	io.WriteString(w, " ")
+	builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", dimStyle)
 
 	if m := message.ReferencedMessage; m != nil {
 		m.GuildID = message.GuildID
-		ml.drawAuthor(w, *m)
-		ml.drawContent(w, *m)
+		ml.drawAuthor(builder, *m, dimStyle)
+		ml.drawContent(builder, *m, dimStyle)
 	} else {
-		io.WriteString(w, "Original message was deleted")
+		builder.Write("Original message was deleted", dimStyle)
 	}
 
-	io.WriteString(w, "\n")
+	builder.NewLine()
 	// main
-	ml.drawDefaultMessage(w, message)
+	ml.drawDefaultMessage(builder, message, baseStyle)
 }
 
-func (ml *messagesList) drawPinnedMessage(w io.Writer, message discord.Message) {
-	io.WriteString(w, message.Author.DisplayOrUsername())
-	io.WriteString(w, " pinned a message.")
+func (ml *messagesList) drawPinnedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
+	builder.Write(message.Author.DisplayOrUsername(), baseStyle)
+	builder.Write(" pinned a message.", baseStyle)
 }
 
 func (ml *messagesList) selectedMessage() (*discord.Message, error) {
@@ -531,47 +530,115 @@ func extractURLs(content string) []string {
 }
 
 func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord.Attachment) {
-	list := tview.NewList().
-		SetWrapAround(true).
-		SetHighlightFullLine(true).
-		ShowSecondaryText(false).
-		SetDoneFunc(func() {
-			ml.chatView.RemoveLayer(attachmentsListLayerName)
-			ml.chatView.app.SetFocus(ml)
-		})
-	list.
-		SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-			switch event.Name() {
-			case ml.cfg.Keybinds.MessagesList.SelectUp:
-				return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
-			case ml.cfg.Keybinds.MessagesList.SelectDown:
-				return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone)
-			case ml.cfg.Keybinds.MessagesList.SelectTop:
-				return tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)
-			case ml.cfg.Keybinds.MessagesList.SelectBottom:
-				return tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)
-			}
+	type attachmentAction struct {
+		label    string
+		shortcut rune
+		open     func()
+	}
 
-			return event
-		})
-	list.Box = ui.ConfigureBox(list.Box, &ml.cfg.Theme)
+	closeList := func() {
+		ml.chatView.RemoveLayer(attachmentsListLayerName)
+		ml.chatView.app.SetFocus(ml)
+	}
 
+	var actions []attachmentAction
 	for i, a := range attachments {
-		list.AddItem(a.Filename, "", rune('a'+i), func() {
-			if strings.HasPrefix(a.ContentType, "image/") {
-				go ml.openAttachment(a)
+		attachment := a
+		action := func() {
+			if strings.HasPrefix(attachment.ContentType, "image/") {
+				go ml.openAttachment(attachment)
 			} else {
-				go ml.openURL(a.URL)
+				go ml.openURL(attachment.URL)
 			}
+		}
+		actions = append(actions, attachmentAction{
+			label:    attachment.Filename,
+			shortcut: rune('a' + i),
+			open:     action,
 		})
 	}
-
 	for i, u := range urls {
-		list.AddItem(u, "", rune('1'+i), func() {
-			go ml.openURL(u)
+		url := u
+		actions = append(actions, attachmentAction{
+			label:    url,
+			shortcut: rune('1' + i),
+			open:     func() { go ml.openURL(url) },
 		})
 	}
 
+	normalItems := make([]*tview.TextView, len(actions))
+	selectedItems := make([]*tview.TextView, len(actions))
+	for i, action := range actions {
+		normalItems[i] = tview.NewTextView().
+			SetScrollable(false).
+			SetWrap(false).
+			SetWordWrap(false).
+			SetLines([]tview.Line{{{Text: action.label, Style: tcell.StyleDefault}}})
+		selectedItems[i] = tview.NewTextView().
+			SetScrollable(false).
+			SetWrap(false).
+			SetWordWrap(false).
+			SetLines([]tview.Line{{{Text: action.label, Style: tcell.StyleDefault.Reverse(true)}}})
+	}
+
+	list := tview.NewList().
+		SetSnapToItems(true).
+		SetBuilder(func(index int, cursor int) tview.ListItem {
+			if index < 0 || index >= len(actions) {
+				return nil
+			}
+			if index == cursor {
+				return selectedItems[index]
+			}
+			return normalItems[index]
+		})
+	list.Box = ui.ConfigureBox(list.Box, &ml.cfg.Theme)
+	if len(actions) > 0 {
+		list.SetCursor(0)
+	}
+	list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+		switch event.Name() {
+		case ml.cfg.Keybinds.MessagesList.SelectUp:
+			return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
+		case ml.cfg.Keybinds.MessagesList.SelectDown:
+			return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone)
+		case ml.cfg.Keybinds.MessagesList.SelectTop:
+			return tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)
+		case ml.cfg.Keybinds.MessagesList.SelectBottom:
+			return tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)
+		case ml.cfg.Keybinds.MessagesList.Cancel:
+			closeList()
+			return nil
+		}
+
+		if event.Key() == tcell.KeyEnter || event.Key() == tcell.KeyRune && event.Str() == " " {
+			index := list.Cursor()
+			if index >= 0 && index < len(actions) {
+				actions[index].open()
+				closeList()
+			}
+			return nil
+		}
+
+		if event.Key() == tcell.KeyRune {
+			key := event.Str()
+			if key == "" {
+				return event
+			}
+			ch := []rune(key)[0]
+			for index, action := range actions {
+				if action.shortcut == ch {
+					list.SetCursor(index)
+					actions[index].open()
+					closeList()
+					return nil
+				}
+			}
+		}
+
+		return event
+	})
+
 	ml.chatView.
 		AddLayer(
 			ui.Centered(list, 0, 0),

+ 5 - 4
internal/ui/login/form.go

@@ -78,18 +78,19 @@ func (f *Form) onError(err error) {
 		})
 	{
 		bg := f.cfg.Theme.Dialog.Style.GetBackground()
+		buttonStyle := f.cfg.Theme.Dialog.Style.Style
 		if bg != tcell.ColorDefault {
 			modal.SetBackgroundColor(bg)
-			modal.SetButtonBackgroundColor(bg)
+			buttonStyle = buttonStyle.Background(bg)
 		}
 		fg := f.cfg.Theme.Dialog.Style.GetForeground()
 		if fg != tcell.ColorDefault {
 			modal.SetTextColor(fg)
-			modal.SetButtonTextColor(fg)
+			buttonStyle = buttonStyle.Foreground(fg)
 		}
 		// Keep button styles aligned with dialog content without hiding text.
-		modal.SetButtonStyle(f.cfg.Theme.Dialog.Style.Style)
-		modal.SetButtonActivatedStyle(f.cfg.Theme.Dialog.Style.Style)
+		modal.SetButtonStyle(buttonStyle)
+		modal.SetButtonActivatedStyle(buttonStyle)
 	}
 	f.
 		AddLayer(

+ 63 - 55
internal/ui/login/qr.go

@@ -52,7 +52,6 @@ func newQRLogin(app *tview.Application, cfg *config.Config, done func(token stri
 	q.Box = ui.ConfigureBox(q.Box, &cfg.Theme)
 
 	q.
-		SetDynamicColors(true).
 		SetScrollable(true).
 		SetWrap(false).
 		SetTextAlign(tview.AlignmentCenter).
@@ -74,21 +73,6 @@ func newQRLogin(app *tview.Application, cfg *config.Config, done func(token stri
 	return q
 }
 
-func (q *qrLogin) centerText(s string) string {
-	_, _, _, height := q.GetInnerRect()
-	if height == 0 {
-		height = 40
-	}
-	lines := strings.Count(s, "\n") + 1
-	padding := (height - lines) / 2
-	if padding < 0 {
-		padding = 0
-	} else if padding < 1 && height > lines {
-		padding = 1
-	}
-	return strings.Repeat("\n", padding) + s
-}
-
 func (q *qrLogin) start() {
 	ctx, cancel := context.WithCancel(context.Background())
 	q.cancel = cancel
@@ -105,11 +89,38 @@ func (q *qrLogin) stop() {
 }
 
 func (q *qrLogin) setText(s string) {
+	builder := tview.NewLineBuilder()
+	builder.Write(s, tcell.StyleDefault)
+	q.setLines(builder.Finish())
+}
+
+func (q *qrLogin) setLines(lines []tview.Line) {
 	q.app.QueueUpdateDraw(func() {
-		q.SetText(q.centerText(s))
+		q.SetLines(q.centerLines(lines))
 	})
 }
 
+func (q *qrLogin) centerLines(lines []tview.Line) []tview.Line {
+	_, _, _, height := q.GetInnerRect()
+	if height == 0 {
+		height = 40
+	}
+	padding := (height - len(lines)) / 2
+	if padding < 0 {
+		padding = 0
+	} else if padding < 1 && height > len(lines) {
+		padding = 1
+	}
+	if padding == 0 {
+		return lines
+	}
+
+	centered := make([]tview.Line, 0, padding+len(lines))
+	centered = append(centered, make([]tview.Line, padding)...)
+	centered = append(centered, lines...)
+	return centered
+}
+
 func (q *qrLogin) writeJSON(data any) error {
 	return q.conn.WriteJSON(data)
 }
@@ -137,14 +148,7 @@ type raPendingTicket struct {
 
 func (q *qrLogin) run(ctx context.Context) {
 	defer q.stop()
-
-	setText := func(s string) {
-		q.app.QueueUpdateDraw(func() {
-			q.SetText(q.centerText(s))
-		})
-	}
-
-	setText("Preparing QR code...\n\n[::d]Press Esc to cancel[-]")
+	q.setText("Preparing QR code...\n\nPress Esc to cancel")
 
 	privKey, err := rsa.GenerateKey(rand.Reader, 2048)
 	if err != nil {
@@ -164,7 +168,7 @@ func (q *qrLogin) run(ctx context.Context) {
 	headers.Set("User-Agent", apphttp.BrowserUserAgent)
 	headers.Set("Origin", "https://discord.com")
 
-	q.setText("Connecting to Remote Auth Gateway...\n\n[::d]Press Esc to cancel[-]")
+	q.setText("Connecting to Remote Auth Gateway...\n\nPress Esc to cancel")
 
 	dialer := websocket.Dialer{
 		Proxy:             stdhttp.ProxyFromEnvironment,
@@ -216,7 +220,7 @@ func (q *qrLogin) run(ctx context.Context) {
 				Op string `json:"op"`
 			}
 			if err := json.Unmarshal(data, &opOnly); err != nil {
-				q.setText("[red]Bad JSON:[-] " + err.Error())
+				q.setText("Bad JSON: " + err.Error())
 				q.fail(err)
 				return
 			}
@@ -225,7 +229,7 @@ func (q *qrLogin) run(ctx context.Context) {
 			case "hello":
 				var h raHello
 				if err := json.Unmarshal(data, &h); err != nil {
-					q.setText("[red]Hello decode failed:[-] " + err.Error())
+					q.setText("Hello decode failed: " + err.Error())
 					q.fail(err)
 					return
 				}
@@ -243,73 +247,76 @@ func (q *qrLogin) run(ctx context.Context) {
 						}
 					}()
 				}
-				q.setText("Connected. Handshaking...\n\n[::d]Press Esc to cancel[-]")
+				q.setText("Connected. Handshaking...\n\nPress Esc to cancel")
 				if err := q.writeJSON(map[string]any{
 					"op":                 "init",
 					"encoded_public_key": encodedPublicKey,
 				}); err != nil {
-					q.setText("[red]Init send failed:[-] " + err.Error())
+					q.setText("Init send failed: " + err.Error())
 					q.fail(err)
 					return
 				}
 			case "nonce_proof":
 				var n raNonceProof
 				if err := json.Unmarshal(data, &n); err != nil {
-					q.setText("[red]Nonce decode failed:[-] " + err.Error())
+					q.setText("Nonce decode failed: " + err.Error())
 					q.fail(err)
 					return
 				}
 				enc, err := base64.StdEncoding.DecodeString(n.EncryptedNonce)
 				if err != nil {
-					q.setText("[red]Nonce b64 decode failed:[-] " + err.Error())
+					q.setText("Nonce b64 decode failed: " + err.Error())
 					q.fail(err)
 					return
 				}
 				pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, enc, nil)
 				if err != nil {
-					q.setText("[red]Nonce decrypt failed:[-] " + err.Error())
+					q.setText("Nonce decrypt failed: " + err.Error())
 					q.fail(err)
 					return
 				}
 				nonce := base64.RawURLEncoding.EncodeToString(pt)
 				if err := q.writeJSON(map[string]any{"op": "nonce_proof", "nonce": nonce}); err != nil {
-					q.setText("[red]Nonce send failed:[-] " + err.Error())
+					q.setText("Nonce send failed: " + err.Error())
 					q.fail(err)
 					return
 				}
 			case "pending_remote_init":
 				var p raPendingInit
 				if err := json.Unmarshal(data, &p); err != nil {
-					q.setText("[red]Init decode failed:[-] " + err.Error())
+					q.setText("Init decode failed: " + err.Error())
 					q.fail(err)
 					return
 				}
 				q.fingerprint = p.Fingerprint
 				content := "https://discord.com/ra/" + p.Fingerprint
-				ascii, err := renderQR(content)
+				qrLines, err := renderQR(content)
 				if err != nil {
-					q.setText("[red]QR render failed:[-] " + err.Error())
+					q.setText("QR render failed: " + err.Error())
 					q.fail(err)
 					return
 				}
-				q.setText(ascii + "\n\n[::b]Scan with Discord mobile app[::-]\n\n[::d]Press Esc to cancel[-]")
+				builder := tview.NewLineBuilder()
+				builder.AppendLines(qrLines)
+				builder.Write("\n\nScan with Discord mobile app\n\nPress Esc to cancel", tcell.StyleDefault)
+				q.setLines(builder.Finish())
 			case "heartbeat_ack":
 			case "pending_ticket":
 				var t raPendingTicket
 				if err := json.Unmarshal(data, &t); err != nil {
-					q.setText("[red]Ticket decode failed:[-] " + err.Error())
+					q.setText("Ticket decode failed: " + err.Error())
 					q.fail(err)
 					return
 				}
 				payload, err := base64.StdEncoding.DecodeString(t.EncryptedUserPayload)
 				if err != nil {
-					q.setText("[red]Ticket payload b64 failed:[-] " + err.Error())
+					q.setText("Ticket payload b64 failed: " + err.Error())
 					q.fail(err)
 					return
 				}
 				pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, payload, nil)
 				if err != nil {
-					q.setText("[red]Ticket payload decrypt failed:[-] " + err.Error())
+					q.setText("Ticket payload decrypt failed: " + err.Error())
 					q.fail(err)
 					return
 				}
@@ -320,21 +327,21 @@ func (q *qrLogin) run(ctx context.Context) {
 					username = parts[3]
 				}
 				if discriminator == "" && username == "" {
-					q.setText("Scan received.\n\nWaiting for approval on mobile...\n\n[::d]Press Esc to cancel[-]")
+					q.setText("Scan received.\n\nWaiting for approval on mobile...\n\nPress Esc to cancel")
 				} else {
-					q.setText("Logging in as [::b]" + username + "[#]" + discriminator + "[::-]\n\nConfirm on mobile...\n\n[::d]Press Esc to cancel[-]")
+					q.setText("Logging in as " + username + "#" + discriminator + "\n\nConfirm on mobile...\n\nPress Esc to cancel")
 				}
 			case "pending_login":
 				var p raPendingLogin
 				if err := json.Unmarshal(data, &p); err != nil {
-					q.setText("[red]Login decode failed:[-] " + err.Error())
+					q.setText("Login decode failed: " + err.Error())
 					q.fail(err)
 					return
 				}
-				q.setText("Authenticating...\n\n[::d]Please wait[-]")
+				q.setText("Authenticating...\n\nPlease wait")
 				token, err := exchangeTicket(ctx, p.Ticket, q.fingerprint, q.privKey)
 				if err != nil {
-					q.setText("[red]Ticket exchange failed:[-] " + err.Error())
+					q.setText("Ticket exchange failed: " + err.Error())
 					q.fail(err)
 					return
 				}
@@ -352,13 +359,14 @@ func (q *qrLogin) run(ctx context.Context) {
 	}
 }
 
-func renderQR(content string) (string, error) {
+func renderQR(content string) ([]tview.Line, error) {
 	code, err := qrcode.New(content, qrcode.Low)
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 	bitmap := code.Bitmap()
-	var b strings.Builder
+	builder := tview.NewLineBuilder()
+	style := tcell.StyleDefault
 	for y := 0; y < len(bitmap); y += 2 {
 		for x := range bitmap[y] {
 			top := bitmap[y][x]
@@ -367,18 +375,18 @@ func renderQR(content string) (string, error) {
 				bottom = bitmap[y+1][x]
 			}
 			if top && bottom {
-				b.WriteString("█")
+				builder.Write("█", style)
 			} else if top && !bottom {
-				b.WriteString("▀")
+				builder.Write("▀", style)
 			} else if !top && bottom {
-				b.WriteString("▄")
+				builder.Write("▄", style)
 			} else {
-				b.WriteString(" ")
+				builder.Write(" ", style)
 			}
 		}
-		b.WriteByte('\n')
+		builder.NewLine()
 	}
-	return b.String(), nil
+	return builder.Finish(), nil
 }
 
 func exchangeTicket(ctx context.Context, ticket string, fingerprint string, priv *rsa.PrivateKey) (string, error) {

+ 23 - 0
internal/ui/util.go

@@ -8,6 +8,7 @@ import (
 	"github.com/ayn2op/discordo/internal/config"
 	"github.com/ayn2op/tview"
 	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/gdamore/tcell/v3"
 )
 
 // ConfigureBox configures the provided box according to the provided theme.
@@ -121,3 +122,25 @@ func getMessageIDFromChannel(channel discord.Channel) discord.MessageID {
 	}
 	return discord.MessageID(channel.ID)
 }
+
+func MergeStyle(base, overlay tcell.Style) tcell.Style {
+	fg := overlay.GetForeground()
+	if fg == tcell.ColorDefault {
+		fg = base.GetForeground()
+	}
+	bg := overlay.GetBackground()
+	if bg == tcell.ColorDefault {
+		bg = base.GetBackground()
+	}
+	style := base.Foreground(fg).Background(bg)
+	style = style.Bold(base.HasBold() || overlay.HasBold())
+	style = style.Dim(base.HasDim() || overlay.HasDim())
+	style = style.Italic(base.HasItalic() || overlay.HasItalic())
+	style = style.Blink(base.HasBlink() || overlay.HasBlink())
+	style = style.Reverse(base.HasReverse() || overlay.HasReverse())
+	style = style.StrikeThrough(base.HasStrikeThrough() || overlay.HasStrikeThrough())
+	if base.HasUnderline() || overlay.HasUnderline() {
+		style = style.Underline(true)
+	}
+	return style
+}

+ 32 - 12
pkg/picker/picker.go

@@ -45,10 +45,7 @@ func New() *Picker {
 		SetBorderStyle(tcell.StyleDefault.Dim(true)).
 		SetInputCapture(p.onInputCapture)
 
-	p.list.
-		SetSelectedFunc(p.onListSelected).
-		ShowSecondaryText(false).
-		SetHighlightFullLine(true)
+	p.list.SetSnapToItems(true)
 
 	p.
 		SetDirection(tview.FlexRow).
@@ -60,6 +57,32 @@ func New() *Picker {
 	return p
 }
 
+func (p *Picker) setFilteredItems(filtered Items) {
+	p.filtered = filtered
+
+	p.list.SetBuilder(func(index int, cursor int) tview.ListItem {
+		if index < 0 || index >= len(p.filtered) {
+			return nil
+		}
+		style := tcell.StyleDefault
+		if index == cursor {
+			style = style.Foreground(tview.Styles.PrimitiveBackgroundColor).Background(tview.Styles.PrimaryTextColor)
+		}
+		return tview.NewTextView().
+			SetScrollable(false).
+			SetWrap(false).
+			SetWordWrap(false).
+			SetTextStyle(style).
+			SetLines([]tview.Line{{{Text: p.filtered[index].Text, Style: style}}})
+	})
+
+	if len(filtered) == 0 {
+		p.list.SetCursor(-1)
+	} else {
+		p.list.SetCursor(0)
+	}
+}
+
 func (p *Picker) SetKeyMap(keyMap *KeyMap) {
 	p.keyMap = keyMap
 }
@@ -77,6 +100,7 @@ func (p *Picker) ClearInput() {
 }
 
 func (p *Picker) ClearList() {
+	p.filtered = nil
 	p.list.Clear()
 }
 
@@ -94,7 +118,7 @@ func (p *Picker) Update() {
 	p.onInputChanged("")
 }
 
-func (p *Picker) onListSelected(index int, text, _ string, _ rune) {
+func (p *Picker) onListSelected(index int) {
 	if p.onSelected != nil {
 		if index >= 0 && index < len(p.filtered) {
 			item := p.filtered[index]
@@ -113,12 +137,7 @@ func (p *Picker) onInputChanged(text string) {
 			fuzzied = append(fuzzied, p.items[match.Index])
 		}
 	}
-	p.filtered = fuzzied
-
-	p.ClearList()
-	for _, item := range fuzzied {
-		p.list.AddItem(item.Text, "", 0, nil)
-	}
+	p.setFilteredItems(fuzzied)
 }
 
 func (p *Picker) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
@@ -140,7 +159,8 @@ func (p *Picker) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	case p.keyMap.Bottom:
 		handler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil)
 	case p.keyMap.Select:
-		handler(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone), nil)
+		p.onListSelected(p.list.Cursor())
+		return nil
 
 	case p.keyMap.Cancel:
 		if p.onCancel != nil {