refactor(ui): move focus ownership to containers (#834)

This commit is contained in:
ayn2op 2026-08-29 01:10:17 -04:00 • committed by GitHub
parent fb39147eae
commit 811c5e4418
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 145 additions and 128 deletions

View file

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

2
go.mod
View file

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

4
go.sum
View file

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

View file

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

View file

@ -340,10 +340,7 @@ func (gt *guildsTree) collapseParentNode(node *tree.Node) {
}
func (gt *guildsTree) Update(msg tview.Msg) tview.Cmd {
ui.UpdateBoxFocus(gt.Box, &gt.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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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