feat(chat): add members tree
This commit is contained in:
parent
05e622e753
commit
08f27eb60d
14 changed files with 718 additions and 92 deletions
2
go.mod
2
go.mod
|
|
@ -14,7 +14,7 @@ require (
|
|||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/andybalholm/brotli v1.2.5
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20260928035447-6eac31cb96a7
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928043244-6cde42c5dfdd
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928223257-36eef7b8a0bc
|
||||
github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0
|
||||
github.com/bogdanfinn/fhttp v0.6.9
|
||||
github.com/bogdanfinn/tls-client v1.16.0
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -12,10 +12,8 @@ github.com/andybalholm/brotli v1.2.5 h1:BSI8V4zmx/3BAn6OKjF1PmfVq7Aoi52AdFsi6bpC
|
|||
github.com/andybalholm/brotli v1.2.5/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20260928035447-6eac31cb96a7 h1:7N2ZeNeqFrGPUHUmTZ7654JMPE3thYd2WOeWckIbPsQ=
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20260928035447-6eac31cb96a7/go.mod h1:SHqcr+EmZKeNtVKZybHJn4HNz+ufA1XbeKNSTigBCEE=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928043244-6cde42c5dfdd h1:TvGZslFOdrfWW005pcNKjUXRGuR+XC04Us55O/8lr0I=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928043244-6cde42c5dfdd/go.mod h1:c3qDZseL0znZUq68SygG1TsILXyGMKLSyKyO5XjTDBM=
|
||||
github.com/ayn2op/tview v0.0.0-20260928003911-758a51d24cad h1:NzrmJFrWVtRVY8u+Q9HUv0ow9dvlu4s+YdrqEnh4yGM=
|
||||
github.com/ayn2op/tview v0.0.0-20260928003911-758a51d24cad/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928223257-36eef7b8a0bc h1:uUaC5uzjNuKG29bTsRb1pUKyIoL2Mv8/dA7TGFMO/h0=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928223257-36eef7b8a0bc/go.mod h1:c3qDZseL0znZUq68SygG1TsILXyGMKLSyKyO5XjTDBM=
|
||||
github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0 h1:mh5nA4XFDLYLmLs++EhN2X+6LODI0tTbRRQFp4Q4F/A=
|
||||
github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
|
||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||
|
|
|
|||
|
|
@ -132,14 +132,18 @@ type (
|
|||
DM int `toml:"dm"`
|
||||
}
|
||||
|
||||
SidebarConfig struct {
|
||||
// Visible controls whether the guilds tree (sidebar) is shown by default at start-up.
|
||||
// It can still be toggled at runtime with the toggle_guilds_tree keybind.
|
||||
// PaneConfig is a side pane that can be toggled at runtime.
|
||||
PaneConfig struct {
|
||||
// Visible controls whether the pane is shown by default at start-up.
|
||||
Visible bool `toml:"visible"`
|
||||
// WidthPercent is the percentage of the total window width that the guilds tree (sidebar) occupies.
|
||||
WidthPercent int `toml:"width_percent"`
|
||||
Markers SidebarMarkersConfig `toml:"markers"`
|
||||
Indents SidebarIndentsConfig `toml:"indents"`
|
||||
// WidthPercent is the percentage of the total window width that the pane occupies.
|
||||
WidthPercent int `toml:"width_percent"`
|
||||
}
|
||||
|
||||
SidebarConfig struct {
|
||||
PaneConfig
|
||||
Markers SidebarMarkersConfig `toml:"markers"`
|
||||
Indents SidebarIndentsConfig `toml:"indents"`
|
||||
}
|
||||
|
||||
Config struct {
|
||||
|
|
@ -164,6 +168,7 @@ type (
|
|||
Notifications Notifications `toml:"notifications"`
|
||||
TypingIndicator TypingIndicator `toml:"typing_indicator"`
|
||||
Sidebar SidebarConfig `toml:"sidebar"`
|
||||
MembersTree PaneConfig `toml:"members_tree"`
|
||||
Composer ComposerConfig `toml:"composer"`
|
||||
|
||||
Icons Icons `toml:"icons"`
|
||||
|
|
@ -228,8 +233,10 @@ func (cfg *Config) applyDefaults() {
|
|||
cfg.Composer.MaxHeight = 10
|
||||
}
|
||||
|
||||
if cfg.Sidebar.WidthPercent <= 0 || cfg.Sidebar.WidthPercent >= 100 {
|
||||
cfg.Sidebar.WidthPercent = 20
|
||||
for _, pane := range []*PaneConfig{&cfg.Sidebar.PaneConfig, &cfg.MembersTree} {
|
||||
if pane.WidthPercent <= 0 || pane.WidthPercent >= 100 {
|
||||
pane.WidthPercent = 20
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.DateSeparator.Format == "" {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,14 @@ category = 1
|
|||
channel = 2
|
||||
forum = 2
|
||||
|
||||
[members_tree]
|
||||
# Whether the members tree is visible by default at start-up.
|
||||
# It can still be toggled at runtime with the `toggle_members_tree` keybind.
|
||||
visible = true
|
||||
# Percentage (%) of the width right of the guilds tree used by the members tree.
|
||||
# Valid values are 1-99. Invalid values fall back to 20.
|
||||
width_percent = 20
|
||||
|
||||
[icons]
|
||||
guild_category = ""
|
||||
guild_text = "#"
|
||||
|
|
@ -110,13 +118,16 @@ guild_store = "s-"
|
|||
# Esc: Reset message selection or close the channel selection popup.
|
||||
[keybinds]
|
||||
# Hide/show the guilds tree.
|
||||
toggle_guilds_tree = "ctrl+b"
|
||||
toggle_guilds_tree = "alt+g"
|
||||
# Hide/show the members tree.
|
||||
toggle_members_tree = "alt+m"
|
||||
toggle_channels_picker = "ctrl+k"
|
||||
# Hide/show keybind help.
|
||||
toggle_help = "alt+."
|
||||
# Switch between compact and full keybind help.
|
||||
toggle_full_help = "ctrl+."
|
||||
focus_guilds_tree = "ctrl+g"
|
||||
focus_members_tree = "ctrl+y"
|
||||
focus_messages_list = "ctrl+t"
|
||||
focus_composer = "ctrl+i"
|
||||
# Cycle focus between the widgets.
|
||||
|
|
@ -149,6 +160,19 @@ collapse_all = "_"
|
|||
collapse_parent_node = "-"
|
||||
move_to_parent_node = "p"
|
||||
|
||||
# Only while focusing on the members tree
|
||||
[keybinds.members_tree]
|
||||
select_up = "k"
|
||||
select_down = "j"
|
||||
select_top = "g"
|
||||
select_bottom = "G"
|
||||
# Expand or collapse the highlighted group.
|
||||
select_current = "enter"
|
||||
yank_id = "i"
|
||||
collapse_all = "_"
|
||||
collapse_parent_node = "-"
|
||||
move_to_parent_node = "p"
|
||||
|
||||
# Only while focusing on sent messages
|
||||
[keybinds.messages_list]
|
||||
select_up = "k"
|
||||
|
|
@ -245,6 +269,17 @@ idle_style = { foreground = "yellow" }
|
|||
dnd_style = { foreground = "red" }
|
||||
offline_style = { foreground = "gray" }
|
||||
|
||||
[theme.members_tree]
|
||||
# Give tree-like shape
|
||||
graphics = true
|
||||
graphics_color = "default"
|
||||
|
||||
# Styles of the status dot before each member.
|
||||
online_style = { foreground = "green" }
|
||||
idle_style = { foreground = "yellow" }
|
||||
dnd_style = { foreground = "red" }
|
||||
offline_style = { foreground = "gray" }
|
||||
|
||||
[theme.scroll_bar]
|
||||
visibility = "auto"
|
||||
# "minimal", "box_drawing", or "unicode"
|
||||
|
|
|
|||
|
|
@ -115,12 +115,14 @@ type MentionsListKeybinds struct {
|
|||
|
||||
type Keybinds struct {
|
||||
ToggleGuildsTree Keybind `toml:"toggle_guilds_tree"`
|
||||
ToggleMembersTree Keybind `toml:"toggle_members_tree"`
|
||||
ToggleChannelsPicker Keybind `toml:"toggle_channels_picker"`
|
||||
ToggleHelp Keybind `toml:"toggle_help"`
|
||||
ToggleFullHelp Keybind `toml:"toggle_full_help"`
|
||||
Suspend Keybind `toml:"suspend"`
|
||||
|
||||
FocusGuildsTree Keybind `toml:"focus_guilds_tree"`
|
||||
FocusMembersTree Keybind `toml:"focus_members_tree"`
|
||||
FocusMessagesList Keybind `toml:"focus_messages_list"`
|
||||
FocusComposer Keybind `toml:"focus_composer"`
|
||||
|
||||
|
|
@ -129,6 +131,7 @@ type Keybinds struct {
|
|||
|
||||
Picker PickerKeybinds `toml:"picker"`
|
||||
GuildsTree TreeKeybinds `toml:"guilds_tree"`
|
||||
MembersTree TreeKeybinds `toml:"members_tree"`
|
||||
MessagesList MessagesListKeybinds `toml:"messages_list"`
|
||||
Composer ComposerKeybinds `toml:"composer"`
|
||||
MentionsList MentionsListKeybinds `toml:"mentions_list"`
|
||||
|
|
@ -213,12 +216,14 @@ func defaultMentionsListKeybinds() MentionsListKeybinds {
|
|||
func defaultKeybinds() Keybinds {
|
||||
return Keybinds{
|
||||
ToggleGuildsTree: desc("toggle guilds"),
|
||||
ToggleMembersTree: desc("toggle members"),
|
||||
ToggleChannelsPicker: desc("channels picker"),
|
||||
ToggleHelp: desc("toggle help"),
|
||||
ToggleFullHelp: desc("full help"),
|
||||
Suspend: desc("suspend"),
|
||||
|
||||
FocusGuildsTree: desc("guilds"),
|
||||
FocusMembersTree: desc("members"),
|
||||
FocusMessagesList: desc("messages"),
|
||||
FocusComposer: desc("composer"),
|
||||
|
||||
|
|
@ -230,6 +235,7 @@ func defaultKeybinds() Keybinds {
|
|||
|
||||
Picker: defaultPickerKeybinds(),
|
||||
GuildsTree: defaultTreeKeybinds(),
|
||||
MembersTree: defaultTreeKeybinds(),
|
||||
MessagesList: defaultMessagesListKeybinds(),
|
||||
Composer: defaultComposerKeybinds(),
|
||||
MentionsList: defaultMentionsListKeybinds(),
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ type (
|
|||
Footer FooterTheme `toml:"footer"`
|
||||
Border BorderTheme `toml:"border"`
|
||||
GuildsTree GuildsTreeTheme `toml:"guilds_tree"`
|
||||
MembersTree CommonTreeTheme `toml:"members_tree"`
|
||||
ScrollBar ScrollBarTheme `toml:"scroll_bar"`
|
||||
MessagesList MessagesListTheme `toml:"messages_list"`
|
||||
MentionsList MentionsListTheme `toml:"mentions_list"`
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ func (m Model) activeKeyMap() help.KeyMap {
|
|||
return m.messagesList
|
||||
case composerPane:
|
||||
return m.composer
|
||||
case membersTreePane:
|
||||
return m.membersTree
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
@ -54,7 +56,7 @@ func (m Model) baseFullHelp() [][]keybind.Keybind {
|
|||
return [][]keybind.Keybind{
|
||||
m.focusHelp(),
|
||||
{cfg.FocusPrevious.Keybind, cfg.FocusNext.Keybind},
|
||||
{cfg.ToggleGuildsTree.Keybind, cfg.ToggleChannelsPicker.Keybind},
|
||||
{cfg.ToggleGuildsTree.Keybind, cfg.ToggleMembersTree.Keybind, cfg.ToggleChannelsPicker.Keybind},
|
||||
{cfg.Logout.Keybind},
|
||||
}
|
||||
}
|
||||
|
|
@ -62,7 +64,7 @@ func (m Model) baseFullHelp() [][]keybind.Keybind {
|
|||
func (m Model) focusHelp() []keybind.Keybind {
|
||||
kbs := m.cfg.Keybinds
|
||||
focused := m.focused
|
||||
focusKbs := make([]keybind.Keybind, 0, 3)
|
||||
focusKbs := make([]keybind.Keybind, 0, 4)
|
||||
|
||||
if focused != guildsTreePane {
|
||||
focusKbs = append(focusKbs, kbs.FocusGuildsTree.Keybind)
|
||||
|
|
@ -73,6 +75,9 @@ func (m Model) focusHelp() []keybind.Keybind {
|
|||
if !m.composer.Disabled() && focused != composerPane {
|
||||
focusKbs = append(focusKbs, kbs.FocusComposer.Keybind)
|
||||
}
|
||||
if m.membersTreeShown() && focused != membersTreePane {
|
||||
focusKbs = append(focusKbs, kbs.FocusMembersTree.Keybind)
|
||||
}
|
||||
|
||||
return focusKbs
|
||||
}
|
||||
|
|
|
|||
35
internal/ui/chat/memberstree/keymap.go
Normal file
35
internal/ui/chat/memberstree/keymap.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package memberstree
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/tview/help"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
)
|
||||
|
||||
var _ help.KeyMap = Model{}
|
||||
|
||||
func (m Model) ShortHelp() []keybind.Keybind {
|
||||
kbs := m.cfg.Keybinds.MembersTree
|
||||
short := []keybind.Keybind{kbs.SelectUp.Keybind, kbs.SelectDown.Keybind}
|
||||
if node := m.selectionState.CurrentNode(); node != nil {
|
||||
if _, ok := node.Reference().(groupRef); ok {
|
||||
desc := "expand"
|
||||
if node.Expanded() {
|
||||
desc = "collapse"
|
||||
}
|
||||
short = append(short, kbs.SelectCurrent.WithHelp(kbs.SelectCurrent.Help().Key, desc))
|
||||
}
|
||||
}
|
||||
if m.parent(m.selectionState.CurrentNode()) != nil {
|
||||
short = append(short, kbs.CollapseParentNode.Keybind)
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func (m Model) FullHelp() [][]keybind.Keybind {
|
||||
kbs := m.cfg.Keybinds.MembersTree
|
||||
return [][]keybind.Keybind{
|
||||
{kbs.SelectUp.Keybind, kbs.SelectDown.Keybind, kbs.SelectTop.Keybind, kbs.SelectBottom.Keybind},
|
||||
{kbs.SelectCurrent.Keybind, kbs.MoveToParentNode.Keybind, kbs.CollapseParentNode.Keybind, kbs.CollapseAll.Keybind},
|
||||
{kbs.YankID.Keybind},
|
||||
}
|
||||
}
|
||||
329
internal/ui/chat/memberstree/model.go
Normal file
329
internal/ui/chat/memberstree/model.go
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
package memberstree
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/arikawa/v3/state"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
uitree "github.com/ayn2op/discordo/internal/ui/tree"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/ayn2op/tview/richtext"
|
||||
"github.com/ayn2op/tview/tree"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
)
|
||||
|
||||
// groupRef is the ID of a member list group: a role ID, "online", or "offline".
|
||||
type groupRef string
|
||||
|
||||
func (r groupRef) String() string {
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// memberRef is a member and its index in the guild member list, which decides the chunk of the list to request.
|
||||
type memberRef struct {
|
||||
userID discord.UserID
|
||||
index int
|
||||
}
|
||||
|
||||
func (r memberRef) String() string {
|
||||
return r.userID.String()
|
||||
}
|
||||
|
||||
// Model is a tree of the members of the selected channel, grouped as Discord groups them.
|
||||
type Model struct {
|
||||
root *tree.Node
|
||||
selectionState tree.SelectionState
|
||||
|
||||
cfg *config.Config
|
||||
state *ningen.State
|
||||
|
||||
channel *discord.Channel
|
||||
// nodes indexes the tree by group and user ID, so that a rebuild reuses the nodes and keeps the selection and collapsed groups.
|
||||
nodes map[string]*tree.Node
|
||||
}
|
||||
|
||||
func NewModel(cfg *config.Config, state *ningen.State) Model {
|
||||
return Model{
|
||||
root: tree.NewNode(""),
|
||||
cfg: cfg,
|
||||
state: state,
|
||||
nodes: make(map[string]*tree.Node),
|
||||
}
|
||||
}
|
||||
|
||||
// Shown reports whether the selected channel has members to show: guild channels other than threads, and group DMs.
|
||||
func (m Model) Shown() bool {
|
||||
channel := m.channel
|
||||
if channel == nil {
|
||||
return false
|
||||
}
|
||||
switch channel.Type {
|
||||
case discord.GroupDM:
|
||||
return true
|
||||
case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:
|
||||
return false
|
||||
default:
|
||||
return channel.GuildID.IsValid()
|
||||
}
|
||||
}
|
||||
|
||||
// SetChannel shows the members of channel and requests its member list.
|
||||
func (m *Model) SetChannel(channel *discord.Channel) tview.Cmd {
|
||||
m.channel = channel
|
||||
m.rebuild()
|
||||
return m.requestMembers(0)
|
||||
}
|
||||
|
||||
// View shows the tree in a box titled Members.
|
||||
func (m Model) View(focused bool) tview.Element {
|
||||
onChange := func(c tree.Change) tview.Msg { return Msg(c) }
|
||||
return uitree.New(m.root, &m.selectionState, m.cfg, m.cfg.Theme.MembersTree, m.cfg.Keybinds.MembersTree, focused, onChange).Title("Members")
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||
cmd := m.update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// update changes m in response to msg and returns a command to run, or nil.
|
||||
func (m *Model) update(msg tview.Msg) tview.Cmd {
|
||||
switch msg := msg.(type) {
|
||||
case *gateway.ReadyEvent:
|
||||
// ningen drops its member lists on READY, so request the list again.
|
||||
m.rebuild()
|
||||
return m.requestMembers(0)
|
||||
case *gateway.GuildMemberListUpdateEvent:
|
||||
if channel := m.channel; channel != nil && channel.GuildID == msg.GuildID && m.state.MemberState.ListID(channel) == msg.ID {
|
||||
m.rebuild()
|
||||
}
|
||||
return nil
|
||||
case *gateway.PresenceUpdateEvent:
|
||||
m.refreshStatus(msg.User.ID)
|
||||
return nil
|
||||
|
||||
case Msg:
|
||||
m.selectionState.Apply(tree.Change(msg))
|
||||
return m.requestSelectedMembers()
|
||||
case tree.SelectedMsg:
|
||||
m.selectionState.SetCurrentNode(msg.Node)
|
||||
if _, ok := msg.Node.Reference().(groupRef); ok {
|
||||
msg.Node.SetExpanded(!msg.Node.Expanded())
|
||||
}
|
||||
return m.requestSelectedMembers()
|
||||
case tview.KeyMsg:
|
||||
kbs := m.cfg.Keybinds.MembersTree
|
||||
switch {
|
||||
case keybind.Matches(msg, kbs.CollapseAll.Keybind):
|
||||
for _, node := range m.root.Children() {
|
||||
node.Collapse()
|
||||
}
|
||||
return nil
|
||||
case keybind.Matches(msg, kbs.CollapseParentNode.Keybind):
|
||||
if parent := m.parent(m.selectionState.CurrentNode()); parent != nil {
|
||||
parent.Collapse()
|
||||
m.selectionState.SetCurrentNode(parent)
|
||||
}
|
||||
return nil
|
||||
case keybind.Matches(msg, kbs.YankID.Keybind):
|
||||
return uitree.YankID(m.selectionState.CurrentNode())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rebuild recreates the tree from the member list of the selected channel.
|
||||
func (m *Model) rebuild() {
|
||||
m.root.ClearChildren()
|
||||
channel := m.channel
|
||||
switch {
|
||||
case !m.Shown():
|
||||
case channel.Type == discord.GroupDM:
|
||||
m.addRecipients(channel.DMRecipients)
|
||||
default:
|
||||
m.addGuildMembers(channel)
|
||||
}
|
||||
|
||||
clear(m.nodes)
|
||||
m.root.Walk(func(node, _ *tree.Node) bool {
|
||||
if ref, ok := node.Reference().(fmt.Stringer); ok {
|
||||
m.nodes[ref.String()] = node
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) addGuildMembers(channel *discord.Channel) {
|
||||
list, err := m.state.MemberState.GetMemberList(channel.GuildID, channel.ID)
|
||||
if err != nil {
|
||||
// The list has yet to arrive.
|
||||
return
|
||||
}
|
||||
|
||||
list.ViewItems(func(items []gateway.GuildMemberListOpItem) {
|
||||
var group *tree.Node
|
||||
for i, item := range items {
|
||||
switch {
|
||||
case item.Group != nil:
|
||||
group = m.addGroup(channel.GuildID, *item.Group)
|
||||
case item.Member != nil:
|
||||
// Members after an unloaded range belong to a group that has yet to arrive.
|
||||
if group == nil {
|
||||
group = m.addGroup(channel.GuildID, gateway.GuildMemberListGroup{})
|
||||
}
|
||||
m.addMember(group, channel.GuildID, item.Member.Member, i)
|
||||
default:
|
||||
group = nil
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) addRecipients(recipients []discord.User) {
|
||||
recipients = slices.Clone(recipients)
|
||||
// Discord leaves the current user out of the recipients.
|
||||
if me, err := m.state.Cabinet.Me(); err == nil {
|
||||
recipients = append(recipients, *me)
|
||||
}
|
||||
slices.SortFunc(recipients, func(a, b discord.User) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(statusRank(m.status(discord.NullGuildID, a.ID)), statusRank(m.status(discord.NullGuildID, b.ID))),
|
||||
strings.Compare(strings.ToLower(a.DisplayOrUsername()), strings.ToLower(b.DisplayOrUsername())),
|
||||
)
|
||||
})
|
||||
|
||||
group := m.addGroup(discord.NullGuildID, gateway.GuildMemberListGroup{ID: "members", Count: uint64(len(recipients))})
|
||||
for _, recipient := range recipients {
|
||||
m.addMember(group, discord.NullGuildID, discord.Member{User: recipient}, -1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) addGroup(guildID discord.GuildID, group gateway.GuildMemberListGroup) *tree.Node {
|
||||
name := "Members"
|
||||
var style tcell.Style
|
||||
switch group.ID {
|
||||
case "online":
|
||||
name = "Online"
|
||||
case "offline":
|
||||
name = "Offline"
|
||||
default:
|
||||
if id, err := discord.ParseSnowflake(group.ID); err == nil {
|
||||
if role, err := m.state.Cabinet.Role(guildID, discord.RoleID(id)); err == nil {
|
||||
name = role.Name
|
||||
if role.Color != 0 {
|
||||
style = style.Foreground(tcell.NewHexColor(int32(role.Color)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if group.Count > 0 {
|
||||
name = fmt.Sprintf("%s — %d", name, group.Count)
|
||||
}
|
||||
|
||||
ref := groupRef(group.ID)
|
||||
node := m.reuse(ref.String()).
|
||||
SetLine(richtext.NewLine(richtext.NewSegment(name, style))).
|
||||
SetReference(ref).
|
||||
SetExpandable(true)
|
||||
m.root.AddChild(node)
|
||||
return node
|
||||
}
|
||||
|
||||
// addMember adds a member as Discord shows it: a status dot, then the name in the color of their highest colored role, dimmed when offline.
|
||||
func (m *Model) addMember(group *tree.Node, guildID discord.GuildID, member discord.Member, index int) {
|
||||
name := member.Nick
|
||||
if name == "" {
|
||||
name = member.User.DisplayOrUsername()
|
||||
}
|
||||
var nameStyle tcell.Style
|
||||
color, ok := state.MemberColor(&member, func(id discord.RoleID) *discord.Role {
|
||||
r, _ := m.state.Cabinet.Role(guildID, id)
|
||||
return r
|
||||
})
|
||||
if ok {
|
||||
nameStyle = nameStyle.Foreground(tcell.NewHexColor(int32(color)))
|
||||
}
|
||||
line := richtext.NewLine(
|
||||
richtext.NewSegment(statusMarker, tcell.StyleDefault),
|
||||
richtext.NewSegment(" ", tcell.StyleDefault),
|
||||
richtext.NewSegment(name, nameStyle),
|
||||
)
|
||||
m.setStatus(line, m.status(guildID, member.User.ID))
|
||||
group.AddChild(m.reuse(member.User.ID.String()).SetLine(line).SetReference(memberRef{userID: member.User.ID, index: index}))
|
||||
}
|
||||
|
||||
const statusMarker = "●"
|
||||
|
||||
// setStatus styles the status dot and name of a member line for status.
|
||||
func (m *Model) setStatus(line richtext.Line, status discord.Status) {
|
||||
line[0].Style = m.statusStyle(status)
|
||||
line[2].Style = line[2].Style.Dim(status == discord.OfflineStatus)
|
||||
}
|
||||
|
||||
// reuse takes the node of id out of the index, so that it is reused once, or else returns a new node. Either way the node has no children.
|
||||
func (m *Model) reuse(id string) *tree.Node {
|
||||
node, ok := m.nodes[id]
|
||||
if !ok {
|
||||
return tree.NewNode("")
|
||||
}
|
||||
delete(m.nodes, id)
|
||||
return node.ClearChildren()
|
||||
}
|
||||
|
||||
func (m *Model) refreshStatus(userID discord.UserID) {
|
||||
if node, ok := m.nodes[userID.String()]; ok {
|
||||
line := node.Line()
|
||||
m.setStatus(line, m.status(m.channel.GuildID, userID))
|
||||
node.SetLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) status(guildID discord.GuildID, userID discord.UserID) discord.Status {
|
||||
presence, err := m.state.Cabinet.Presence(guildID, userID)
|
||||
if err != nil {
|
||||
return discord.OfflineStatus
|
||||
}
|
||||
return presence.Status
|
||||
}
|
||||
|
||||
func (m *Model) statusStyle(status discord.Status) tcell.Style {
|
||||
theme := &m.cfg.Theme.MembersTree
|
||||
switch status {
|
||||
case discord.OnlineStatus:
|
||||
return theme.OnlineStyle.Style
|
||||
case discord.IdleStatus:
|
||||
return theme.IdleStyle.Style
|
||||
case discord.DoNotDisturbStatus:
|
||||
return theme.DNDStyle.Style
|
||||
default:
|
||||
return theme.OfflineStyle.Style
|
||||
}
|
||||
}
|
||||
|
||||
// statusRank orders members as Discord does: online, idle, do not disturb, then offline.
|
||||
func statusRank(status discord.Status) int {
|
||||
switch status {
|
||||
case discord.OnlineStatus:
|
||||
return 0
|
||||
case discord.IdleStatus:
|
||||
return 1
|
||||
case discord.DoNotDisturbStatus:
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) parent(node *tree.Node) *tree.Node {
|
||||
path := m.root.PathTo(node)
|
||||
if len(path) < 3 {
|
||||
return nil
|
||||
}
|
||||
return path[len(path)-2]
|
||||
}
|
||||
146
internal/ui/chat/memberstree/model_test.go
Normal file
146
internal/ui/chat/memberstree/model_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package memberstree
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/arikawa/v3/state"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview/tree"
|
||||
)
|
||||
|
||||
const guildID, channelID = 1, 2
|
||||
|
||||
func newTestModel(t *testing.T) Model {
|
||||
t.Helper()
|
||||
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewModel(cfg, ningen.FromState(state.New("")))
|
||||
}
|
||||
|
||||
// setGuildMembers stores a guild channel whose member list holds items, given as JSON.
|
||||
func setGuildMembers(t *testing.T, m Model, items string) *discord.Channel {
|
||||
t.Helper()
|
||||
channel := &discord.Channel{ID: channelID, GuildID: guildID, Type: discord.GuildText}
|
||||
m.state.Cabinet.ChannelSet(channel, false)
|
||||
m.state.Cabinet.RoleSet(guildID, &discord.Role{ID: 3, Name: "Mods"}, false)
|
||||
|
||||
event := &gateway.GuildMemberListUpdateEvent{ID: "everyone", GuildID: guildID}
|
||||
event.Ops = []gateway.GuildMemberListOp{{Op: "SYNC", Range: [2]int{0, 99}}}
|
||||
if err := json.Unmarshal([]byte(items), &event.Ops[0].Items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.state.State.Handler.Call(event)
|
||||
return channel
|
||||
}
|
||||
|
||||
// lines returns the text of the groups and, indented, their members.
|
||||
func lines(m Model) []string {
|
||||
var lines []string
|
||||
for _, group := range m.root.Children() {
|
||||
lines = append(lines, text(group))
|
||||
for _, member := range group.Children() {
|
||||
lines = append(lines, " "+text(member))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func text(node *tree.Node) string {
|
||||
var s string
|
||||
for _, segment := range node.Line() {
|
||||
s += segment.Text
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestModelShown(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
channel *discord.Channel
|
||||
want bool
|
||||
}{
|
||||
{"none", nil, false},
|
||||
{"guild channel", &discord.Channel{GuildID: guildID, Type: discord.GuildText}, true},
|
||||
{"thread", &discord.Channel{GuildID: guildID, Type: discord.GuildPublicThread}, false},
|
||||
{"group dm", &discord.Channel{Type: discord.GroupDM}, true},
|
||||
{"dm", &discord.Channel{Type: discord.DirectMessage}, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
m.channel = tt.channel
|
||||
if got := m.Shown(); got != tt.want {
|
||||
t.Fatalf("Shown() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRebuild(t *testing.T) {
|
||||
t.Run("guild members", func(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
m.channel = setGuildMembers(t, m, `[
|
||||
{"group": {"id": "3", "count": 1}},
|
||||
{"member": {"user": {"id": "10", "username": "a"}, "nick": "Alice"}},
|
||||
{"group": {"id": "online", "count": 2}},
|
||||
{"member": {"user": {"id": "11", "username": "b"}}},
|
||||
{},
|
||||
{"member": {"user": {"id": "12", "username": "c"}}}
|
||||
]`)
|
||||
m.rebuild()
|
||||
|
||||
// The member after the unloaded item belongs to a group that has yet to arrive.
|
||||
want := []string{"Mods — 1", " ● Alice", "Online — 2", " ● b", "Members", " ● c"}
|
||||
if got := lines(m); !slices.Equal(got, want) {
|
||||
t.Fatalf("lines = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group dm recipients", func(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
m.state.Cabinet.MyselfSet(discord.User{ID: 13, Username: "me"}, false)
|
||||
m.state.Cabinet.PresenceSet(discord.NullGuildID, &discord.Presence{User: discord.User{ID: 11}, Status: discord.OnlineStatus}, false)
|
||||
m.channel = &discord.Channel{Type: discord.GroupDM, DMRecipients: []discord.User{
|
||||
{ID: 10, Username: "a"},
|
||||
{ID: 11, Username: "c"},
|
||||
{ID: 12, Username: "B"},
|
||||
}}
|
||||
m.rebuild()
|
||||
|
||||
// Online members come first, then members by name, including the current user.
|
||||
want := []string{"Members — 4", " ● c", " ● a", " ● B", " ● me"}
|
||||
if got := lines(m); !slices.Equal(got, want) {
|
||||
t.Fatalf("lines = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps selection and collapsed groups", func(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
m.channel = setGuildMembers(t, m, `[
|
||||
{"group": {"id": "online", "count": 1}},
|
||||
{"member": {"user": {"id": "10", "username": "a"}}},
|
||||
{"group": {"id": "offline", "count": 1}},
|
||||
{"member": {"user": {"id": "11", "username": "b"}}}
|
||||
]`)
|
||||
m.rebuild()
|
||||
online, offline := m.root.Children()[0], m.root.Children()[1]
|
||||
selected := online.Children()[0]
|
||||
m.selectionState.SetCurrentNode(selected)
|
||||
offline.Collapse()
|
||||
|
||||
m.rebuild()
|
||||
if got := m.selectionState.CurrentNode(); got != selected || m.root.PathTo(got) == nil {
|
||||
t.Fatal("the selected member was not kept in the tree")
|
||||
}
|
||||
if m.root.Children()[1].Expanded() {
|
||||
t.Fatal("the collapsed group was expanded")
|
||||
}
|
||||
})
|
||||
}
|
||||
36
internal/ui/chat/memberstree/msg.go
Normal file
36
internal/ui/chat/memberstree/msg.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package memberstree
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/ningen/v3/states/member"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/tree"
|
||||
)
|
||||
|
||||
// Msg moves or scrolls the tree.
|
||||
type Msg tree.Change
|
||||
|
||||
// requestSelectedMembers requests the chunk of the member list after the selected member, so that the members below it load before they are reached.
|
||||
func (m *Model) requestSelectedMembers() tview.Cmd {
|
||||
node := m.selectionState.CurrentNode()
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
ref, ok := node.Reference().(memberRef)
|
||||
if !ok || ref.index < 0 {
|
||||
return nil
|
||||
}
|
||||
return m.requestMembers(member.ChunkFromIndex(ref.index) + 1)
|
||||
}
|
||||
|
||||
// requestMembers requests the member list of the selected guild channel up to chunk.
|
||||
func (m *Model) requestMembers(chunk int) tview.Cmd {
|
||||
channel := m.channel
|
||||
if !m.Shown() || !channel.GuildID.IsValid() {
|
||||
return nil
|
||||
}
|
||||
state := m.state
|
||||
return func() tview.Msg {
|
||||
state.MemberState.RequestMemberList(channel.GuildID, channel.ID, chunk)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/composer"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/messageslist"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
|
|
@ -39,11 +40,14 @@ type Model struct {
|
|||
focused pane
|
||||
// guildsTreeVisible reports whether the guilds tree is shown left of the messages.
|
||||
guildsTreeVisible bool
|
||||
// membersTreeVisible reports whether the members tree is shown right of the messages when the selected channel has members.
|
||||
membersTreeVisible bool
|
||||
// channelsPickerOpen and attachmentsPickerOpen report whether that picker is shown on top and takes all input.
|
||||
channelsPickerOpen bool
|
||||
attachmentsPickerOpen bool
|
||||
|
||||
guildsTree guildstree.Model
|
||||
membersTree memberstree.Model
|
||||
messagesList messageslist.Model
|
||||
composer composer.Model
|
||||
channelsPicker channelspicker.Model
|
||||
|
|
@ -99,12 +103,14 @@ func NewModel(cfg *config.Config, token string) Model {
|
|||
m.state.OnRequest = append(m.state.OnRequest, httputil.WithHeaders(http.Headers()), onRequest)
|
||||
|
||||
m.guildsTree = guildstree.NewModel(cfg, m.state)
|
||||
m.membersTree = memberstree.NewModel(cfg, m.state)
|
||||
m.messagesList = messageslist.NewModel(cfg, m.state)
|
||||
m.composer = composer.NewModel(cfg, m.state)
|
||||
m.channelsPicker = channelspicker.NewModel(cfg)
|
||||
m.attachmentsPicker = attachmentspicker.NewModel(cfg)
|
||||
|
||||
m.guildsTreeVisible = cfg.Sidebar.Visible
|
||||
m.membersTreeVisible = cfg.MembersTree.Visible
|
||||
if m.guildsTreeVisible {
|
||||
// The guilds tree is focused first at start-up when visible.
|
||||
m.focused = guildsTreePane
|
||||
|
|
@ -114,9 +120,14 @@ func NewModel(cfg *config.Config, token string) Model {
|
|||
return m
|
||||
}
|
||||
|
||||
func (m *Model) setSelectedChannel(channel *discord.Channel) {
|
||||
func (m *Model) setSelectedChannel(channel *discord.Channel) tview.Cmd {
|
||||
m.selectedChannel = channel
|
||||
m.composer.SetChannel(channel)
|
||||
cmd := m.membersTree.SetChannel(channel)
|
||||
if !m.canFocus(m.focused) {
|
||||
m.focused = messagesListPane
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m *Model) togglePicker() tview.Cmd {
|
||||
|
|
@ -167,68 +178,30 @@ func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd {
|
|||
return tview.Sequence(closeCmd, cmd)
|
||||
}
|
||||
|
||||
func (m *Model) toggleGuildsTree() tview.Cmd {
|
||||
if m.guildsTreeVisible {
|
||||
if m.focused == guildsTreePane {
|
||||
m.setFocus(messagesListPane)
|
||||
}
|
||||
m.guildsTreeVisible = false
|
||||
} else {
|
||||
m.guildsTreeVisible = true
|
||||
m.setFocus(guildsTreePane)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) focusGuildsTree() bool {
|
||||
m.setFocus(guildsTreePane)
|
||||
return m.focused == guildsTreePane
|
||||
}
|
||||
|
||||
func (m *Model) focusComposer() bool {
|
||||
if !m.composer.Disabled() {
|
||||
m.setFocus(composerPane)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) focusPrevious() {
|
||||
switch m.focused {
|
||||
case guildsTreePane:
|
||||
if m.focusComposer() {
|
||||
return
|
||||
}
|
||||
m.setFocus(messagesListPane)
|
||||
case messagesListPane:
|
||||
if m.focusGuildsTree() {
|
||||
return
|
||||
}
|
||||
if m.focusComposer() {
|
||||
return
|
||||
}
|
||||
m.setFocus(messagesListPane)
|
||||
case composerPane:
|
||||
m.setFocus(messagesListPane)
|
||||
// toggle shows or hides the side pane p and focuses it when shown.
|
||||
func (m *Model) toggle(visible *bool, p pane) {
|
||||
*visible = !*visible
|
||||
if *visible {
|
||||
m.setFocus(p)
|
||||
} else if m.focused == p {
|
||||
m.focused = messagesListPane
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) focusNext() {
|
||||
switch m.focused {
|
||||
case guildsTreePane:
|
||||
m.setFocus(messagesListPane)
|
||||
case messagesListPane:
|
||||
if m.focusComposer() {
|
||||
// membersTreeShown reports whether the members tree is visible and the selected channel has members to show.
|
||||
func (m Model) membersTreeShown() bool {
|
||||
return m.membersTreeVisible && m.membersTree.Shown()
|
||||
}
|
||||
|
||||
// cycleFocus focuses the next pane that can take the focus, going backwards if step is -1.
|
||||
func (m *Model) cycleFocus(step pane) {
|
||||
p := m.focused
|
||||
for range paneCount - 1 {
|
||||
p = (p + step + paneCount) % paneCount
|
||||
if m.canFocus(p) {
|
||||
m.focused = p
|
||||
return
|
||||
}
|
||||
if m.focusGuildsTree() {
|
||||
return
|
||||
}
|
||||
case composerPane:
|
||||
if m.focusGuildsTree() {
|
||||
return
|
||||
}
|
||||
m.setFocus(messagesListPane)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,14 +232,14 @@ func (m *Model) update(msg tview.Msg) tview.Cmd {
|
|||
return nil
|
||||
}
|
||||
|
||||
m.setSelectedChannel(&msg.Channel)
|
||||
membersCmd := m.setSelectedChannel(&msg.Channel)
|
||||
m.clearTypers()
|
||||
|
||||
if m.cfg.AutoFocus {
|
||||
m.focusComposer()
|
||||
m.setFocus(composerPane)
|
||||
}
|
||||
title := ui.ChannelToString(msg.Channel, m.cfg.Icons, m.state) + " - " + consts.Name
|
||||
return tview.Batch(tview.SetTitle(title), m.messagesList.SetChannel(&msg.Channel, msg.Messages))
|
||||
return tview.Batch(tview.SetTitle(title), m.messagesList.SetChannel(&msg.Channel, msg.Messages), membersCmd)
|
||||
case messageslist.Msg:
|
||||
return m.updatePane(messagesListPane, msg)
|
||||
case typingExpiredMsg:
|
||||
|
|
@ -288,11 +261,11 @@ func (m *Model) update(msg tview.Msg) tview.Cmd {
|
|||
return nil
|
||||
case messageslist.ReplyMsg:
|
||||
m.composer.StartReply(msg.Message, msg.Name, msg.Mention)
|
||||
m.focusComposer()
|
||||
m.setFocus(composerPane)
|
||||
return nil
|
||||
case messageslist.EditMsg:
|
||||
m.composer.StartEdit(discord.Message(msg))
|
||||
m.focusComposer()
|
||||
m.setFocus(composerPane)
|
||||
return nil
|
||||
case composer.EditLastMsg:
|
||||
if message, ok := m.messagesList.SelectLastOwn(); ok {
|
||||
|
|
@ -308,25 +281,33 @@ func (m *Model) update(msg tview.Msg) tview.Cmd {
|
|||
switch {
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.FocusGuildsTree.Keybind):
|
||||
m.composer.CloseMentions()
|
||||
m.focusGuildsTree()
|
||||
m.setFocus(guildsTreePane)
|
||||
return nil
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.FocusMembersTree.Keybind):
|
||||
m.composer.CloseMentions()
|
||||
m.setFocus(membersTreePane)
|
||||
return nil
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.FocusMessagesList.Keybind):
|
||||
m.composer.CloseMentions()
|
||||
m.setFocus(messagesListPane)
|
||||
return nil
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.FocusComposer.Keybind):
|
||||
m.focusComposer()
|
||||
m.setFocus(composerPane)
|
||||
return nil
|
||||
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.FocusPrevious.Keybind):
|
||||
m.focusPrevious()
|
||||
m.cycleFocus(-1)
|
||||
return nil
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.FocusNext.Keybind):
|
||||
m.focusNext()
|
||||
m.cycleFocus(1)
|
||||
return nil
|
||||
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.ToggleGuildsTree.Keybind):
|
||||
return m.toggleGuildsTree()
|
||||
m.toggle(&m.guildsTreeVisible, guildsTreePane)
|
||||
return nil
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.ToggleMembersTree.Keybind):
|
||||
m.toggle(&m.membersTreeVisible, membersTreePane)
|
||||
return nil
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.ToggleChannelsPicker.Keybind):
|
||||
return m.togglePicker()
|
||||
|
||||
|
|
@ -371,6 +352,8 @@ func (m *Model) updatePane(p pane, msg tview.Msg) tview.Cmd {
|
|||
m.messagesList, cmd = m.messagesList.Update(msg)
|
||||
case composerPane:
|
||||
m.composer, cmd = m.composer.Update(msg)
|
||||
case membersTreePane:
|
||||
m.membersTree, cmd = m.membersTree.Update(msg)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -408,6 +391,8 @@ const (
|
|||
guildsTreePane pane = iota
|
||||
messagesListPane
|
||||
composerPane
|
||||
membersTreePane
|
||||
paneCount
|
||||
)
|
||||
|
||||
// paneView returns the view of p, focused if it has the focus and no picker is open, marked so that clicking it focuses it.
|
||||
|
|
@ -421,15 +406,31 @@ func (m Model) paneView(p pane) tview.Element {
|
|||
child = m.messagesList.View(focused)
|
||||
case composerPane:
|
||||
child = m.composer.View(focused)
|
||||
case membersTreePane:
|
||||
child = m.membersTree.View(focused)
|
||||
}
|
||||
return paneElement{pane: p, child: child}
|
||||
}
|
||||
|
||||
func (m *Model) setFocus(target pane) {
|
||||
if target == m.focused || (target == guildsTreePane && !m.guildsTreeVisible) {
|
||||
return
|
||||
// setFocus focuses p if it can take the focus.
|
||||
func (m *Model) setFocus(p pane) {
|
||||
if m.canFocus(p) {
|
||||
m.focused = p
|
||||
}
|
||||
}
|
||||
|
||||
// canFocus reports whether p is shown and can take the focus.
|
||||
func (m Model) canFocus(p pane) bool {
|
||||
switch p {
|
||||
case guildsTreePane:
|
||||
return m.guildsTreeVisible
|
||||
case composerPane:
|
||||
return !m.composer.Disabled()
|
||||
case membersTreePane:
|
||||
return m.membersTreeShown()
|
||||
default:
|
||||
return true
|
||||
}
|
||||
m.focused = target
|
||||
}
|
||||
|
||||
// View shows the guilds tree left of the messages above the composer, with the mentions list over the messages and an open picker on top.
|
||||
|
|
@ -445,11 +446,18 @@ func (m Model) View() tview.Element {
|
|||
}
|
||||
|
||||
main := right
|
||||
if m.membersTreeShown() {
|
||||
width := m.cfg.MembersTree.WidthPercent
|
||||
main = row.New(
|
||||
column.New(main).Width(tview.FillPortion(100-width)),
|
||||
column.New(m.paneView(membersTreePane)).Width(tview.FillPortion(width)),
|
||||
)
|
||||
}
|
||||
if m.guildsTreeVisible {
|
||||
width := m.cfg.Sidebar.WidthPercent
|
||||
main = row.New(
|
||||
column.New(m.paneView(guildsTreePane)).Width(tview.FillPortion(width)),
|
||||
column.New(right).Width(tview.FillPortion(100-width)),
|
||||
column.New(main).Width(tview.FillPortion(100-width)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,3 +79,21 @@ func TestModelAddMessageOrNotify(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCycleFocus(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
m.setSelectedChannel(&discord.Channel{ID: 1, GuildID: 2, Type: discord.GuildText})
|
||||
m.focused = guildsTreePane
|
||||
|
||||
// The composer is disabled without permission to send messages, so it is skipped.
|
||||
for _, want := range []pane{messagesListPane, membersTreePane, guildsTreePane} {
|
||||
if m.cycleFocus(1); m.focused != want {
|
||||
t.Fatalf("focused = %v, want %v", m.focused, want)
|
||||
}
|
||||
}
|
||||
for _, want := range []pane{membersTreePane, messagesListPane, guildsTreePane} {
|
||||
if m.cycleFocus(-1); m.focused != want {
|
||||
t.Fatalf("focused = %v, want %v", m.focused, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,14 +112,16 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
|
|||
m.onRaw(event)
|
||||
|
||||
case *gateway.ReadyEvent:
|
||||
return m.loadGuildsTree(event)
|
||||
return tview.Batch(m.loadGuildsTree(event), m.updatePane(membersTreePane, event))
|
||||
|
||||
case *gateway.MessageCreateEvent:
|
||||
return m.addMessageOrNotify(event)
|
||||
case *gateway.MessageUpdateEvent:
|
||||
m.messagesList.UpdateMessage(event.Message)
|
||||
case *gateway.PresenceUpdateEvent:
|
||||
return m.updatePane(guildsTreePane, event)
|
||||
return tview.Batch(m.updatePane(guildsTreePane, event), m.updatePane(membersTreePane, event))
|
||||
case *gateway.GuildMemberListUpdateEvent:
|
||||
return m.updatePane(membersTreePane, event)
|
||||
case *gateway.MessageDeleteEvent:
|
||||
m.messagesList.DeleteMessage(event.ChannelID, event.ID)
|
||||
case *gateway.MessageReactionAddEvent:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue