From 7abc3e4003a8d92482f4d3f0922a07b8cb8a1ba6 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Wed, 5 Aug 2026 00:50:58 -0400 Subject: [PATCH] refactor(chat): extract channels picker package --- internal/ui/chat/attachments_picker.go | 3 +- internal/ui/chat/channels_picker.go | 125 ---------------------- internal/ui/chat/channelspicker/keymap.go | 21 ++++ internal/ui/chat/channelspicker/model.go | 91 ++++++++++++++++ internal/ui/chat/channelspicker/msg.go | 9 ++ internal/ui/chat/composer.go | 14 +++ internal/ui/chat/model.go | 34 +++++- internal/ui/chat/util.go | 56 ---------- internal/ui/util.go | 33 ++++++ 9 files changed, 201 insertions(+), 185 deletions(-) delete mode 100644 internal/ui/chat/channels_picker.go create mode 100644 internal/ui/chat/channelspicker/keymap.go create mode 100644 internal/ui/chat/channelspicker/model.go create mode 100644 internal/ui/chat/channelspicker/msg.go delete mode 100644 internal/ui/chat/util.go diff --git a/internal/ui/chat/attachments_picker.go b/internal/ui/chat/attachments_picker.go index b659827..42c9f34 100644 --- a/internal/ui/chat/attachments_picker.go +++ b/internal/ui/chat/attachments_picker.go @@ -2,6 +2,7 @@ package chat import ( "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" "github.com/ayn2op/tview/help" "github.com/ayn2op/tview/keybind" @@ -24,7 +25,7 @@ var _ help.KeyMap = (*attachmentsPicker)(nil) func newAttachmentsPicker(cfg *config.Config, chat *Model) *attachmentsPicker { ap := &attachmentsPicker{Model: picker.NewModel(), cfg: cfg, chat: chat} - ConfigurePicker(ap.Model, cfg, "Attachments") + ui.ConfigurePicker(ap.Model, cfg, "Attachments") return ap } diff --git a/internal/ui/chat/channels_picker.go b/internal/ui/chat/channels_picker.go deleted file mode 100644 index 6ef0d8e..0000000 --- a/internal/ui/chat/channels_picker.go +++ /dev/null @@ -1,125 +0,0 @@ -package chat - -import ( - "log/slog" - "strings" - - "github.com/ayn2op/arikawa/v3/discord" - "github.com/ayn2op/discordo/internal/config" - "github.com/ayn2op/discordo/internal/ui" - "github.com/ayn2op/tview" - "github.com/ayn2op/tview/help" - "github.com/ayn2op/tview/keybind" - "github.com/ayn2op/tview/picker" -) - -type channelsPicker struct { - *picker.Model - chat *Model - cfg *config.Config -} - -var _ help.KeyMap = (*channelsPicker)(nil) - -func newChannelsPicker(cfg *config.Config, chat *Model) *channelsPicker { - cp := &channelsPicker{Model: picker.NewModel(), chat: chat, cfg: cfg} - ConfigurePicker(cp.Model, cfg, "Channels") - return cp -} - -func (cp *channelsPicker) Update(msg tview.Msg) tview.Cmd { - switch msg := msg.(type) { - case picker.SelectedMsg: - channelID, ok := msg.Reference.(discord.ChannelID) - if !ok || !channelID.IsValid() { - return nil - } - - channel, err := cp.chat.state.Cabinet.Channel(channelID) - if err != nil { - slog.Error("failed to get channel from state", "err", err, "channel_id", channelID) - return nil - } - - node := cp.chat.guildsTree.findNodeByChannelID(channel.ID) - if node == nil { - slog.Error("failed to locate channel in tree", "channel_id", channel.ID) - return nil - } - - cp.chat.guildsTree.expandPathToNode(node) - cp.chat.guildsTree.SetCurrentNode(node) - var selectCmd tview.Cmd - if channel.Type != discord.GuildCategory { - selectCmd = cp.chat.guildsTree.onSelected(node) - } - cp.chat.closePicker() - return selectCmd - case picker.CancelMsg: - cp.chat.closePicker() - return nil - } - return cp.Model.Update(msg) -} - -func (cp *channelsPicker) update() { - var items picker.Items - state := cp.chat.state - - privateChannels, err := state.Cabinet.PrivateChannels() - if err != nil { - slog.Error("failed to get private channels from state", "err", err) - return - } - - ui.SortPrivateChannels(privateChannels) - for _, channel := range privateChannels { - items = append(items, cp.channelItem(nil, channel)) - } - - guilds, err := state.Cabinet.Guilds() - if err != nil { - slog.Error("failed to get guilds from state", "err", err) - return - } - - for _, guild := range guilds { - channels, err := state.Cabinet.Channels(guild.ID) - if err != nil { - slog.Error("failed to get channels from state", "err", err, "guild_id", guild.ID) - continue - } - - for _, channel := range channels { - items = append(items, cp.channelItem(&guild, channel)) - } - } - - cp.SetItems(items) -} - -func (cp *channelsPicker) channelItem(guild *discord.Guild, channel discord.Channel) picker.Item { - var b strings.Builder - b.WriteString(ui.ChannelToString(channel, cp.cfg.Icons, cp.chat.state)) - - if guild != nil { - b.WriteString(" - ") - b.WriteString(guild.Name) - } - - name := b.String() - return picker.Item{Text: name, FilterText: name, Reference: channel.ID} -} - -func (cp *channelsPicker) ShortHelp() []keybind.Keybind { - cfg := cp.cfg.Keybinds.Picker - return []keybind.Keybind{cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind} -} - -func (cp *channelsPicker) FullHelp() [][]keybind.Keybind { - cfg := cp.cfg.Keybinds.Picker - return [][]keybind.Keybind{ - {cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind}, - {cfg.Select.Keybind, cfg.Cancel.Keybind}, - } -} diff --git a/internal/ui/chat/channelspicker/keymap.go b/internal/ui/chat/channelspicker/keymap.go new file mode 100644 index 0000000..4aabfcc --- /dev/null +++ b/internal/ui/chat/channelspicker/keymap.go @@ -0,0 +1,21 @@ +package channelspicker + +import ( + "github.com/ayn2op/tview/help" + "github.com/ayn2op/tview/keybind" +) + +var _ help.KeyMap = (*Model)(nil) + +func (m *Model) ShortHelp() []keybind.Keybind { + cfg := m.cfg.Keybinds.Picker + return []keybind.Keybind{cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind} +} + +func (m *Model) FullHelp() [][]keybind.Keybind { + cfg := m.cfg.Keybinds.Picker + return [][]keybind.Keybind{ + {cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind}, + {cfg.Select.Keybind, cfg.Cancel.Keybind}, + } +} diff --git a/internal/ui/chat/channelspicker/model.go b/internal/ui/chat/channelspicker/model.go new file mode 100644 index 0000000..e25e1b6 --- /dev/null +++ b/internal/ui/chat/channelspicker/model.go @@ -0,0 +1,91 @@ +package channelspicker + +import ( + "log/slog" + "strings" + + "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/ningen/v3" + "github.com/ayn2op/tview" + "github.com/ayn2op/tview/picker" +) + +type Model struct { + *picker.Model + cfg *config.Config +} + +func NewModel(cfg *config.Config) *Model { + p := picker.NewModel() + ui.ConfigurePicker(p, cfg, "Channels") + return &Model{ + Model: p, + cfg: cfg, + } +} + +var _ tview.Model = (*Model)(nil) + +func (m *Model) Update(msg tview.Msg) tview.Cmd { + switch msg := msg.(type) { + case picker.SelectedMsg: + channelID, ok := msg.Reference.(discord.ChannelID) + if !ok || !channelID.IsValid() { + return nil + } + return func() tview.Msg { return SelectedMsg{ChannelID: channelID} } + case picker.CancelMsg: + return func() tview.Msg { return CancelMsg{} } + } + return m.Model.Update(msg) +} + +func (m *Model) RefreshChannels(state *ningen.State) { + var items picker.Items + + privateChannels, err := state.Cabinet.PrivateChannels() + if err != nil { + slog.Error("failed to get private channels from state", "err", err) + return + } + + ui.SortPrivateChannels(privateChannels) + for _, channel := range privateChannels { + items = append(items, m.channelItem(state, nil, channel)) + } + + guilds, err := state.Cabinet.Guilds() + if err != nil { + slog.Error("failed to get guilds from state", "err", err) + return + } + + for _, guild := range guilds { + channels, err := state.Cabinet.Channels(guild.ID) + if err != nil { + slog.Error("failed to get channels from state", "err", err, "guild_id", guild.ID) + continue + } + + for _, channel := range channels { + items = append(items, m.channelItem(state, &guild, channel)) + } + } + + m.SetItems(items) +} + +func (m *Model) channelItem(state *ningen.State, guild *discord.Guild, channel discord.Channel) picker.Item { + var b strings.Builder + b.WriteString(ui.ChannelToString(channel, m.cfg.Icons, state)) + + if guild != nil { + b.WriteString(" - ") + b.WriteString(guild.Name) + } + + name := b.String() + return picker.Item{Text: name, FilterText: name, Reference: channel.ID} +} diff --git a/internal/ui/chat/channelspicker/msg.go b/internal/ui/chat/channelspicker/msg.go new file mode 100644 index 0000000..4ffa18d --- /dev/null +++ b/internal/ui/chat/channelspicker/msg.go @@ -0,0 +1,9 @@ +package channelspicker + +import "github.com/ayn2op/arikawa/v3/discord" + +type SelectedMsg struct { + ChannelID discord.ChannelID +} + +type CancelMsg struct{} diff --git a/internal/ui/chat/composer.go b/internal/ui/chat/composer.go index 1c4ddf2..b587b9f 100644 --- a/internal/ui/chat/composer.go +++ b/internal/ui/chat/composer.go @@ -872,3 +872,17 @@ func (c *composer) FullHelp() [][]keybind.Keybind { openEditor, } } + +func humanJoin(items []string) string { + count := len(items) + switch count { + case 0: + return "" + case 1: + return items[0] + case 2: + return items[0] + " and " + items[1] + default: + return strings.Join(items[:count-1], ", ") + ", and " + items[count-1] + } +} diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index d9f32b0..ea2b989 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -18,6 +18,7 @@ import ( clientgateway "github.com/ayn2op/discordo/internal/gateway" "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/discordo/internal/ui/chat/channelspicker" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3/states/read" "github.com/ayn2op/tview" @@ -48,7 +49,7 @@ type Model struct { guildsTree *guildsTree messagesList *messagesList composer *composer - channelsPicker *channelsPicker + channelsPicker *channelspicker.Model selectedChannel *discord.Channel selectedChannelMu sync.RWMutex @@ -79,7 +80,7 @@ func NewModel(app *tview.Application, cfg *config.Config, token string) *Model { m.guildsTree = newGuildsTree(cfg, m) m.messagesList = newMessagesList(cfg, m) m.composer = newComposer(cfg, m) - m.channelsPicker = newChannelsPicker(cfg, m) + m.channelsPicker = channelspicker.NewModel(cfg) id := gateway.NewIdentifier(gateway.IdentifyCommand{ Token: token, @@ -161,7 +162,7 @@ func (m *Model) openPicker() { layers.WithVisible(true), layers.WithOverlay(), ).SendToFront(channelsPickerLayerName) - m.channelsPicker.update() + m.channelsPicker.RefreshChannels(m.state) } func (m *Model) closePicker() { @@ -169,6 +170,28 @@ func (m *Model) closePicker() { m.channelsPicker.Refresh() } +func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd { + channel, err := m.state.Cabinet.Channel(channelID) + if err != nil { + slog.Error("failed to get channel from state", "err", err, "channel_id", channelID) + return nil + } + + node := m.guildsTree.findNodeByChannelID(channel.ID) + if node == nil { + slog.Error("failed to locate channel in tree", "channel_id", channel.ID) + return nil + } + + m.guildsTree.expandPathToNode(node) + m.guildsTree.SetCurrentNode(node) + m.closePicker() + if channel.Type != discord.GuildCategory { + return m.guildsTree.onSelected(node) + } + return nil +} + func (m *Model) toggleGuildsTree() tview.Cmd { // The guilds tree is visible if the number of items is two. if m.mainFlex.GetItemCount() == 2 { @@ -326,6 +349,11 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { return focusCmd case deleteMessageMsg: return m.messagesList.deleteMessageRequest(discord.Message(msg)) + case channelspicker.SelectedMsg: + return m.navigateToChannel(msg.ChannelID) + case channelspicker.CancelMsg: + m.closePicker() + return nil case QuitMsg: return m.closeState() case tview.KeyMsg: diff --git a/internal/ui/chat/util.go b/internal/ui/chat/util.go deleted file mode 100644 index c956ff1..0000000 --- a/internal/ui/chat/util.go +++ /dev/null @@ -1,56 +0,0 @@ -package chat - -import ( - "strings" - - "github.com/ayn2op/discordo/internal/config" - "github.com/ayn2op/discordo/internal/ui" - "github.com/ayn2op/tview" - "github.com/ayn2op/tview/list" - "github.com/ayn2op/tview/picker" -) - -func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) { - model.Box = ui.ConfigureBox(tview.NewBox(), &cfg.Theme) - // When a child of the parent flex is focused, the parent layout itself is not reported as focused. - // Instead, the focused child (picker) is considered focused. - // Therefore, we manually set the active border style on the picker to ensure it displays the correct focused appearance. - model. - SetBlurFunc(nil). - SetFocusFunc(nil). - SetBorderSet(cfg.Theme.Border.ActiveSet.BorderSet). - SetBorderStyle(cfg.Theme.Border.ActiveStyle.Style). - SetTitleStyle(cfg.Theme.Title.ActiveStyle.Style). - SetFooterStyle(cfg.Theme.Footer.ActiveStyle.Style) - - model.SetTitle(title) - model.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility) - model.SetScrollBar(tview.NewScrollBar(). - SetTrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style). - SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style). - SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet)) - model.SetKeybinds(picker.Keybinds{ - Cancel: cfg.Keybinds.Picker.Cancel.Keybind, - Keybinds: list.Keybinds{ - SelectUp: cfg.Keybinds.Picker.SelectUp.Keybind, - SelectDown: cfg.Keybinds.Picker.SelectDown.Keybind, - SelectTop: cfg.Keybinds.Picker.SelectTop.Keybind, - SelectBottom: cfg.Keybinds.Picker.SelectBottom.Keybind, - }, - Select: cfg.Keybinds.Picker.Select.Keybind, - }) -} - -func humanJoin(items []string) string { - count := len(items) - switch count { - case 0: - return "" - case 1: - return items[0] - case 2: - return items[0] + " and " + items[1] - default: - return strings.Join(items[:count-1], ", ") + ", and " + items[count-1] - } -} diff --git a/internal/ui/util.go b/internal/ui/util.go index 8035a91..4e0f3ee 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -10,6 +10,8 @@ import ( "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" "github.com/ayn2op/tview/grid" + "github.com/ayn2op/tview/list" + "github.com/ayn2op/tview/picker" ) // ConfigureBox configures the provided box according to the provided theme. @@ -54,6 +56,37 @@ func ConfigureBox(box *tview.Box, cfg *config.Theme) *tview.Box { return box } +func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) { + model.Box = ConfigureBox(tview.NewBox(), &cfg.Theme) + // When a child of the parent flex is focused, the parent layout itself is not reported as focused. + // Instead, the focused child (picker) is considered focused. + // Therefore, we manually set the active border style on the picker to ensure it displays the correct focused appearance. + model. + SetBlurFunc(nil). + SetFocusFunc(nil). + SetBorderSet(cfg.Theme.Border.ActiveSet.BorderSet). + SetBorderStyle(cfg.Theme.Border.ActiveStyle.Style). + SetTitleStyle(cfg.Theme.Title.ActiveStyle.Style). + SetFooterStyle(cfg.Theme.Footer.ActiveStyle.Style) + + model.SetTitle(title) + model.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility) + model.SetScrollBar(tview.NewScrollBar(). + SetTrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style). + SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style). + SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet)) + model.SetKeybinds(picker.Keybinds{ + Cancel: cfg.Keybinds.Picker.Cancel.Keybind, + Keybinds: list.Keybinds{ + SelectUp: cfg.Keybinds.Picker.SelectUp.Keybind, + SelectDown: cfg.Keybinds.Picker.SelectDown.Keybind, + SelectTop: cfg.Keybinds.Picker.SelectTop.Keybind, + SelectBottom: cfg.Keybinds.Picker.SelectBottom.Keybind, + }, + Select: cfg.Keybinds.Picker.Select.Keybind, + }) +} + // Centered creates a new grid with provided primitive aligned in the center. func Centered(m tview.Model, width, height int) tview.Model { return grid.NewModel().