فهرست منبع

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

Ayyan 3 ماه پیش
والد
کامیت
3a4fd37bf6

BIN
.github/preview.png


+ 6 - 0
internal/config/config.go

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

+ 13 - 0
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.
 # 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
 messages_limit = 50
 
 
+[picker]
+width = 60
+height = 20
+
 [timestamps]
 [timestamps]
 enabled = true
 enabled = true
 # https://pkg.go.dev/time#Layout
 # https://pkg.go.dev/time#Layout
@@ -69,6 +73,15 @@ quit = "Ctrl+C"
 # Requires re-login upon restart.
 # Requires re-login upon restart.
 logout = "Ctrl+D"
 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
 # Only while focusing on the guilds tree
 [keys.guilds_tree]
 [keys.guilds_tree]
 select_previous = "Rune[k]"
 select_previous = "Rune[k]"

+ 15 - 0
internal/config/keys.go

@@ -53,6 +53,20 @@ type (
 		SelectionKeys
 		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 {
 	Keys struct {
 		FocusGuildsTree   string `toml:"focus_guilds_tree"`
 		FocusGuildsTree   string `toml:"focus_guilds_tree"`
 		FocusMessagesList string `toml:"focus_messages_list"`
 		FocusMessagesList string `toml:"focus_messages_list"`
@@ -61,6 +75,7 @@ type (
 		FocusNext         string `toml:"focus_next"`
 		FocusNext         string `toml:"focus_next"`
 		ToggleGuildsTree  string `toml:"toggle_guilds_tree"`
 		ToggleGuildsTree  string `toml:"toggle_guilds_tree"`
 
 
+		Picker       PickerKeys       `toml:"picker"`
 		GuildsTree   GuildsTreeKeys   `toml:"guilds_tree"`
 		GuildsTree   GuildsTreeKeys   `toml:"guilds_tree"`
 		MessagesList MessagesListKeys `toml:"messages_list"`
 		MessagesList MessagesListKeys `toml:"messages_list"`
 		MessageInput MessageInputKeys `toml:"message_input"`
 		MessageInput MessageInputKeys `toml:"message_input"`

+ 118 - 0
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})
+}

+ 51 - 24
internal/ui/chat/guilds_tree.go

@@ -1,10 +1,8 @@
 package chat
 package chat
 
 
 import (
 import (
-	"cmp"
 	"fmt"
 	"fmt"
 	"log/slog"
 	"log/slog"
-	"slices"
 
 
 	"github.com/ayn2op/discordo/internal/clipboard"
 	"github.com/ayn2op/discordo/internal/clipboard"
 	"github.com/ayn2op/discordo/internal/config"
 	"github.com/ayn2op/discordo/internal/config"
@@ -16,6 +14,8 @@ import (
 	"github.com/gdamore/tcell/v3"
 	"github.com/gdamore/tcell/v3"
 )
 )
 
 
+type dmNode struct{}
+
 type guildsTree struct {
 type guildsTree struct {
 	*tview.TreeView
 	*tview.TreeView
 	cfg      *config.Config
 	cfg      *config.Config
@@ -159,15 +159,12 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 			return
 			return
 		}
 		}
 
 
-		slices.SortFunc(channels, func(a, b discord.Channel) int {
-			return cmp.Compare(a.Position, b.Position)
-		})
-
+		ui.SortGuildChannels(channels)
 		gt.createChannelNodes(node, channels)
 		gt.createChannelNodes(node, channels)
 	case discord.ChannelID:
 	case discord.ChannelID:
 		channel, err := gt.chatView.state.Cabinet.Channel(ref)
 		channel, err := gt.chatView.state.Cabinet.Channel(ref)
 		if err != nil {
 		if err != nil {
-			slog.Error("failed to get channel", "channel_id", ref)
+			slog.Error("failed to get channel from state", "channel_id", ref)
 			return
 			return
 		}
 		}
 
 
@@ -194,9 +191,6 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 			for _, thread := range forumThreads {
 			for _, thread := range forumThreads {
 				gt.createChannelNode(node, thread)
 				gt.createChannelNode(node, thread)
 			}
 			}
-
-			// Expand the node to show threads
-			node.SetExpanded(true)
 			return
 			return
 		}
 		}
 
 
@@ -232,26 +226,14 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 				gt.chatView.app.SetFocus(gt.chatView.messageInput)
 				gt.chatView.app.SetFocus(gt.chatView.messageInput)
 			}
 			}
 		}
 		}
-
-	case nil: // Direct messages folder
+	case dmNode: // Direct messages folder
 		channels, err := gt.chatView.state.PrivateChannels()
 		channels, err := gt.chatView.state.PrivateChannels()
 		if err != nil {
 		if err != nil {
 			slog.Error("failed to get private channels", "err", err)
 			slog.Error("failed to get private channels", "err", err)
 			return
 			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 {
 		for _, c := range channels {
 			gt.createChannelNode(node, c)
 			gt.createChannelNode(node, c)
 		}
 		}
@@ -313,3 +295,48 @@ func (gt *guildsTree) yankID() {
 		go clipboard.Write(clipboard.FmtText, []byte(id.String()))
 		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()
+	}
+}

+ 1 - 1
internal/ui/chat/state.go

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

+ 28 - 3
internal/ui/chat/view.go

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

+ 22 - 0
internal/ui/util.go

@@ -1,6 +1,8 @@
 package ui
 package ui
 
 
 import (
 import (
+	"cmp"
+	"slices"
 	"strings"
 	"strings"
 
 
 	"github.com/ayn2op/discordo/internal/config"
 	"github.com/ayn2op/discordo/internal/config"
@@ -99,3 +101,23 @@ func ChannelToString(channel discord.Channel, icons config.Icons) string {
 
 
 	return icon + channel.Name
 	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 - 0
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)
+}

+ 11 - 0
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
+}

+ 153 - 0
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
+}