diff --git a/.github/preview.png b/.github/preview.png index 1e00919..2ac1586 100644 Binary files a/.github/preview.png and b/.github/preview.png differ diff --git a/internal/config/config.go b/internal/config/config.go index 666835c..26e7c49 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/config.toml b/internal/config/config.toml index f6cf1f7..7aeb0ad 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -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]" diff --git a/internal/config/keys.go b/internal/config/keys.go index 1031a17..4c1e586 100644 --- a/internal/config/keys.go +++ b/internal/config/keys.go @@ -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"` diff --git a/internal/ui/chat/channels_picker.go b/internal/ui/chat/channels_picker.go new file mode 100644 index 0000000..a249e9b --- /dev/null +++ b/internal/ui/chat/channels_picker.go @@ -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}) +} diff --git a/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go index 0f2727f..95269ff 100644 --- a/internal/ui/chat/guilds_tree.go +++ b/internal/ui/chat/guilds_tree.go @@ -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() + } +} diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index 0b6da2b..23815af 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -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(). diff --git a/internal/ui/chat/view.go b/internal/ui/chat/view.go index 9c73fd4..5bd478d 100644 --- a/internal/ui/chat/view.go +++ b/internal/ui/chat/view.go @@ -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 diff --git a/internal/ui/util.go b/internal/ui/util.go index 88f0865..1f62b3e 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -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) +} diff --git a/pkg/picker/item.go b/pkg/picker/item.go new file mode 100644 index 0000000..1369210 --- /dev/null +++ b/pkg/picker/item.go @@ -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) +} diff --git a/pkg/picker/keymap.go b/pkg/picker/keymap.go new file mode 100644 index 0000000..fe23a58 --- /dev/null +++ b/pkg/picker/keymap.go @@ -0,0 +1,11 @@ +package picker + +type KeyMap struct { + Cancel string + + Up string + Down string + Top string + Bottom string + Select string +} diff --git a/pkg/picker/picker.go b/pkg/picker/picker.go new file mode 100644 index 0000000..588dff4 --- /dev/null +++ b/pkg/picker/picker.go @@ -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 +}