refactor(ui): adopt command API (#762)
This commit is contained in:
parent
5ed32db323
commit
ff6f8f9ad0
10 changed files with 273 additions and 290 deletions
4
go.mod
4
go.mod
|
|
@ -2,13 +2,11 @@ module github.com/ayn2op/discordo
|
|||
|
||||
go 1.26.0
|
||||
|
||||
// replace github.com/ayn2op/tview => ../tview
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/ayn2op/tview v0.0.0-20260225014619-7f27f376aea9
|
||||
github.com/ayn2op/tview v0.0.0-20260301025618-69fc2518f451
|
||||
github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb
|
||||
github.com/diamondburned/arikawa/v3 v3.6.1-0.20260226015332-783a3e8e8e86
|
||||
github.com/diamondburned/ningen/v3 v3.0.1-0.20260226220604-93f1e60c3cdb
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -14,8 +14,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
|||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
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-20260225014619-7f27f376aea9 h1:6zEOzR3v3bylvKLrNUiQ2ykVGzZilVKypdUU3EcVK7c=
|
||||
github.com/ayn2op/tview v0.0.0-20260225014619-7f27f376aea9/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0=
|
||||
github.com/ayn2op/tview v0.0.0-20260301025618-69fc2518f451 h1:DfZZiNSwERJNZ9f33hsxWIFFiAgGY2T+Fi7unZ6Ka7A=
|
||||
github.com/ayn2op/tview v0.0.0-20260301025618-69fc2518f451/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -377,41 +377,36 @@ func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) {
|
|||
})
|
||||
}
|
||||
|
||||
func (gt *guildsTree) handleInput(event *tcell.EventKey) *tcell.EventKey {
|
||||
func (gt *guildsTree) InputHandler(event *tcell.EventKey) tview.Command {
|
||||
consume := tview.ConsumeEventCommand{}
|
||||
consumeRedraw := tview.BatchCommand{tview.RedrawCommand{}, tview.ConsumeEventCommand{}}
|
||||
handler := gt.TreeView.InputHandler
|
||||
|
||||
switch {
|
||||
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind):
|
||||
gt.collapseParentNode(gt.GetCurrentNode())
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.MoveToParentNode.Keybind):
|
||||
return tcell.NewEventKey(tcell.KeyRune, "K", tcell.ModNone)
|
||||
|
||||
return handler(tcell.NewEventKey(tcell.KeyRune, "K", tcell.ModNone))
|
||||
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Up.Keybind):
|
||||
return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
|
||||
return handler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone))
|
||||
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Down.Keybind):
|
||||
return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone)
|
||||
return handler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone))
|
||||
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Top.Keybind):
|
||||
gt.Move(gt.GetRowCount() * -1)
|
||||
// return tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Bottom.Keybind):
|
||||
gt.Move(gt.GetRowCount())
|
||||
// return tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone)
|
||||
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind):
|
||||
return tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone)
|
||||
|
||||
return handler(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone))
|
||||
case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.YankID.Keybind):
|
||||
gt.yankID()
|
||||
return consume
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gt *guildsTree) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
event = gt.handleInput(event)
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
gt.TreeView.InputHandler(event, setFocus)
|
||||
// Do not fall through to TreeView defaults for unmatched keys.
|
||||
return consume
|
||||
}
|
||||
|
||||
func (gt *guildsTree) yankID() {
|
||||
|
|
|
|||
|
|
@ -92,52 +92,56 @@ func (mi *messageInput) stopTypingTimer() {
|
|||
}
|
||||
}
|
||||
|
||||
func (mi *messageInput) handleInput(event *tcell.EventKey) *tcell.EventKey {
|
||||
func (mi *messageInput) InputHandler(event *tcell.EventKey) tview.Command {
|
||||
consume := tview.ConsumeEventCommand{}
|
||||
consumeRedraw := tview.BatchCommand{tview.RedrawCommand{}, tview.ConsumeEventCommand{}}
|
||||
handler := mi.TextArea.InputHandler
|
||||
|
||||
switch {
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Paste.Keybind):
|
||||
mi.paste()
|
||||
return tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)
|
||||
|
||||
return handler(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Send.Keybind):
|
||||
if mi.chatView.GetVisible(mentionsListLayerName) {
|
||||
mi.tabComplete()
|
||||
return nil
|
||||
} else {
|
||||
mi.send()
|
||||
}
|
||||
|
||||
mi.send()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenEditor.Keybind):
|
||||
mi.stopTabCompletion()
|
||||
var cmd tview.Command
|
||||
mi.stopTabCompletion(func(next tview.Command) { cmd = tview.AppendCommand(cmd, next) })
|
||||
mi.editor()
|
||||
return nil
|
||||
return tview.AppendCommand(cmd, consumeRedraw)
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenFilePicker.Keybind):
|
||||
mi.stopTabCompletion()
|
||||
var cmd tview.Command
|
||||
mi.stopTabCompletion(func(next tview.Command) { cmd = tview.AppendCommand(cmd, next) })
|
||||
mi.openFilePicker()
|
||||
return nil
|
||||
return tview.AppendCommand(cmd, consumeRedraw)
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Cancel.Keybind):
|
||||
var cmd tview.Command
|
||||
if mi.chatView.GetVisible(mentionsListLayerName) {
|
||||
mi.stopTabCompletion()
|
||||
mi.stopTabCompletion(func(next tview.Command) { cmd = tview.AppendCommand(cmd, next) })
|
||||
} else {
|
||||
mi.reset()
|
||||
}
|
||||
|
||||
return nil
|
||||
return tview.AppendCommand(cmd, consumeRedraw)
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.TabComplete.Keybind):
|
||||
go mi.chatView.app.QueueUpdateDraw(func() { mi.tabComplete() })
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Undo.Keybind):
|
||||
return tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone)
|
||||
default:
|
||||
if mi.cfg.TypingIndicator.Send && mi.typingTimer == nil {
|
||||
mi.typingTimer = time.AfterFunc(typingDuration, func() {
|
||||
mi.typingTimerMu.Lock()
|
||||
mi.typingTimer = nil
|
||||
mi.typingTimerMu.Unlock()
|
||||
})
|
||||
return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone))
|
||||
}
|
||||
|
||||
if selectedChannel := mi.chatView.SelectedChannel(); selectedChannel != nil {
|
||||
go mi.chatView.state.Typing(selectedChannel.ID)
|
||||
}
|
||||
if mi.cfg.TypingIndicator.Send && mi.typingTimer == nil {
|
||||
mi.typingTimer = time.AfterFunc(typingDuration, func() {
|
||||
mi.typingTimerMu.Lock()
|
||||
mi.typingTimer = nil
|
||||
mi.typingTimerMu.Unlock()
|
||||
})
|
||||
|
||||
if selectedChannel := mi.chatView.SelectedChannel(); selectedChannel != nil {
|
||||
go mi.chatView.state.Typing(selectedChannel.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,32 +149,24 @@ func (mi *messageInput) handleInput(event *tcell.EventKey) *tcell.EventKey {
|
|||
if mi.chatView.GetVisible(mentionsListLayerName) {
|
||||
switch {
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Up.Keybind):
|
||||
mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil)
|
||||
return nil
|
||||
mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone))
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Down.Keybind):
|
||||
mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone), nil)
|
||||
return nil
|
||||
mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone))
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Top.Keybind):
|
||||
mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone), nil)
|
||||
return nil
|
||||
mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone))
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Bottom.Keybind):
|
||||
mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil)
|
||||
return nil
|
||||
mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone))
|
||||
return consumeRedraw
|
||||
}
|
||||
}
|
||||
|
||||
go mi.chatView.app.QueueUpdateDraw(func() { mi.tabSuggestion() })
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
func (mi *messageInput) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
event = mi.handleInput(event)
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
mi.TextArea.InputHandler(event, setFocus)
|
||||
return handler(event)
|
||||
}
|
||||
|
||||
func (mi *messageInput) paste() {
|
||||
|
|
@ -308,7 +304,7 @@ func (mi *messageInput) tabComplete() {
|
|||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
|
||||
})
|
||||
if r != '@' {
|
||||
mi.stopTabCompletion()
|
||||
mi.stopTabCompletion(nil)
|
||||
return
|
||||
}
|
||||
pos := posEnd - (len(name) + 1)
|
||||
|
|
@ -352,7 +348,7 @@ func (mi *messageInput) tabComplete() {
|
|||
return
|
||||
}
|
||||
mi.Replace(pos, posEnd, "@"+name+" ")
|
||||
mi.stopTabCompletion()
|
||||
mi.stopTabCompletion(nil)
|
||||
}
|
||||
|
||||
func (mi *messageInput) tabSuggestion() {
|
||||
|
|
@ -360,7 +356,7 @@ func (mi *messageInput) tabSuggestion() {
|
|||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
|
||||
})
|
||||
if r != '@' {
|
||||
mi.stopTabCompletion()
|
||||
mi.stopTabCompletion(nil)
|
||||
return
|
||||
}
|
||||
selected := mi.chatView.SelectedChannel()
|
||||
|
|
@ -440,7 +436,7 @@ func (mi *messageInput) tabSuggestion() {
|
|||
}
|
||||
|
||||
if mi.mentionsList.itemCount() == 0 {
|
||||
mi.stopTabCompletion()
|
||||
mi.stopTabCompletion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -532,14 +528,7 @@ func (mi *messageInput) showMentionList() {
|
|||
}
|
||||
|
||||
l.SetRect(x, y, w, h)
|
||||
|
||||
mi.chatView.
|
||||
AddLayer(l,
|
||||
layers.WithName(mentionsListLayerName),
|
||||
layers.WithResize(false),
|
||||
layers.WithVisible(true),
|
||||
).
|
||||
SendToFront(mentionsListLayerName)
|
||||
mi.chatView.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName)
|
||||
mi.chatView.app.SetFocus(mi)
|
||||
}
|
||||
|
||||
|
|
@ -602,15 +591,19 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
|
|||
|
||||
// used by chatView
|
||||
func (mi *messageInput) removeMentionsList() {
|
||||
mi.chatView.
|
||||
RemoveLayer(mentionsListLayerName)
|
||||
mi.chatView.HideLayer(mentionsListLayerName)
|
||||
}
|
||||
|
||||
func (mi *messageInput) stopTabCompletion() {
|
||||
func (mi *messageInput) stopTabCompletion(emit func(tview.Command)) {
|
||||
if mi.cfg.AutocompleteLimit > 0 {
|
||||
mi.mentionsList.clear()
|
||||
mi.removeMentionsList()
|
||||
mi.chatView.app.SetFocus(mi)
|
||||
if emit != nil {
|
||||
emit(layers.CloseLayerCommand{Name: mentionsListLayerName})
|
||||
emit(tview.SetFocusCommand{Target: mi})
|
||||
} else {
|
||||
mi.removeMentionsList()
|
||||
mi.chatView.app.SetFocus(mi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ func (ml *messagesList) setMessages(messages []discord.Message) {
|
|||
// New channel payload / refetch: replace the cache wholesale to keep it in
|
||||
// lockstep with the current message slice.
|
||||
clear(ml.itemByID)
|
||||
ml.MarkDirty()
|
||||
}
|
||||
|
||||
func (ml *messagesList) addMessage(message discord.Message) {
|
||||
|
|
@ -135,7 +134,6 @@ func (ml *messagesList) addMessage(message discord.Message) {
|
|||
ml.invalidateRows()
|
||||
// Defensive invalidation for ID reuse/edits delivered out-of-order.
|
||||
delete(ml.itemByID, message.ID)
|
||||
ml.MarkDirty()
|
||||
}
|
||||
|
||||
func (ml *messagesList) setMessage(index int, message discord.Message) {
|
||||
|
|
@ -146,7 +144,6 @@ func (ml *messagesList) setMessage(index int, message discord.Message) {
|
|||
ml.messages[index] = message
|
||||
delete(ml.itemByID, message.ID)
|
||||
ml.invalidateRows()
|
||||
ml.MarkDirty()
|
||||
}
|
||||
|
||||
func (ml *messagesList) deleteMessage(index int) {
|
||||
|
|
@ -157,7 +154,6 @@ func (ml *messagesList) deleteMessage(index int) {
|
|||
delete(ml.itemByID, ml.messages[index].ID)
|
||||
ml.messages = append(ml.messages[:index], ml.messages[index+1:]...)
|
||||
ml.invalidateRows()
|
||||
ml.MarkDirty()
|
||||
}
|
||||
|
||||
func (ml *messagesList) clearSelection() {
|
||||
|
|
@ -529,78 +525,72 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) {
|
|||
return &ml.messages[cursor], nil
|
||||
}
|
||||
|
||||
func (ml *messagesList) handleInput(event *tcell.EventKey) *tcell.EventKey {
|
||||
func (ml *messagesList) InputHandler(event *tcell.EventKey) tview.Command {
|
||||
consume := tview.ConsumeEventCommand{}
|
||||
consumeRedraw := tview.BatchCommand{tview.RedrawCommand{}, tview.ConsumeEventCommand{}}
|
||||
|
||||
switch {
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollUp.Keybind):
|
||||
ml.ScrollUp()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollDown.Keybind):
|
||||
ml.ScrollDown()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollTop.Keybind):
|
||||
ml.ScrollToStart()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollBottom.Keybind):
|
||||
ml.ScrollToEnd()
|
||||
return nil
|
||||
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):
|
||||
ml.clearSelection()
|
||||
return nil
|
||||
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind):
|
||||
ml.selectUp()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind):
|
||||
ml.selectDown()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectTop.Keybind):
|
||||
ml.selectTop()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectBottom.Keybind):
|
||||
ml.selectBottom()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectReply.Keybind):
|
||||
ml.selectReply()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankID.Keybind):
|
||||
ml.yankID()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankContent.Keybind):
|
||||
ml.yankContent()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankURL.Keybind):
|
||||
ml.yankURL()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Open.Keybind):
|
||||
ml.open()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Reply.Keybind):
|
||||
ml.reply(false)
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ReplyMention.Keybind):
|
||||
ml.reply(true)
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Edit.Keybind):
|
||||
ml.edit()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Delete.Keybind):
|
||||
ml.delete()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.DeleteConfirm.Keybind):
|
||||
ml.confirmDelete()
|
||||
return nil
|
||||
return consumeRedraw
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
func (ml *messagesList) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
event = ml.handleInput(event)
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
ml.List.InputHandler(event, setFocus)
|
||||
// Do not fall through to List defaults for unmatched keys.
|
||||
return consume
|
||||
}
|
||||
|
||||
func (ml *messagesList) selectUp() {
|
||||
|
|
@ -709,7 +699,6 @@ func (ml *messagesList) prependOlderMessages() int {
|
|||
}
|
||||
ml.messages = slices.Concat(older, ml.messages)
|
||||
ml.invalidateRows()
|
||||
ml.MarkDirty()
|
||||
return len(messages)
|
||||
}
|
||||
|
||||
|
|
@ -985,7 +974,7 @@ func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []
|
|||
}
|
||||
|
||||
if len(usersToFetch) > 0 {
|
||||
err := ml.chatView.state.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{
|
||||
err := ml.chatView.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{
|
||||
GuildIDs: []discord.GuildID{guildID},
|
||||
UserIDs: usersToFetch,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ func (v *View) OpenState(token string) error {
|
|||
}
|
||||
|
||||
v.state.OnRequest = append(v.state.OnRequest, httputil.WithHeaders(http.Headers()), v.onRequest)
|
||||
return v.state.Open(context.TODO())
|
||||
return v.state.Open(context.Background())
|
||||
}
|
||||
|
||||
func (v *View) CloseState() error {
|
||||
|
|
@ -81,69 +81,70 @@ func (v *View) onRaw(event *ws.RawEvent) {
|
|||
}
|
||||
|
||||
func (v *View) onReady(event *gateway.ReadyEvent) {
|
||||
// Rebuild indexes from scratch so reconnects and account switches do not
|
||||
// retain stale pointers to detached tree nodes.
|
||||
v.guildsTree.resetNodeIndex()
|
||||
v.app.QueueUpdateDraw(func() {
|
||||
// Rebuild indexes from scratch so reconnects and account switches do not
|
||||
// retain pointers to detached tree nodes.
|
||||
v.guildsTree.resetNodeIndex()
|
||||
|
||||
dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false)
|
||||
v.guildsTree.dmRootNode = dmNode
|
||||
dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false)
|
||||
v.guildsTree.dmRootNode = dmNode
|
||||
|
||||
root := v.guildsTree.
|
||||
GetRoot().
|
||||
ClearChildren().
|
||||
AddChild(dmNode)
|
||||
root := v.guildsTree.
|
||||
GetRoot().
|
||||
ClearChildren().
|
||||
AddChild(dmNode)
|
||||
|
||||
// Track guilds already in folders to find orphans (newly joined guilds may not be synced to GuildFolders yet but always appear in GuildPositions)
|
||||
guildsInFolders := make(map[discord.GuildID]bool)
|
||||
for _, folder := range event.UserSettings.GuildFolders {
|
||||
for _, guildID := range folder.GuildIDs {
|
||||
guildsInFolders[guildID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build index of all available guilds.
|
||||
guildsByID := make(map[discord.GuildID]*gateway.GuildCreateEvent, len(event.Guilds))
|
||||
for index := range event.Guilds {
|
||||
guildsByID[event.Guilds[index].ID] = &event.Guilds[index]
|
||||
}
|
||||
|
||||
// Use GuildPositions for ordering (it's the canonical order).
|
||||
// Guilds not in any folder are "orphans" - add them directly to root.
|
||||
positions := event.UserSettings.GuildPositions
|
||||
// Fallback: GuildPositions shouldn't be nil but handle gracefully
|
||||
if len(positions) == 0 {
|
||||
positions = make([]discord.GuildID, 0, len(event.Guilds))
|
||||
for _, guildEvent := range event.Guilds {
|
||||
positions = append(positions, guildEvent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, guildID := range positions {
|
||||
// Already handled in folder processing below
|
||||
if guildsInFolders[guildID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Orphan guild - add directly to root in order
|
||||
if guildEvent, ok := guildsByID[guildID]; ok {
|
||||
v.guildsTree.createGuildNode(root, guildEvent.Guild)
|
||||
}
|
||||
}
|
||||
|
||||
// Process folders (real folders and single-guild "folders")
|
||||
for _, folder := range event.UserSettings.GuildFolders {
|
||||
if folder.ID == 0 && len(folder.GuildIDs) == 1 {
|
||||
if guild, ok := guildsByID[folder.GuildIDs[0]]; ok {
|
||||
v.guildsTree.createGuildNode(root, guild.Guild)
|
||||
// Track guilds already in folders to find orphans (newly joined guilds may not be synced to GuildFolders yet but always appear in GuildPositions)
|
||||
guildsInFolders := make(map[discord.GuildID]bool)
|
||||
for _, folder := range event.UserSettings.GuildFolders {
|
||||
for _, guildID := range folder.GuildIDs {
|
||||
guildsInFolders[guildID] = true
|
||||
}
|
||||
} else {
|
||||
v.guildsTree.createFolderNode(folder, guildsByID)
|
||||
}
|
||||
}
|
||||
|
||||
v.guildsTree.SetCurrentNode(root)
|
||||
v.app.SetFocus(v.guildsTree)
|
||||
v.app.Draw()
|
||||
// Build index of all available guilds.
|
||||
guildsByID := make(map[discord.GuildID]*gateway.GuildCreateEvent, len(event.Guilds))
|
||||
for index := range event.Guilds {
|
||||
guildsByID[event.Guilds[index].ID] = &event.Guilds[index]
|
||||
}
|
||||
|
||||
// Use GuildPositions for ordering (it's the canonical order).
|
||||
// Guilds not in any folder are "orphans" - add them directly to root.
|
||||
positions := event.UserSettings.GuildPositions
|
||||
// Fallback: GuildPositions shouldn't be nil but handle gracefully
|
||||
if len(positions) == 0 {
|
||||
positions = make([]discord.GuildID, 0, len(event.Guilds))
|
||||
for _, guildEvent := range event.Guilds {
|
||||
positions = append(positions, guildEvent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, guildID := range positions {
|
||||
// Already handled in folder processing below
|
||||
if guildsInFolders[guildID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Orphan guild - add directly to root in order
|
||||
if guildEvent, ok := guildsByID[guildID]; ok {
|
||||
v.guildsTree.createGuildNode(root, guildEvent.Guild)
|
||||
}
|
||||
}
|
||||
|
||||
// Process folders (real folders and single-guild "folders")
|
||||
for _, folder := range event.UserSettings.GuildFolders {
|
||||
if folder.ID == 0 && len(folder.GuildIDs) == 1 {
|
||||
if guild, ok := guildsByID[folder.GuildIDs[0]]; ok {
|
||||
v.guildsTree.createGuildNode(root, guild.Guild)
|
||||
}
|
||||
} else {
|
||||
v.guildsTree.createFolderNode(folder, guildsByID)
|
||||
}
|
||||
}
|
||||
|
||||
v.guildsTree.SetCurrentNode(root)
|
||||
v.app.SetFocus(v.guildsTree)
|
||||
})
|
||||
}
|
||||
|
||||
func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ func (v *View) buildLayout() {
|
|||
|
||||
v.updateHelpHeight()
|
||||
v.AddLayer(v.rootFlex, layers.WithName(flexLayerName), layers.WithResize(true), layers.WithVisible(true))
|
||||
v.AddLayer(v.messageInput.mentionsList, layers.WithName(mentionsListLayerName), layers.WithResize(false), layers.WithVisible(false))
|
||||
}
|
||||
|
||||
func (v *View) togglePicker() {
|
||||
|
|
@ -221,57 +222,97 @@ func (v *View) focusNext() {
|
|||
}
|
||||
}
|
||||
|
||||
func (v *View) handleInput(event *tcell.EventKey) *tcell.EventKey {
|
||||
func (v *View) InputHandler(event *tcell.EventKey) tview.Command {
|
||||
consume := tview.BatchCommand{tview.RedrawCommand{}, tview.ConsumeEventCommand{}}
|
||||
|
||||
switch {
|
||||
case keybind.Matches(event, v.cfg.Keybinds.ToggleHelp.Keybind):
|
||||
v.help.SetShowAll(!v.help.ShowAll())
|
||||
v.updateHelpHeight()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, v.cfg.Keybinds.FocusGuildsTree.Keybind):
|
||||
v.messageInput.removeMentionsList()
|
||||
v.focusGuildsTree()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, v.cfg.Keybinds.FocusMessagesList.Keybind):
|
||||
v.messageInput.removeMentionsList()
|
||||
v.app.SetFocus(v.messagesList)
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, v.cfg.Keybinds.FocusMessageInput.Keybind):
|
||||
v.focusMessageInput()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, v.cfg.Keybinds.FocusPrevious.Keybind):
|
||||
v.focusPrevious()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, v.cfg.Keybinds.FocusNext.Keybind):
|
||||
v.focusNext()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, v.cfg.Keybinds.Logout.Keybind):
|
||||
if v.onLogout != nil {
|
||||
v.onLogout()
|
||||
}
|
||||
|
||||
if err := keyring.DeleteToken(); err != nil {
|
||||
slog.Error("failed to delete token from keyring", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, v.cfg.Keybinds.ToggleGuildsTree.Keybind):
|
||||
v.toggleGuildsTree()
|
||||
return nil
|
||||
return consume
|
||||
case keybind.Matches(event, v.cfg.Keybinds.ToggleChannelsPicker.Keybind):
|
||||
v.togglePicker()
|
||||
return consume
|
||||
}
|
||||
|
||||
cmd := v.Layers.InputHandler(event)
|
||||
return v.consumeLayerCommands(cmd)
|
||||
}
|
||||
|
||||
func (v *View) consumeLayerCommands(command tview.Command) tview.Command {
|
||||
if command == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
func (v *View) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
event = v.handleInput(event)
|
||||
if event == nil {
|
||||
return
|
||||
var commands []tview.Command
|
||||
switch c := command.(type) {
|
||||
case tview.BatchCommand:
|
||||
commands = c
|
||||
default:
|
||||
commands = []tview.Command{c}
|
||||
}
|
||||
v.Layers.InputHandler(event, setFocus)
|
||||
|
||||
remaining := make([]tview.Command, 0, len(commands))
|
||||
for _, cmd := range commands {
|
||||
switch c := cmd.(type) {
|
||||
case layers.OpenLayerCommand:
|
||||
if v.HasLayer(c.Name) {
|
||||
v.ShowLayer(c.Name).SendToFront(c.Name)
|
||||
}
|
||||
continue
|
||||
case layers.CloseLayerCommand:
|
||||
if v.HasLayer(c.Name) {
|
||||
v.HideLayer(c.Name)
|
||||
}
|
||||
continue
|
||||
case layers.ToggleLayerCommand:
|
||||
if v.HasLayer(c.Name) {
|
||||
if v.GetVisible(c.Name) {
|
||||
v.HideLayer(c.Name)
|
||||
} else {
|
||||
v.ShowLayer(c.Name).SendToFront(c.Name)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, cmd)
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(remaining) == 1 {
|
||||
return remaining[0]
|
||||
}
|
||||
return tview.BatchCommand(remaining)
|
||||
}
|
||||
|
||||
func (v *View) updateHelpHeight() {
|
||||
|
|
@ -311,26 +352,21 @@ func (v *View) showConfirmModal(prompt string, buttons []string, onDone func(lab
|
|||
}
|
||||
|
||||
func (v *View) onReadUpdate(event *read.UpdateEvent) {
|
||||
// Use indexed node lookup to avoid walking the whole tree on every read
|
||||
// event. This runs frequently while reading/typing across channels.
|
||||
var updated bool
|
||||
if event.GuildID.IsValid() {
|
||||
if guildNode := v.guildsTree.findNodeByReference(event.GuildID); guildNode != nil {
|
||||
v.guildsTree.setNodeLineStyle(guildNode, v.guildsTree.getGuildNodeStyle(event.GuildID))
|
||||
updated = true
|
||||
v.app.QueueUpdateDraw(func() {
|
||||
// Use indexed node lookup to avoid walking the whole tree on every read
|
||||
// event. This runs frequently while reading/typing across channels.
|
||||
if event.GuildID.IsValid() {
|
||||
if guildNode := v.guildsTree.findNodeByReference(event.GuildID); guildNode != nil {
|
||||
v.guildsTree.setNodeLineStyle(guildNode, v.guildsTree.getGuildNodeStyle(event.GuildID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel style is always updated for the target channel regardless of
|
||||
// whether it's in a guild or DM.
|
||||
if channelNode := v.guildsTree.findNodeByReference(event.ChannelID); channelNode != nil {
|
||||
v.guildsTree.setNodeLineStyle(channelNode, v.guildsTree.getChannelNodeStyle(event.ChannelID))
|
||||
updated = true
|
||||
}
|
||||
|
||||
if updated {
|
||||
v.app.Draw()
|
||||
}
|
||||
// Channel style is always updated for the target channel regardless of
|
||||
// whether it's in a guild or DM.
|
||||
if channelNode := v.guildsTree.findNodeByReference(event.ChannelID); channelNode != nil {
|
||||
v.guildsTree.setNodeLineStyle(channelNode, v.guildsTree.getChannelNodeStyle(event.ChannelID))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (v *View) clearTypers() {
|
||||
|
|
|
|||
|
|
@ -63,15 +63,15 @@ func newQRLogin(app *tview.Application, cfg *config.Config, done func(token stri
|
|||
return q
|
||||
}
|
||||
|
||||
func (q *qrLogin) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
func (q *qrLogin) InputHandler(event *tcell.EventKey) tview.Command {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
q.stop()
|
||||
if q.done != nil {
|
||||
q.done("", nil)
|
||||
}
|
||||
return
|
||||
return tview.BatchCommand{tview.RedrawCommand{}, tview.ConsumeEventCommand{}}
|
||||
}
|
||||
q.TextView.InputHandler(event, setFocus)
|
||||
return q.TextView.InputHandler(event)
|
||||
}
|
||||
|
||||
func (q *qrLogin) start() {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import (
|
|||
"github.com/gdamore/tcell/v3"
|
||||
)
|
||||
|
||||
const tokenEnvVarKey = "DISCORDO_TOKEN"
|
||||
|
||||
type View struct {
|
||||
*tview.Box
|
||||
app *tview.Application
|
||||
|
|
@ -41,7 +43,7 @@ func NewView(cfg *config.Config) *View {
|
|||
}
|
||||
|
||||
func (v *View) Run() error {
|
||||
token := os.Getenv("DISCORDO_TOKEN")
|
||||
token := os.Getenv(tokenEnvVarKey)
|
||||
if token == "" {
|
||||
t, err := keyring.GetToken()
|
||||
if err != nil {
|
||||
|
|
@ -90,7 +92,6 @@ func (v *View) showLoginView() {
|
|||
}
|
||||
})
|
||||
v.inner = loginForm
|
||||
v.MarkDirty()
|
||||
v.app.SetFocus(v)
|
||||
}
|
||||
|
||||
|
|
@ -100,16 +101,10 @@ func (v *View) showChatView(token string) error {
|
|||
return err
|
||||
}
|
||||
v.inner = v.chat
|
||||
v.MarkDirty()
|
||||
v.app.SetFocus(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *View) quit() {
|
||||
v.closeChatViewState()
|
||||
v.app.Stop()
|
||||
}
|
||||
|
||||
func (v *View) closeChatViewState() {
|
||||
if v.chat != nil {
|
||||
if err := v.chat.CloseState(); err != nil {
|
||||
|
|
@ -128,19 +123,20 @@ func (v *View) Draw(screen tcell.Screen) {
|
|||
v.inner.Draw(screen)
|
||||
}
|
||||
|
||||
func (v *View) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
func (v *View) InputHandler(event *tcell.EventKey) tview.Command {
|
||||
switch {
|
||||
case keybind.Matches(event, v.cfg.Keybinds.Suspend.Keybind):
|
||||
v.suspend()
|
||||
return
|
||||
return tview.ConsumeEventCommand{}
|
||||
case keybind.Matches(event, v.cfg.Keybinds.Quit.Keybind):
|
||||
v.quit()
|
||||
return
|
||||
v.closeChatViewState()
|
||||
return tview.QuitCommand{}
|
||||
}
|
||||
|
||||
if v.inner != nil {
|
||||
v.inner.InputHandler(event, setFocus)
|
||||
return v.inner.InputHandler(event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *View) Focus(delegate func(p tview.Primitive)) {
|
||||
|
|
@ -165,33 +161,16 @@ func (v *View) Blur() {
|
|||
v.Box.Blur()
|
||||
}
|
||||
|
||||
func (v *View) MouseHandler(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) {
|
||||
func (v *View) MouseHandler(action tview.MouseAction, event *tcell.EventMouse) (tview.Primitive, tview.Command) {
|
||||
if v.inner == nil {
|
||||
return false, nil
|
||||
return nil, nil
|
||||
}
|
||||
return v.inner.MouseHandler(action, event, setFocus)
|
||||
return v.inner.MouseHandler(action, event)
|
||||
}
|
||||
|
||||
func (v *View) PasteHandler(text string, setFocus func(p tview.Primitive)) {
|
||||
func (v *View) PasteHandler(text string) tview.Command {
|
||||
if v.inner == nil {
|
||||
return
|
||||
}
|
||||
v.inner.PasteHandler(text, setFocus)
|
||||
}
|
||||
|
||||
func (v *View) IsDirty() bool {
|
||||
if v.Box.IsDirty() {
|
||||
return true
|
||||
}
|
||||
if v.inner == nil {
|
||||
return false
|
||||
}
|
||||
return v.inner.IsDirty()
|
||||
}
|
||||
|
||||
func (v *View) MarkClean() {
|
||||
v.Box.MarkClean()
|
||||
if v.inner != nil {
|
||||
v.inner.MarkClean()
|
||||
return nil
|
||||
}
|
||||
return v.inner.PasteHandler(text)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,41 +148,33 @@ func (p *Picker) onInputChanged(text string) {
|
|||
p.setFilteredItems(fuzzied)
|
||||
}
|
||||
|
||||
func (p *Picker) handleInput(event *tcell.EventKey) *tcell.EventKey {
|
||||
if p.keyMap == nil {
|
||||
return event
|
||||
}
|
||||
func (p *Picker) InputHandler(event *tcell.EventKey) tview.Command {
|
||||
consume := tview.BatchCommand{tview.RedrawCommand{}, tview.ConsumeEventCommand{}}
|
||||
|
||||
switch {
|
||||
case keybind.Matches(event, p.keyMap.Up):
|
||||
p.list.InputHandler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil)
|
||||
return nil
|
||||
case keybind.Matches(event, p.keyMap.Down):
|
||||
p.list.InputHandler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone), nil)
|
||||
return nil
|
||||
case keybind.Matches(event, p.keyMap.Top):
|
||||
p.list.InputHandler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone), nil)
|
||||
return nil
|
||||
case keybind.Matches(event, p.keyMap.Bottom):
|
||||
p.list.InputHandler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil)
|
||||
case keybind.Matches(event, p.keyMap.Select):
|
||||
p.onListSelected(p.list.Cursor())
|
||||
return nil
|
||||
|
||||
case keybind.Matches(event, p.keyMap.Cancel):
|
||||
if p.onCancel != nil {
|
||||
p.onCancel()
|
||||
if p.keyMap != nil {
|
||||
switch {
|
||||
case keybind.Matches(event, p.keyMap.Up):
|
||||
p.list.InputHandler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone))
|
||||
return consume
|
||||
case keybind.Matches(event, p.keyMap.Down):
|
||||
p.list.InputHandler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone))
|
||||
return consume
|
||||
case keybind.Matches(event, p.keyMap.Top):
|
||||
p.list.InputHandler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone))
|
||||
return consume
|
||||
case keybind.Matches(event, p.keyMap.Bottom):
|
||||
p.list.InputHandler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone))
|
||||
return consume
|
||||
case keybind.Matches(event, p.keyMap.Select):
|
||||
p.onListSelected(p.list.Cursor())
|
||||
return consume
|
||||
case keybind.Matches(event, p.keyMap.Cancel):
|
||||
if p.onCancel != nil {
|
||||
p.onCancel()
|
||||
}
|
||||
return consume
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
func (p *Picker) InputHandler(event *tcell.EventKey, setFocus func(p2 tview.Primitive)) {
|
||||
event = p.handleInput(event)
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
p.Flex.InputHandler(event, setFocus)
|
||||
return p.Flex.InputHandler(event)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue