diff --git a/go.mod b/go.mod index 60922eb..4a7ce9f 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 104faf1..e9b3dbf 100644 --- a/go.sum +++ b/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= diff --git a/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go index 6978fcb..78a083c 100644 --- a/internal/ui/chat/guilds_tree.go +++ b/internal/ui/chat/guilds_tree.go @@ -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() { diff --git a/internal/ui/chat/message_input.go b/internal/ui/chat/message_input.go index 58a765a..4ae1ce7 100644 --- a/internal/ui/chat/message_input.go +++ b/internal/ui/chat/message_input.go @@ -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) + } } } diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 4a2d07c..2ca1d50 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -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, }) diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index aa97397..7b241e4 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -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) { diff --git a/internal/ui/chat/view.go b/internal/ui/chat/view.go index 4df7b2b..1dea66d 100644 --- a/internal/ui/chat/view.go +++ b/internal/ui/chat/view.go @@ -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() { diff --git a/internal/ui/login/qr.go b/internal/ui/login/qr.go index c024bbc..d998c9b 100644 --- a/internal/ui/login/qr.go +++ b/internal/ui/login/qr.go @@ -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() { diff --git a/internal/ui/root/view.go b/internal/ui/root/view.go index ea049af..23ba0a7 100644 --- a/internal/ui/root/view.go +++ b/internal/ui/root/view.go @@ -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) } diff --git a/pkg/picker/picker.go b/pkg/picker/picker.go index f4ab511..31f8f89 100644 --- a/pkg/picker/picker.go +++ b/pkg/picker/picker.go @@ -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) }