From f7180f87671e7d0bf7f54b7a157d8986a576ad58 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Sat, 3 Oct 2026 14:45:12 -0400 Subject: [PATCH] feat(guildstree): select the previous or next unread guild or channel Closes #665 --- internal/config/config.toml | 3 ++ internal/config/keybinds.go | 10 +++- internal/ui/chat/guildstree/keymap.go | 1 + internal/ui/chat/guildstree/model.go | 64 ++++++++++++++++++++++- internal/ui/chat/guildstree/model_test.go | 17 ++++++ 5 files changed, 92 insertions(+), 3 deletions(-) diff --git a/internal/config/config.toml b/internal/config/config.toml index def33e5..a208554 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -175,6 +175,9 @@ select_top = "g" select_bottom = "G" # Select the currently highlighted text-based channel or expand a guild or channel. select_current = "enter" +# Highlight the previous or next unread guild or channel, wrapping around. +select_previous_unread = "K" +select_next_unread = "J" yank_id = "i" collapse_all = "_" collapse_parent_node = "-" diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 646258b..45f0a95 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -72,6 +72,12 @@ type TreeKeybinds struct { MoveToParentNode Keybind `toml:"move_to_parent_node"` } +type GuildsTreeKeybinds struct { + TreeKeybinds + SelectPreviousUnread Keybind `toml:"select_previous_unread"` + SelectNextUnread Keybind `toml:"select_next_unread"` +} + type MessagesListKeybinds struct { SelectionKeybinds ScrollKeybinds @@ -130,7 +136,7 @@ type Keybinds struct { FocusNext Keybind `toml:"focus_next"` Picker PickerKeybinds `toml:"picker"` - GuildsTree TreeKeybinds `toml:"guilds_tree"` + GuildsTree GuildsTreeKeybinds `toml:"guilds_tree"` MembersTree TreeKeybinds `toml:"members_tree"` MessagesList MessagesListKeybinds `toml:"messages_list"` Composer ComposerKeybinds `toml:"composer"` @@ -234,7 +240,7 @@ func defaultKeybinds() Keybinds { Quit: desc("quit"), Picker: defaultPickerKeybinds(), - GuildsTree: defaultTreeKeybinds(), + GuildsTree: GuildsTreeKeybinds{TreeKeybinds: defaultTreeKeybinds(), SelectPreviousUnread: desc("prev unread"), SelectNextUnread: desc("next unread")}, MembersTree: defaultTreeKeybinds(), MessagesList: defaultMessagesListKeybinds(), Composer: defaultComposerKeybinds(), diff --git a/internal/ui/chat/guildstree/keymap.go b/internal/ui/chat/guildstree/keymap.go index 42eb761..7f2dbc1 100644 --- a/internal/ui/chat/guildstree/keymap.go +++ b/internal/ui/chat/guildstree/keymap.go @@ -49,6 +49,7 @@ func (m Model) FullHelp() [][]keybind.Keybind { return [][]keybind.Keybind{ {cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind}, selectGroup, + {cfg.SelectPreviousUnread.Keybind, cfg.SelectNextUnread.Keybind}, {cfg.YankID.Keybind}, } } diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go index 7d57f93..2096f60 100644 --- a/internal/ui/chat/guildstree/model.go +++ b/internal/ui/chat/guildstree/model.go @@ -50,7 +50,16 @@ func (m *Model) CurrentNode() *tree.Node { func (m Model) View(focused bool) tview.Element { onChange := func(c tree.Change) tview.Msg { return Msg(c) } onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} } - return uitree.New(m.root, m.selectionState, m.cfg, m.cfg.Theme.GuildsTree.CommonTreeTheme, m.cfg.Keybinds.GuildsTree, focused, onChange, onSelect).Title("Guilds") + return uitree.New( + m.root, + m.selectionState, + m.cfg, + m.cfg.Theme.GuildsTree.CommonTreeTheme, + m.cfg.Keybinds.GuildsTree.TreeKeybinds, + focused, + onChange, + onSelect, + ).Title("Guilds") } func (m *Model) reset() *tree.Node { @@ -332,6 +341,20 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind): m.collapseParentNode(m.selectionState.CurrentNode()) return nil + case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.SelectPreviousUnread.Keybind, m.cfg.Keybinds.GuildsTree.SelectNextUnread.Keybind): + previous := keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.SelectPreviousUnread.Keybind) + if node := adjacentNode(m.root, m.selectionState.CurrentNode(), m.isUnread, previous); node != nil { + // A guild or the direct messages without channels yet: load them and move on to the unread one. + if _, ok := node.Reference().(discord.ChannelID); !ok { + m.selectNode(node) + if channel := adjacentNode(node, node, m.isUnread, previous); channel != nil { + node = channel + } + } + m.expandPathToNode(node) + m.selectionState.SetCurrentNode(node) + } + return nil case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind): return uitree.YankID(m.selectionState.CurrentNode()) } @@ -339,6 +362,45 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { return nil } +// isUnread reports whether node is an unread channel, or a guild or the direct messages whose channels are not shown yet and has one. +func (m *Model) isUnread(node *tree.Node) bool { + if len(node.Children()) > 0 { + return false + } + opts := ningen.UnreadOpts{IncludeMutedCategories: true} + switch ref := node.Reference().(type) { + case discord.GuildID: + return m.state.GuildIsUnread(ref, ningen.GuildUnreadOpts{UnreadOpts: opts}) != ningen.ChannelRead + case discord.ChannelID: + return m.state.ChannelIsUnread(ref, opts) != ningen.ChannelRead + case dmNode: + channels, _ := m.state.PrivateChannels() + return slices.ContainsFunc(channels, func(c discord.Channel) bool { return m.state.ChannelIsUnread(c.ID, opts) != ningen.ChannelRead }) + } + return false +} + +// adjacentNode returns the nearest node under root matching match after current, or before it if previous, wrapping around, or nil if no other node matches. +func adjacentNode(root, current *tree.Node, match func(*tree.Node) bool, previous bool) *tree.Node { + var nodes []*tree.Node + after := 0 + root.Walk(func(node, _ *tree.Node) bool { + if node == current { + after = len(nodes) + } else if match(node) { + nodes = append(nodes, node) + } + return true + }) + if len(nodes) == 0 { + return nil + } + if previous { + after += len(nodes) - 1 + } + return nodes[after%len(nodes)] +} + func (m *Model) findNodeByReference(reference any) *tree.Node { switch ref := reference.(type) { case discord.GuildID: diff --git a/internal/ui/chat/guildstree/model_test.go b/internal/ui/chat/guildstree/model_test.go index e66da11..9d0fa05 100644 --- a/internal/ui/chat/guildstree/model_test.go +++ b/internal/ui/chat/guildstree/model_test.go @@ -52,3 +52,20 @@ func TestModelMoveDMToFront(t *testing.T) { } }) } + +func TestAdjacentNode(t *testing.T) { + a, b, c := tree.NewNode("a"), tree.NewNode("b"), tree.NewNode("c") + root := tree.NewNode("").SetChildren([]*tree.Node{a, tree.NewNode("guild").AddChild(b), c}) + match := func(n *tree.Node) bool { return n == a || n == b } + for current, want := range map[*tree.Node][2]*tree.Node{nil: {a, b}, a: {b, b}, b: {a, a}, c: {a, b}} { + if got := adjacentNode(root, current, match, false); got != want[0] { + t.Errorf("after %v = %v, want %v", current, got, want[0]) + } + if got := adjacentNode(root, current, match, true); got != want[1] { + t.Errorf("before %v = %v, want %v", current, got, want[1]) + } + } + if got := adjacentNode(root, a, func(*tree.Node) bool { return false }, false); got != nil { + t.Errorf("got %v, want nil", got) + } +}