refactor(ui): switch to segment-based api

This commit is contained in:
ayn2op 2026-02-08 01:23:56 -05:00
parent 8f458d984c
commit d86b2f6e60
11 changed files with 567 additions and 396 deletions

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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(

View file

@ -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(

View file

@ -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) {

View file

@ -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
}

View file

@ -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 {