From d86b2f6e609d4166a99398bc20e8368338deb993 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Sun, 8 Feb 2026 01:23:56 -0500 Subject: [PATCH] refactor(ui): switch to segment-based api --- go.mod | 2 +- go.sum | 4 +- internal/markdown/renderer.go | 300 +++++++++++++---------------- internal/ui/chat/guilds_tree.go | 6 +- internal/ui/chat/mentions_list.go | 90 +++++++++ internal/ui/chat/message_input.go | 64 ++++--- internal/ui/chat/messages_list.go | 303 ++++++++++++++++++------------ internal/ui/login/form.go | 9 +- internal/ui/login/qr.go | 118 ++++++------ internal/ui/util.go | 23 +++ pkg/picker/picker.go | 44 +++-- 11 files changed, 567 insertions(+), 396 deletions(-) create mode 100644 internal/ui/chat/mentions_list.go diff --git a/go.mod b/go.mod index 2690bb4..978f9e3 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 2d4e0a5..983d0be 100644 --- a/go.sum +++ b/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= diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 68442ec..4b85c1a 100644 --- a/internal/markdown/renderer.go +++ b/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) - - case *discordmd.Inline: - r.renderInline(w, node, entering) - case *discordmd.Mention: - r.renderMention(w, node, entering) - case *discordmd.Emoji: - r.renderEmoji(w, node, entering) - } - - 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) - } -} - -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 + if node.IsOrdered() { + start := node.Start + r.listIx = &start + } else { + r.listIx = nil } - io.WriteString(w, "@"+name) - case node.GuildRole != nil: - io.WriteString(w, "@"+node.GuildRole.Name) + 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: + if entering { + pushStyle(applyInlineAttr(currentStyle(), node.Attr)) + } else { + popStyle() + } + case *discordmd.Mention: + if entering { + style := ui.MergeStyle(currentStyle(), r.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) + builder.Write(":"+node.Name+":", style) + } } - } else { - io.WriteString(w, "[-:-:B]") + return ast.WalkContinue, nil + }) + + return builder.Finish() +} + +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 + } + return "@" + name + case node.GuildRole != nil: + return "@" + node.GuildRole.Name + default: + return "" } } -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, "[-:-]") - } -} - -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 } diff --git a/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go index d13fbdf..04c9bb3 100644 --- a/internal/ui/chat/guilds_tree.go +++ b/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 { diff --git a/internal/ui/chat/mentions_list.go b/internal/ui/chat/mentions_list.go new file mode 100644 index 0000000..759c0e7 --- /dev/null +++ b/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 +} diff --git a/internal/ui/chat/message_input.go b/internal/ui/chat/message_input.go index bd6f654..396670b 100644 --- a/internal/ui/chat/message_input.go +++ b/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) } diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index d9ac3ee..008208a 100644 --- a/internal/ui/chat/messages_list.go +++ b/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,46 +530,114 @@ 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 + closeList := func() { + ml.chatView.RemoveLayer(attachmentsListLayerName) + ml.chatView.app.SetFocus(ml) + } + + var actions []attachmentAction + for i, a := range attachments { + attachment := a + action := func() { + if strings.HasPrefix(attachment.ContentType, "image/") { + go ml.openAttachment(attachment) + } else { + go ml.openURL(attachment.URL) + } + } + actions = append(actions, attachmentAction{ + label: attachment.Filename, + shortcut: rune('a' + i), + open: action, + }) + } + for i, u := range urls { + 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 + } - for i, a := range attachments { - list.AddItem(a.Filename, "", rune('a'+i), func() { - if strings.HasPrefix(a.ContentType, "image/") { - go ml.openAttachment(a) - } else { - go ml.openURL(a.URL) + 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 + } - for i, u := range urls { - list.AddItem(u, "", rune('1'+i), func() { - go ml.openURL(u) - }) - } + 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( diff --git a/internal/ui/login/form.go b/internal/ui/login/form.go index 51a7ad8..27c01ff 100644 --- a/internal/ui/login/form.go +++ b/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( diff --git a/internal/ui/login/qr.go b/internal/ui/login/qr.go index f4b0104..2323d63 100644 --- a/internal/ui/login/qr.go +++ b/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) { diff --git a/internal/ui/util.go b/internal/ui/util.go index 1f62b3e..102a805 100644 --- a/internal/ui/util.go +++ b/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 +} diff --git a/pkg/picker/picker.go b/pkg/picker/picker.go index 588dff4..575bcae 100644 --- a/pkg/picker/picker.go +++ b/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 {