refactor(ui): switch to segment-based api
This commit is contained in:
parent
8f458d984c
commit
d86b2f6e60
11 changed files with 567 additions and 396 deletions
2
go.mod
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
internal/ui/chat/mentions_list.go
Normal file
90
internal/ui/chat/mentions_list.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue