refactor(ui): adopt command API (#762)

This commit is contained in:
ayn2op 2026-03-01 01:47:25 -05:00 • committed by GitHub
parent 5ed32db323
commit ff6f8f9ad0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 273 additions and 290 deletions

4
go.mod
View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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