Quellcode durchsuchen

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

ayn2op vor 2 Monaten
Ursprung
Commit
ff6f8f9ad0

+ 1 - 3
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

+ 2 - 2
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=

+ 15 - 20
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() {

+ 54 - 61
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()
-			})
-
-			if selectedChannel := mi.chatView.SelectedChannel(); selectedChannel != nil {
-				go mi.chatView.state.Typing(selectedChannel.ID)
-			}
+		return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone))
+	}
+
+	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)
+		}
 	}
 }
 

+ 26 - 37
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,
 		})

+ 54 - 53
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()
-
-	dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false)
-	v.guildsTree.dmRootNode = 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
+	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
+
+		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)
+		// 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]
 		}
-	}
 
-	for _, guildID := range positions {
-		// Already handled in folder processing below
-		if guildsInFolders[guildID] {
-			continue
+		// 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)
+			}
 		}
 
-		// Orphan guild - add directly to root in order
-		if guildEvent, ok := guildsByID[guildID]; ok {
-			v.guildsTree.createGuildNode(root, guildEvent.Guild)
+		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)
+		// 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)
 			}
-		} else {
-			v.guildsTree.createFolderNode(folder, guildsByID)
 		}
-	}
 
-	v.guildsTree.SetCurrentNode(root)
-	v.app.SetFocus(v.guildsTree)
-	v.app.Draw()
+		v.guildsTree.SetCurrentNode(root)
+		v.app.SetFocus(v.guildsTree)
+	})
 }
 
 func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {

+ 73 - 37
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 nil
+		return consume
 	}
 
-	return event
+	cmd := v.Layers.InputHandler(event)
+	return v.consumeLayerCommands(cmd)
 }
 
-func (v *View) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
-	event = v.handleInput(event)
-	if event == nil {
-		return
+func (v *View) consumeLayerCommands(command tview.Command) tview.Command {
+	if command == nil {
+		return nil
+	}
+
+	var commands []tview.Command
+	switch c := command.(type) {
+	case tview.BatchCommand:
+		commands = c
+	default:
+		commands = []tview.Command{c}
+	}
+
+	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)
 	}
-	v.Layers.InputHandler(event, setFocus)
+
+	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() {

+ 3 - 3
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() {

+ 15 - 36
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) {
-	if v.inner == nil {
-		return false, nil
-	}
-	return v.inner.MouseHandler(action, event, setFocus)
-}
-
-func (v *View) PasteHandler(text string, setFocus func(p tview.Primitive)) {
+func (v *View) MouseHandler(action tview.MouseAction, event *tcell.EventMouse) (tview.Primitive, tview.Command) {
 	if v.inner == nil {
-		return
+		return nil, nil
 	}
-	v.inner.PasteHandler(text, setFocus)
+	return v.inner.MouseHandler(action, event)
 }
 
-func (v *View) IsDirty() bool {
-	if v.Box.IsDirty() {
-		return true
-	}
+func (v *View) PasteHandler(text string) tview.Command {
 	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)
 }

+ 26 - 34
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
-	}
-
-	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()
+func (p *Picker) InputHandler(event *tcell.EventKey) tview.Command {
+	consume := tview.BatchCommand{tview.RedrawCommand{}, tview.ConsumeEventCommand{}}
+
+	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)
 }