From 811c5e441896b11a39df7a08de6f94fbbdf381b5 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Sat, 29 Aug 2026 01:10:17 -0400 Subject: [PATCH] refactor(ui): move focus ownership to containers (#834) --- cmd/root.go | 3 +- go.mod | 2 +- go.sum | 4 +- internal/ui/chat/composer.go | 7 +- internal/ui/chat/guilds_tree.go | 3 - internal/ui/chat/keybinds.go | 4 +- internal/ui/chat/messages_list.go | 11 +-- internal/ui/chat/model.go | 147 ++++++++++++++++++++---------- internal/ui/chat/msg.go | 4 - internal/ui/chat/state.go | 3 +- internal/ui/root/model.go | 56 ++++-------- internal/ui/util.go | 29 +++--- 12 files changed, 145 insertions(+), 128 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 0f5a5db..d88e9e1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,5 +64,6 @@ func Run() error { screen.EnablePaste() tview.Styles = tview.Theme{} - return tview.NewApplication(root.NewModel(cfg), tview.WithScreen(screen)).Run() + model := root.NewModel(cfg) + return tview.NewApplication(model, tview.WithScreen(screen)).Run() } diff --git a/go.mod b/go.mod index 6413f13..9a96d81 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/andybalholm/brotli v1.2.3 github.com/ayn2op/arikawa/v3 v3.0.0-20260827004545-b90631eb8a21 github.com/ayn2op/ningen/v3 v3.0.1-0.20260827010304-ee3a9ce6c2e4 - github.com/ayn2op/tview v0.0.0-20260827231920-c0e5ff669a2c + github.com/ayn2op/tview v0.0.0-20260829040318-2c40c4a50e56 github.com/bogdanfinn/fhttp v0.6.8 github.com/bogdanfinn/tls-client v1.15.1 github.com/gdamore/tcell/v3 v3.4.2 diff --git a/go.sum b/go.sum index a67d8c4..ae97536 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20260827004545-b90631eb8a21 h1:+6W+QtSmIV8Dr github.com/ayn2op/arikawa/v3 v3.0.0-20260827004545-b90631eb8a21/go.mod h1:C7NGoHHt0MQH/plbA9ly4YUQwG0v+n0XdgXmdEPoRpU= github.com/ayn2op/ningen/v3 v3.0.1-0.20260827010304-ee3a9ce6c2e4 h1:Jl3hKkjANCCGPHTyrxLqy+FVdvdftGY41E5f4/1YrRA= github.com/ayn2op/ningen/v3 v3.0.1-0.20260827010304-ee3a9ce6c2e4/go.mod h1:p+rsTJpVomHc8gyyLY0/Wwzj9qsPKoXgwCg2yIlG04A= -github.com/ayn2op/tview v0.0.0-20260827231920-c0e5ff669a2c h1:zh00g4QZXpXQCU/GTE+V47P1uwJOYU+CbH9nguFznJM= -github.com/ayn2op/tview v0.0.0-20260827231920-c0e5ff669a2c/go.mod h1:iFzkW7DBltCaeLMR9SvXVUAw/zgKIbgE8JaUU8W8crk= +github.com/ayn2op/tview v0.0.0-20260829040318-2c40c4a50e56 h1:0kerJdw1SWXBafEtkRAZbEdPYxWtq+c0N1HnLTcwqsE= +github.com/ayn2op/tview v0.0.0-20260829040318-2c40c4a50e56/go.mod h1:iFzkW7DBltCaeLMR9SvXVUAw/zgKIbgE8JaUU8W8crk= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= diff --git a/internal/ui/chat/composer.go b/internal/ui/chat/composer.go index 4f4b47b..95d090c 100644 --- a/internal/ui/chat/composer.go +++ b/internal/ui/chat/composer.go @@ -155,10 +155,7 @@ func (c *composer) SetFooter(footer string) *tview.Box { } func (c *composer) Update(msg tview.Msg) tview.Cmd { - ui.UpdateBoxFocus(c.Box, &c.cfg.Theme, msg) switch msg := msg.(type) { - case tview.FocusMsg: - return tview.Sequence(c.TextArea.Update(msg), focused(c)) case tabSuggestMsg: return c.tabSuggest() case imagePastedMsg: @@ -689,7 +686,7 @@ func (c *composer) showMentionsList() tview.Cmd { l.SetRect(x, y, w, h) c.chat.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName) - return tview.SetFocus(c) + return nil } func (c *composer) addMentionMember(gID discord.GuildID, m *discord.Member) bool { @@ -759,7 +756,7 @@ func (c *composer) stopTabCompletion() tview.Cmd { if c.cfg.AutocompleteLimit > 0 { c.mentionsList.Clear() c.removeMentionsList() - return tview.SetFocus(c) + return nil } return nil } diff --git a/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go index 19b54ee..8ef14ce 100644 --- a/internal/ui/chat/guilds_tree.go +++ b/internal/ui/chat/guilds_tree.go @@ -340,10 +340,7 @@ func (gt *guildsTree) collapseParentNode(node *tree.Node) { } func (gt *guildsTree) Update(msg tview.Msg) tview.Cmd { - ui.UpdateBoxFocus(gt.Box, >.cfg.Theme, msg) switch msg := msg.(type) { - case tview.FocusMsg: - return tview.Sequence(gt.Model.Update(msg), focused(gt)) case tree.SelectedMsg: return gt.onSelected(msg.Node) case tview.KeyMsg: diff --git a/internal/ui/chat/keybinds.go b/internal/ui/chat/keybinds.go index 812b87d..22e2cbb 100644 --- a/internal/ui/chat/keybinds.go +++ b/internal/ui/chat/keybinds.go @@ -33,7 +33,7 @@ func (m *Model) activeKeyMap() help.KeyMap { return m.messagesList.attachmentsPicker } - switch m.focused { + switch m.focusedModel() { case m.guildsTree: return m.guildsTree case m.messagesList: @@ -64,7 +64,7 @@ func (m *Model) baseFullHelp() [][]keybind.Keybind { func (m *Model) focusHelp() []keybind.Keybind { kbs := m.cfg.Keybinds - focused := m.focused + focused := m.focusedModel() focusKbs := make([]keybind.Keybind, 0, 3) if focused != m.guildsTree { diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index d271715..141a2a3 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -847,10 +847,7 @@ func (ml *messagesList) selectedMessage() (*discord.Message, bool) { } func (ml *messagesList) Update(msg tview.Msg) tview.Cmd { - ui.UpdateBoxFocus(ml.Box, &ml.cfg.Theme, msg) switch msg := msg.(type) { - case tview.FocusMsg: - return tview.Sequence(ml.Model.Update(msg), focused(ml)) case tview.KeyMsg: switch { case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Cancel.Keybind): @@ -1183,7 +1180,7 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord layers.WithOverlay(), ). SendToFront(attachmentsPickerLayerName) - return tview.SetFocus(ml.attachmentsPicker) + return nil } func openAttachment(attachment discord.Attachment) tview.Cmd { @@ -1260,7 +1257,8 @@ func (ml *messagesList) reply(mention bool) tview.Cmd { ml.chat.composer.sendMessageData = data ml.chat.composer.SetTitle(title + name) - return tview.SetFocus(ml.chat.composer) + ml.chat.focusComposer() + return nil } func (ml *messagesList) editSelectedMessage() tview.Cmd { @@ -1277,7 +1275,8 @@ func (ml *messagesList) editSelectedMessage() tview.Cmd { ml.chat.composer.SetTitle("Editing") ml.chat.composer.edit = true ml.chat.composer.SetText(selectedMessage.Content, true) - return tview.SetFocus(ml.chat.composer) + ml.chat.focusComposer() + return nil } func (ml *messagesList) confirmDelete() tview.Cmd { diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index 2657381..485b2dd 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -51,7 +51,6 @@ type Model struct { messagesList *messagesList composer *composer channelsPicker *channelspicker.Model - focused tview.Model selectedChannel *discord.Channel selectedChannelMu sync.RWMutex @@ -170,18 +169,18 @@ func (m *Model) openPicker() tview.Cmd { layers.WithOverlay(), ).SendToFront(channelsPickerLayerName) m.channelsPicker.RefreshChannels(m.state) - return tview.SetFocus(m.channelsPicker) + return nil } func (m *Model) closePicker() tview.Cmd { m.RemoveLayer(channelsPickerLayerName) m.channelsPicker.Refresh() - return tview.SetFocus(m.mainFlex) + return nil } func (m *Model) closeAttachmentsPicker() tview.Cmd { m.RemoveLayer(attachmentsPickerLayerName) - return tview.SetFocus(m.messagesList) + return nil } func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd { @@ -209,76 +208,73 @@ func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd { func (m *Model) toggleGuildsTree() tview.Cmd { if m.mainFlex.GetItemCount() == 2 { m.mainFlex.RemoveItem(m.guildsTree) - if m.guildsTree.HasFocus() { - return tview.SetFocus(m.mainFlex) + if m.focusedModel() == m.guildsTree { + m.setFocus(m.mainFlex) } } else { m.buildLayout() - return tview.SetFocus(m.guildsTree) + m.setFocus(m.guildsTree) } return nil } -func (m *Model) focusGuildsTree() tview.Cmd { +func (m *Model) focusGuildsTree() bool { if m.mainFlex.GetItemCount() == 2 { - return tview.SetFocus(m.guildsTree) + m.setFocus(m.guildsTree) + return true } - return nil + return false } -func (m *Model) focusComposer() tview.Cmd { +func (m *Model) focusComposer() bool { if !m.composer.Disabled() { - return tview.SetFocus(m.composer) + m.setFocus(m.composer) + return true } - return nil + return false } -func (m *Model) focusPrevious() tview.Cmd { - switch m.focused { +func (m *Model) focusPrevious() { + switch m.focusedModel() { case m.guildsTree: - if cmd := m.focusComposer(); cmd != nil { - return cmd + if m.focusComposer() { + return } - return tview.SetFocus(m.messagesList) + m.setFocus(m.messagesList) case m.messagesList: - if cmd := m.focusGuildsTree(); cmd != nil { - return cmd + if m.focusGuildsTree() { + return } - if cmd := m.focusComposer(); cmd != nil { - return cmd + if m.focusComposer() { + return } - return tview.SetFocus(m.messagesList) + m.setFocus(m.messagesList) case m.composer: - return tview.SetFocus(m.messagesList) + m.setFocus(m.messagesList) } - return nil } -func (m *Model) focusNext() tview.Cmd { - switch m.focused { +func (m *Model) focusNext() { + switch m.focusedModel() { case m.guildsTree: - return tview.SetFocus(m.messagesList) + m.setFocus(m.messagesList) case m.messagesList: - if cmd := m.focusComposer(); cmd != nil { - return cmd + if m.focusComposer() { + return } - if cmd := m.focusGuildsTree(); cmd != nil { - return cmd + if m.focusGuildsTree() { + return } case m.composer: - if cmd := m.focusGuildsTree(); cmd != nil { - return cmd + if m.focusGuildsTree() { + return } - return tview.SetFocus(m.messagesList) + m.setFocus(m.messagesList) } - return nil } func (m *Model) Update(msg tview.Msg) tview.Cmd { switch msg := msg.(type) { - case FocusedMsg: - m.focused = msg.Model - return nil case tview.InitMsg: return tview.Batch(openState(m.state), listen(m.events)) case gateway.Event: @@ -346,18 +342,16 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { m.composer.SetDisabled(hasNoPerm) text := "Message..." - var focusCmd tview.Cmd - if hasNoPerm { text = "You do not have permission to send messages in this channel." } else if m.cfg.AutoFocus { - focusCmd = m.focusComposer() + m.focusComposer() } m.composer.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true)))) if msg.Channel.GuildID.IsValid() { - return tview.Batch(focusCmd, m.messagesList.requestGuildMembers(msg.Channel.GuildID, msg.Messages)) + return m.messagesList.requestGuildMembers(msg.Channel.GuildID, msg.Messages) } - return focusCmd + return nil case deleteMessageMsg: return m.messagesList.deleteMessageRequest(discord.Message(msg)) case channelspicker.SelectedMsg: @@ -374,17 +368,22 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { switch { case keybind.Matches(msg, m.cfg.Keybinds.FocusGuildsTree.Keybind): m.composer.removeMentionsList() - return m.focusGuildsTree() + m.focusGuildsTree() + return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusMessagesList.Keybind): m.composer.removeMentionsList() - return tview.SetFocus(m.messagesList) + m.setFocus(m.messagesList) + return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusComposer.Keybind): - return m.focusComposer() + m.focusComposer() + return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusPrevious.Keybind): - return m.focusPrevious() + m.focusPrevious() + return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusNext.Keybind): - return m.focusNext() + m.focusNext() + return nil case keybind.Matches(msg, m.cfg.Keybinds.ToggleGuildsTree.Keybind): return m.toggleGuildsTree() @@ -400,6 +399,56 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { return m.Layers.Update(msg) } +func (m *Model) setFocus(target tview.Model) { + if target == nil || target == m.focusedModel() { + return + } + switch target { + case m.guildsTree: + m.mainFlex.SetFocus(0) + case m.messagesList: + m.rightFlex.SetFocus(0) + m.mainFlex.SetFocus(1) + case m.composer: + m.rightFlex.SetFocus(1) + m.mainFlex.SetFocus(1) + case m.mainFlex: + m.mainFlex.SetFocus(-1) + } +} + +func (m *Model) View(screen tcell.Screen) { + ui.BlurBox(m.guildsTree.Box, &m.cfg.Theme) + ui.BlurBox(m.messagesList.Box, &m.cfg.Theme) + ui.BlurBox(m.composer.Box, &m.cfg.Theme) + if !m.GetVisible(channelsPickerLayerName) && !m.GetVisible(attachmentsPickerLayerName) { + switch m.focusedModel() { + case m.guildsTree: + ui.FocusBox(m.guildsTree.Box, &m.cfg.Theme) + case m.messagesList: + ui.FocusBox(m.messagesList.Box, &m.cfg.Theme) + case m.composer: + ui.FocusBox(m.composer.Box, &m.cfg.Theme) + } + } + m.Layers.View(screen) +} + +func (m *Model) focusedModel() tview.Model { + if m.mainFlex.Focused() == 0 { + return m.guildsTree + } + if m.mainFlex.Focused() == 1 { + if m.rightFlex.Focused() == 0 { + return m.messagesList + } + if m.rightFlex.Focused() == 1 { + return m.composer + } + } + return m.mainFlex +} + func (m *Model) clearTypers() { m.typersMu.Lock() for _, timer := range m.typers { diff --git a/internal/ui/chat/msg.go b/internal/ui/chat/msg.go index ebc962f..1f12330 100644 --- a/internal/ui/chat/msg.go +++ b/internal/ui/chat/msg.go @@ -59,7 +59,3 @@ func logout() tview.Cmd { } type QuitMsg struct{} - -type FocusedMsg struct{ Model tview.Model } - -func focused(model tview.Model) tview.Cmd { return func() tview.Msg { return FocusedMsg{model} } } diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index e973f77..b843e65 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -91,7 +91,8 @@ func (m *Model) onReady(event *gateway.ReadyEvent) tview.Cmd { } m.guildsTree.SetCurrentNode(root) - return tview.SetFocus(m.guildsTree) + m.setFocus(m.guildsTree) + return nil } func (m *Model) onMessageCreate(message *gateway.MessageCreateEvent) tview.Cmd { diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go index 455940d..5f24399 100644 --- a/internal/ui/root/model.go +++ b/internal/ui/root/model.go @@ -33,11 +33,8 @@ type Model struct { inner tview.Model help *help.Model - modalRequest *ui.ModalMsg - modalDialog *modal.Model - modalPreviousFocus tview.Model - focused tview.Model - cfg *config.Config + modal *ui.ModalMsg + cfg *config.Config } func NewModel(cfg *config.Config) *Model { @@ -74,21 +71,18 @@ func newHelpModel(cfg *config.Config, keyMap help.KeyMap) *help.Model { func (m *Model) showLogin() tview.Cmd { m.inner = login.NewModel(m.cfg) m.buildLayout() - return tview.Batch(m.inner.Update(tview.InitMsg{}), tview.SetFocus(m)) + return m.inner.Update(tview.InitMsg{}) } func (m *Model) showChat(token string) tview.Cmd { m.inner = chat.NewModel(m.cfg, token) m.buildLayout() - return tview.Batch(m.inner.Update(tview.InitMsg{}), tview.SetFocus(m)) + return m.inner.Update(tview.InitMsg{}) } func (m *Model) buildLayout() { m.Clear() - m.modalRequest = nil - m.modalDialog = nil - m.modalPreviousFocus = nil - m.focused = nil + m.modal = nil m.rootFlex.Clear() if m.inner != nil { m.rootFlex.AddItem(m.inner, 0, 1, true) @@ -103,8 +97,6 @@ var _ tview.Model = (*Model)(nil) func (m *Model) Update(msg tview.Msg) tview.Cmd { switch msg := msg.(type) { - case chat.FocusedMsg: - m.focused = msg.Model case tview.InitMsg: var cmd tview.Cmd if token := os.Getenv(tokenEnvVarKey); token != "" { @@ -141,10 +133,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { return m.finishModal(msg) case tview.KeyMsg: - if m.modalRequest != nil { - if !m.modalDialog.HasFocus() { - return tview.Sequence(tview.SetFocus(m.modalDialog), func() tview.Msg { return msg }) - } + if m.modal != nil { return m.Layers.Update(msg) } switch { @@ -168,7 +157,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { return tview.Sequence(innerCmd, tview.Quit()) } case tview.MouseMsg, tview.PasteMsg, tview.FormSubmitMsg, tview.FormCancelMsg: - if m.modalRequest != nil { + if m.modal != nil { return m.Layers.Update(msg) } } @@ -193,7 +182,7 @@ func (m *Model) toggleHelp() { } func (m *Model) showModal(request ui.ModalMsg) tview.Cmd { - if m.modalRequest != nil { + if m.modal != nil { return nil } @@ -211,30 +200,25 @@ func (m *Model) showModal(request ui.ModalMsg) tview.Cmd { } dialog.SetButtonStyle(style).SetButtonActivatedStyle(style.Reverse(true)) - m.modalRequest = &request - m.modalDialog = dialog - m.modalPreviousFocus = m.focused - if m.modalPreviousFocus == nil { - m.modalPreviousFocus = m.inner - } + m.modal = &request m.AddLayer(dialog, layers.WithName(modalLayerName), layers.WithResize(true), layers.WithVisible(true), layers.WithOverlay(), ) - return tview.SetFocus(dialog) + return nil } func (m *Model) finishModal(done modal.DoneMsg) tview.Cmd { - request := m.modalRequest - if request == nil { + state := m.modal + if state == nil { return nil } var result tview.Msg - if done.ButtonIndex >= 0 && done.ButtonIndex < len(request.Buttons) { - button := request.Buttons[done.ButtonIndex] + if done.ButtonIndex >= 0 && done.ButtonIndex < len(state.Buttons) { + button := state.Buttons[done.ButtonIndex] if button.KeepOpen { return func() tview.Msg { return button.Result } } @@ -242,17 +226,11 @@ func (m *Model) finishModal(done modal.DoneMsg) tview.Cmd { } m.RemoveLayer(modalLayerName) - var focus tview.Cmd - if m.modalPreviousFocus != nil { - focus = tview.SetFocus(m.modalPreviousFocus) - } - m.modalRequest = nil - m.modalDialog = nil - m.modalPreviousFocus = nil + m.modal = nil if result == nil { - return focus + return nil } - return tview.Sequence(focus, func() tview.Msg { return result }) + return func() tview.Msg { return result } } func (m *Model) helpHeight() int { diff --git a/internal/ui/util.go b/internal/ui/util.go index 4ecaf28..dffc118 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -16,7 +16,7 @@ import ( // ConfigureBox configures the provided box according to the provided theme. func ConfigureBox(box *tview.Box, cfg *config.Theme) { padding := cfg.Border.Padding - UpdateBoxFocus(box, cfg, tview.BlurMsg{}) + BlurBox(box, cfg) box. SetBorderPadding(padding[0], padding[1], padding[2], padding[3]). SetTitleAlignment(cfg.Title.Alignment.Alignment). @@ -27,25 +27,24 @@ func ConfigureBox(box *tview.Box, cfg *config.Theme) { } } -func UpdateBoxFocus(box *tview.Box, cfg *config.Theme, msg tview.Msg) { - switch msg.(type) { - case tview.FocusMsg: - box.SetBorderStyle(cfg.Border.ActiveStyle.Style). - SetBorderSet(cfg.Border.ActiveSet.BorderSet). - SetTitleStyle(cfg.Title.ActiveStyle.Style). - SetFooterStyle(cfg.Footer.ActiveStyle.Style) - case tview.BlurMsg: - box.SetBorderStyle(cfg.Border.NormalStyle.Style). - SetBorderSet(cfg.Border.NormalSet.BorderSet). - SetTitleStyle(cfg.Title.NormalStyle.Style). - SetFooterStyle(cfg.Footer.NormalStyle.Style) - } +func FocusBox(box *tview.Box, cfg *config.Theme) { + box.SetBorderStyle(cfg.Border.ActiveStyle.Style). + SetBorderSet(cfg.Border.ActiveSet.BorderSet). + SetTitleStyle(cfg.Title.ActiveStyle.Style). + SetFooterStyle(cfg.Footer.ActiveStyle.Style) +} + +func BlurBox(box *tview.Box, cfg *config.Theme) { + box.SetBorderStyle(cfg.Border.NormalStyle.Style). + SetBorderSet(cfg.Border.NormalSet.BorderSet). + SetTitleStyle(cfg.Title.NormalStyle.Style). + SetFooterStyle(cfg.Footer.NormalStyle.Style) } func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) { model.Box = tview.NewBox() ConfigureBox(model.Box, &cfg.Theme) - UpdateBoxFocus(model.Box, &cfg.Theme, tview.FocusMsg{}) + FocusBox(model.Box, &cfg.Theme) model.SetTitle(title) model.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility)