feat(guildstree): select the previous or next unread guild or channel

Closes #665
This commit is contained in:
ayn2op 2026-10-03 14:45:12 -04:00
parent 0b68476086
commit f7180f8767
5 changed files with 92 additions and 3 deletions

View file

@ -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 = "-"

View file

@ -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(),

View file

@ -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},
}
}

View file

@ -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:

View file

@ -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)
}
}