feat(ui/chat): add channels picker (quickswitcher) (#723)

This commit is contained in:
Ayyan 2026-01-22 23:20:26 -05:00 • committed by GitHub
parent 314bad64cf
commit 3a4fd37bf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 435 additions and 28 deletions

BIN
.github/preview.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -51,6 +51,11 @@ type (
GuildStore string `toml:"guild_store"`
}
PickerConfig struct {
Width int `toml:"width"`
Height int `toml:"height"`
}
Config struct {
AutoFocus bool `toml:"auto_focus"`
Mouse bool `toml:"mouse"`
@ -66,6 +71,7 @@ type (
AutocompleteLimit uint8 `toml:"autocomplete_limit"`
MessagesLimit uint8 `toml:"messages_limit"`
Picker PickerConfig `toml:"picker"`
Timestamps Timestamps `toml:"timestamps"`
Notifications Notifications `toml:"notifications"`
TypingIndicator TypingIndicator `toml:"typing_indicator"`

View file

@ -22,6 +22,10 @@ autocomplete_limit = 20
# The number of messages to fetch when a text-based channel is selected from guilds tree. The minimum and maximum value is 1 and 100, respectively.
messages_limit = 50
[picker]
width = 60
height = 20
[timestamps]
enabled = true
# https://pkg.go.dev/time#Layout
@ -69,6 +73,15 @@ quit = "Ctrl+C"
# Requires re-login upon restart.
logout = "Ctrl+D"
[keys.picker]
toggle = "Ctrl+K"
cancel = "Esc"
up = "Ctrl+P"
down = "Ctrl+N"
top = "Home"
bottom = "End"
select = "Enter"
# Only while focusing on the guilds tree
[keys.guilds_tree]
select_previous = "Rune[k]"

View file

@ -53,6 +53,20 @@ type (
SelectionKeys
}
NavigationKeys struct {
Up string `toml:"up"`
Down string `toml:"down"`
Top string `toml:"top"`
Bottom string `toml:"bottom"`
}
PickerKeys struct {
Toggle string `toml:"toggle"`
Cancel string `toml:"cancel"`
NavigationKeys
Select string `toml:"select"`
}
Keys struct {
FocusGuildsTree string `toml:"focus_guilds_tree"`
FocusMessagesList string `toml:"focus_messages_list"`
@ -61,6 +75,7 @@ type (
FocusNext string `toml:"focus_next"`
ToggleGuildsTree string `toml:"toggle_guilds_tree"`
Picker PickerKeys `toml:"picker"`
GuildsTree GuildsTreeKeys `toml:"guilds_tree"`
MessagesList MessagesListKeys `toml:"messages_list"`
MessageInput MessageInputKeys `toml:"message_input"`

View file

@ -0,0 +1,118 @@
package chat
import (
"log/slog"
"strings"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/pkg/picker"
"github.com/ayn2op/tview"
"github.com/diamondburned/arikawa/v3/discord"
)
type channelsPicker struct {
*picker.Picker
chatView *View
}
func newChannelsPicker(cfg *config.Config, chatView *View) *channelsPicker {
cp := &channelsPicker{picker.New(), chatView}
cp.Box = ui.ConfigureBox(tview.NewBox(), &cfg.Theme)
// When a child of tview.Flex is focused, tview.Flex 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.
cp.
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)
cp.SetSelectedFunc(cp.onSelected)
cp.SetTitle("Channels")
cp.SetKeyMap(&picker.KeyMap{
Cancel: cfg.Keys.Picker.Cancel,
Up: cfg.Keys.Picker.Up,
Down: cfg.Keys.Picker.Down,
Top: cfg.Keys.Picker.Top,
Bottom: cfg.Keys.Picker.Bottom,
Select: cfg.Keys.Picker.Select,
})
return cp
}
func (cp *channelsPicker) onSelected(item picker.Item) {
channelID, ok := item.Reference.(discord.ChannelID)
if !ok || !channelID.IsValid() {
return
}
channel, err := cp.chatView.state.Cabinet.Channel(channelID)
if err != nil {
slog.Error("failed to get channel from state", "err", err, "channel_id", channelID)
return
}
node := cp.chatView.guildsTree.findNodeByChannelID(channel.ID)
if node == nil {
slog.Error("failed to locate channel in tree", "channel_id", channel.ID)
return
}
cp.chatView.guildsTree.expandPathToNode(node)
cp.chatView.guildsTree.SetCurrentNode(node)
if channel.Type != discord.GuildCategory {
cp.chatView.guildsTree.onSelected(node)
}
cp.chatView.closePicker()
cp.chatView.focusMessageInput()
}
func (cp *channelsPicker) update() {
cp.ClearItems()
state := cp.chatView.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 {
cp.addChannel(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 {
cp.addChannel(&guild, channel)
}
}
cp.Update()
}
func (cp *channelsPicker) addChannel(guild *discord.Guild, channel discord.Channel) {
var b strings.Builder
b.WriteString(ui.ChannelToString(channel, cp.chatView.cfg.Icons))
if guild != nil {
b.WriteString(" - ")
b.WriteString(guild.Name)
}
name := b.String()
cp.AddItem(picker.Item{Text: name, FilterText: name, Reference: channel.ID})
}

View file

@ -1,10 +1,8 @@
package chat
import (
"cmp"
"fmt"
"log/slog"
"slices"
"github.com/ayn2op/discordo/internal/clipboard"
"github.com/ayn2op/discordo/internal/config"
@ -16,6 +14,8 @@ import (
"github.com/gdamore/tcell/v3"
)
type dmNode struct{}
type guildsTree struct {
*tview.TreeView
cfg *config.Config
@ -159,15 +159,12 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
return
}
slices.SortFunc(channels, func(a, b discord.Channel) int {
return cmp.Compare(a.Position, b.Position)
})
ui.SortGuildChannels(channels)
gt.createChannelNodes(node, channels)
case discord.ChannelID:
channel, err := gt.chatView.state.Cabinet.Channel(ref)
if err != nil {
slog.Error("failed to get channel", "channel_id", ref)
slog.Error("failed to get channel from state", "channel_id", ref)
return
}
@ -194,9 +191,6 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
for _, thread := range forumThreads {
gt.createChannelNode(node, thread)
}
// Expand the node to show threads
node.SetExpanded(true)
return
}
@ -232,26 +226,14 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
gt.chatView.app.SetFocus(gt.chatView.messageInput)
}
}
case nil: // Direct messages folder
case dmNode: // Direct messages folder
channels, err := gt.chatView.state.PrivateChannels()
if err != nil {
slog.Error("failed to get private channels", "err", err)
return
}
msgID := func(ch discord.Channel) discord.MessageID {
if ch.LastMessageID.IsValid() {
return ch.LastMessageID
}
return discord.MessageID(ch.ID)
}
slices.SortFunc(channels, func(a, b discord.Channel) int {
// Descending order
return cmp.Compare(msgID(b), msgID(a))
})
ui.SortPrivateChannels(channels)
for _, c := range channels {
gt.createChannelNode(node, c)
}
@ -313,3 +295,48 @@ func (gt *guildsTree) yankID() {
go clipboard.Write(clipboard.FmtText, []byte(id.String()))
}
}
func (gt *guildsTree) findNodeByReference(reference any) *tview.TreeNode {
var found *tview.TreeNode
gt.GetRoot().Walk(func(node, _ *tview.TreeNode) bool {
if node.GetReference() == reference {
found = node
return false
}
return true
})
return found
}
func (gt *guildsTree) findNodeByChannelID(channelID discord.ChannelID) *tview.TreeNode {
channel, err := gt.chatView.state.Cabinet.Channel(channelID)
if err != nil {
slog.Error("failed to get channel", "channel_id", channelID, "err", err)
return nil
}
var reference any
if guildID := channel.GuildID; guildID.IsValid() {
reference = guildID
} else {
reference = dmNode{}
}
if parentNode := gt.findNodeByReference(reference); parentNode != nil {
if len(parentNode.GetChildren()) == 0 {
gt.onSelected(parentNode)
}
}
node := gt.findNodeByReference(channelID)
return node
}
func (gt *guildsTree) expandPathToNode(node *tview.TreeNode) {
if node == nil {
return
}
for _, n := range gt.GetPath(node) {
n.Expand()
}
}

View file

@ -81,7 +81,7 @@ func (v *View) onRaw(event *ws.RawEvent) {
}
func (v *View) onReady(r *gateway.ReadyEvent) {
dmNode := tview.NewTreeNode("Direct Messages")
dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{})
root := v.guildsTree.
GetRoot().
ClearChildren().

View file

@ -23,6 +23,7 @@ const (
mentionsListPageName = "mentionsList"
attachmentsListPageName = "attachmentsList"
confirmModalPageName = "confirmModal"
channelsPickerPageName = "channelsPicker"
)
type View struct {
@ -31,9 +32,10 @@ type View struct {
mainFlex *tview.Flex
rightFlex *tview.Flex
guildsTree *guildsTree
messagesList *messagesList
messageInput *messageInput
guildsTree *guildsTree
messagesList *messagesList
messageInput *messageInput
channelsPicker *channelsPicker
selectedChannel *discord.Channel
selectedChannelMu sync.RWMutex
@ -64,6 +66,8 @@ func NewView(app *tview.Application, cfg *config.Config, onLogout func()) *View
v.guildsTree = newGuildsTree(cfg, v)
v.messagesList = newMessagesList(cfg, v)
v.messageInput = newMessageInput(cfg, v)
v.channelsPicker = newChannelsPicker(cfg, v)
v.channelsPicker.SetCancelFunc(v.closePicker)
v.SetInputCapture(v.onInputCapture)
v.buildLayout()
@ -99,6 +103,24 @@ func (v *View) buildLayout() {
v.AddAndSwitchToPage(flexPageName, v.mainFlex, true)
}
func (v *View) togglePicker() {
if v.HasPage(channelsPickerPageName) {
v.closePicker()
} else {
v.openPicker()
}
}
func (v *View) openPicker() {
v.AddAndSwitchToPage(channelsPickerPageName, ui.Centered(v.channelsPicker, v.cfg.Picker.Width, v.cfg.Picker.Height), true).ShowPage(flexPageName)
v.channelsPicker.update()
}
func (v *View) closePicker() {
v.RemovePage(channelsPickerPageName).SwitchToPage(flexPageName)
v.channelsPicker.Update()
}
func (v *View) toggleGuildsTree() {
// The guilds tree is visible if the number of items is two.
if v.mainFlex.GetItemCount() == 2 {
@ -190,6 +212,9 @@ func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
case v.cfg.Keys.ToggleGuildsTree:
v.toggleGuildsTree()
return nil
case v.cfg.Keys.Picker.Toggle:
v.togglePicker()
return nil
}
return event

View file

@ -1,6 +1,8 @@
package ui
import (
"cmp"
"slices"
"strings"
"github.com/ayn2op/discordo/internal/config"
@ -99,3 +101,23 @@ func ChannelToString(channel discord.Channel, icons config.Icons) string {
return icon + channel.Name
}
func SortGuildChannels(channels []discord.Channel) {
slices.SortFunc(channels, func(a, b discord.Channel) int {
return cmp.Compare(a.Position, b.Position)
})
}
func SortPrivateChannels(channels []discord.Channel) {
slices.SortFunc(channels, func(a, b discord.Channel) int {
// Descending order
return cmp.Compare(getMessageIDFromChannel(b), getMessageIDFromChannel(a))
})
}
func getMessageIDFromChannel(channel discord.Channel) discord.MessageID {
if channel.LastMessageID.IsValid() {
return channel.LastMessageID
}
return discord.MessageID(channel.ID)
}

17
pkg/picker/item.go Normal file
View file

@ -0,0 +1,17 @@
package picker
type Item struct {
Text string
FilterText string
Reference any
}
type Items []Item
func (is Items) String(index int) string {
return is[index].FilterText
}
func (is Items) Len() int {
return len(is)
}

11
pkg/picker/keymap.go Normal file
View file

@ -0,0 +1,11 @@
package picker
type KeyMap struct {
Cancel string
Up string
Down string
Top string
Bottom string
Select string
}

153
pkg/picker/picker.go Normal file
View file

@ -0,0 +1,153 @@
package picker
import (
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
"github.com/sahilm/fuzzy"
)
type (
SelectedFunc func(item Item)
CancelFunc func()
)
type Picker struct {
*tview.Flex
input *tview.InputField
list *tview.List
onSelected SelectedFunc
onCancel CancelFunc
keyMap *KeyMap
items Items
filtered Items
}
func New() *Picker {
p := &Picker{
Flex: tview.NewFlex(),
input: tview.NewInputField(),
list: tview.NewList(),
}
// Show a horizontal bottom border to visually separate input from list.
borderSet := tview.BorderSet{
Bottom: tview.BoxDrawingsLightHorizontal,
}
borderSet.BottomLeft = borderSet.Bottom
borderSet.BottomRight = borderSet.Bottom
p.input.
SetChangedFunc(p.onInputChanged).
SetLabel("> ").
SetBorders(tview.BordersBottom).
SetBorderSet(borderSet).
SetBorderStyle(tcell.StyleDefault.Dim(true)).
SetInputCapture(p.onInputCapture)
p.list.
SetSelectedFunc(p.onListSelected).
ShowSecondaryText(false).
SetHighlightFullLine(true)
p.
SetDirection(tview.FlexRow).
// bottom border + value
AddItem(p.input, 2, 0, true).
AddItem(p.list, 0, 1, false)
p.Update()
return p
}
func (p *Picker) SetKeyMap(keyMap *KeyMap) {
p.keyMap = keyMap
}
func (p *Picker) SetSelectedFunc(onSelected SelectedFunc) {
p.onSelected = onSelected
}
func (p *Picker) SetCancelFunc(onCancel CancelFunc) {
p.onCancel = onCancel
}
func (p *Picker) ClearInput() {
p.input.SetText("")
}
func (p *Picker) ClearList() {
p.list.Clear()
}
func (p *Picker) ClearItems() {
p.items = nil
p.filtered = nil
}
func (p *Picker) AddItem(item Item) {
p.items = append(p.items, item)
}
func (p *Picker) Update() {
p.ClearInput()
p.onInputChanged("")
}
func (p *Picker) onListSelected(index int, text, _ string, _ rune) {
if p.onSelected != nil {
if index >= 0 && index < len(p.filtered) {
item := p.filtered[index]
p.onSelected(item)
}
}
}
func (p *Picker) onInputChanged(text string) {
var fuzzied Items
if text == "" {
fuzzied = append(fuzzied, p.items...)
} else {
matches := fuzzy.FindFrom(text, p.items)
for _, match := range matches {
fuzzied = append(fuzzied, p.items[match.Index])
}
}
p.filtered = fuzzied
p.ClearList()
for _, item := range fuzzied {
p.list.AddItem(item.Text, "", 0, nil)
}
}
func (p *Picker) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
if p.keyMap == nil {
return nil
}
handler := p.list.InputHandler()
switch event.Name() {
case p.keyMap.Up:
handler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil)
return nil
case p.keyMap.Down:
handler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone), nil)
return nil
case p.keyMap.Top:
handler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone), nil)
return nil
case p.keyMap.Bottom:
handler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil)
case p.keyMap.Select:
handler(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone), nil)
case p.keyMap.Cancel:
if p.onCancel != nil {
p.onCancel()
}
return nil
}
return event
}