refactor(chat): extract channels picker package

This commit is contained in:
ayn2op 2026-08-05 00:50:58 -04:00
parent c6ca02d8ae
commit 7abc3e4003
9 changed files with 201 additions and 185 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
package channelspicker
import "github.com/ayn2op/arikawa/v3/discord"
type SelectedMsg struct {
ChannelID discord.ChannelID
}
type CancelMsg struct{}

View file

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

View file

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

View file

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

View file

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