Procházet zdrojové kódy

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

Ayyan před 3 měsíci
rodič
revize
3a4fd37bf6

binární
.github/preview.png


+ 6 - 0
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"`

+ 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.
 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]"

+ 15 - 0
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"`

+ 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
 
 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()
+	}
+}

+ 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) {
-	dmNode := tview.NewTreeNode("Direct Messages")
+	dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{})
 	root := v.guildsTree.
 		GetRoot().
 		ClearChildren().

+ 28 - 3
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

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

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