From 05e622e753a553acfe355f52489202339e6a9676 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Mon, 28 Sep 2026 17:27:24 -0400 Subject: [PATCH] refactor(ui): share tree and picker widgets --- internal/config/keybinds.go | 10 +-- internal/config/theme.go | 12 ++-- internal/ui/chat/attachmentspicker/model.go | 41 ++++------- internal/ui/chat/channelspicker/model.go | 44 ++++-------- internal/ui/chat/guildstree/model.go | 25 ++----- internal/ui/chat/guildstree/msg.go | 20 ------ internal/ui/chat/messageslist/model.go | 19 ++++- internal/ui/keybind.go | 52 -------------- internal/ui/picker/picker.go | 74 ++++++++++++++++++++ internal/ui/tree/tree.go | 77 +++++++++++++++++++++ internal/ui/util.go | 15 ---- 11 files changed, 215 insertions(+), 174 deletions(-) create mode 100644 internal/ui/picker/picker.go create mode 100644 internal/ui/tree/tree.go diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 83e3381..3d661b8 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -62,7 +62,7 @@ type PickerKeybinds struct { Cancel Keybind `toml:"cancel"` } -type GuildsTreeKeybinds struct { +type TreeKeybinds struct { SelectionKeybinds SelectCurrent Keybind `toml:"select_current"` YankID Keybind `toml:"yank_id"` @@ -128,7 +128,7 @@ type Keybinds struct { FocusNext Keybind `toml:"focus_next"` Picker PickerKeybinds `toml:"picker"` - GuildsTree GuildsTreeKeybinds `toml:"guilds_tree"` + GuildsTree TreeKeybinds `toml:"guilds_tree"` MessagesList MessagesListKeybinds `toml:"messages_list"` Composer ComposerKeybinds `toml:"composer"` MentionsList MentionsListKeybinds `toml:"mentions_list"` @@ -154,8 +154,8 @@ func defaultPickerKeybinds() PickerKeybinds { } } -func defaultGuildsTreeKeybinds() GuildsTreeKeybinds { - return GuildsTreeKeybinds{ +func defaultTreeKeybinds() TreeKeybinds { + return TreeKeybinds{ SelectionKeybinds: defaultSelectionKeybinds(), SelectCurrent: desc("select"), YankID: desc("copy id"), @@ -229,7 +229,7 @@ func defaultKeybinds() Keybinds { Quit: desc("quit"), Picker: defaultPickerKeybinds(), - GuildsTree: defaultGuildsTreeKeybinds(), + GuildsTree: defaultTreeKeybinds(), MessagesList: defaultMessagesListKeybinds(), Composer: defaultComposerKeybinds(), MentionsList: defaultMentionsListKeybinds(), diff --git a/internal/config/theme.go b/internal/config/theme.go index 06b4f2a..25d808d 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -208,10 +208,9 @@ type ( ActiveSet BorderSetWrapper `toml:"active_set"` } - GuildsTreeTheme struct { - AutoExpandFolders bool `toml:"auto_expand_folders"` - Graphics bool `toml:"graphics"` - GraphicsColor string `toml:"graphics_color"` + CommonTreeTheme struct { + Graphics bool `toml:"graphics"` + GraphicsColor string `toml:"graphics_color"` OnlineStyle StyleWrapper `toml:"online_style"` IdleStyle StyleWrapper `toml:"idle_style"` @@ -219,6 +218,11 @@ type ( OfflineStyle StyleWrapper `toml:"offline_style"` } + GuildsTreeTheme struct { + CommonTreeTheme + AutoExpandFolders bool `toml:"auto_expand_folders"` + } + MessagesListTheme struct { ReplyIndicator string `toml:"reply_indicator"` ForwardedIndicator string `toml:"forwarded_indicator"` diff --git a/internal/ui/chat/attachmentspicker/model.go b/internal/ui/chat/attachmentspicker/model.go index e327a4f..27be837 100644 --- a/internal/ui/chat/attachmentspicker/model.go +++ b/internal/ui/chat/attachmentspicker/model.go @@ -2,7 +2,7 @@ package attachmentspicker import ( "github.com/ayn2op/discordo/internal/config" - "github.com/ayn2op/discordo/internal/ui" + uipicker "github.com/ayn2op/discordo/internal/ui/picker" "github.com/ayn2op/tview" "github.com/ayn2op/tview/picker" ) @@ -13,35 +13,24 @@ type Item struct { } type Model struct { - items picker.Items - searchState picker.SearchState - cfg *config.Config + uipicker.Model } func NewModel(cfg *config.Config) Model { - return Model{searchState: picker.NewSearchState(), cfg: cfg} + return Model{uipicker.NewModel(cfg, "Attachments")} } var _ tview.Model[Model] = Model{} -// changeMsg changes the picker. -type changeMsg picker.Change - -func (Model) Init() tview.Cmd { return nil } - // View shows the picker in a box titled Attachments. func (m Model) View() tview.Element { - p := ui.Picker(m.items, &m.searchState, m.cfg). - OnChange(func(a picker.Change) tview.Msg { return changeMsg(a) }). - OnSelect(func(item picker.Item) tview.Msg { - action, ok := item.Reference.(tview.Cmd) - if !ok { - return nil - } - return SelectedMsg{Action: action} - }). - OnCancel(CancelMsg{}) - return ui.Box(p, &m.cfg.Theme, true).Title("Attachments") + return m.Model.View(func(item picker.Item) tview.Msg { + action, ok := item.Reference.(tview.Cmd) + if !ok { + return nil + } + return SelectedMsg{Action: action} + }, CancelMsg{}) } func (m *Model) SetItems(items []Item) { @@ -49,13 +38,11 @@ func (m *Model) SetItems(items []Item) { for i, item := range items { pickerItems[i] = picker.Item{Text: item.Label, Reference: item.Action} } - m.items = pickerItems - m.searchState.Reset() + m.Model.SetItems(pickerItems) } func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { - if msg, ok := msg.(changeMsg); ok { - m.searchState.Apply(picker.Change(msg)) - } - return m, nil + var cmd tview.Cmd + m.Model, cmd = m.Model.Update(msg) + return m, cmd } diff --git a/internal/ui/chat/channelspicker/model.go b/internal/ui/chat/channelspicker/model.go index 32be83d..3601860 100644 --- a/internal/ui/chat/channelspicker/model.go +++ b/internal/ui/chat/channelspicker/model.go @@ -7,53 +7,40 @@ import ( "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/ui" + uipicker "github.com/ayn2op/discordo/internal/ui/picker" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" "github.com/ayn2op/tview/picker" ) type Model struct { - items picker.Items - searchState picker.SearchState - cfg *config.Config + uipicker.Model + cfg *config.Config } func NewModel(cfg *config.Config) Model { - return Model{searchState: picker.NewSearchState(), cfg: cfg} + return Model{Model: uipicker.NewModel(cfg, "Channels"), cfg: cfg} } var _ tview.Model[Model] = Model{} -// changeMsg changes the picker. -type changeMsg picker.Change - -func (Model) Init() tview.Cmd { return nil } - // View shows the picker in a box titled Channels. func (m Model) View() tview.Element { - p := ui.Picker(m.items, &m.searchState, m.cfg). - OnChange(func(a picker.Change) tview.Msg { return changeMsg(a) }). - OnSelect(func(item picker.Item) tview.Msg { - channelID, ok := item.Reference.(discord.ChannelID) - if !ok || !channelID.IsValid() { - return nil - } - return SelectedMsg{ChannelID: channelID} - }). - OnCancel(CancelMsg{}) - return ui.Box(p, &m.cfg.Theme, true).Title("Channels") + return m.Model.View(func(item picker.Item) tview.Msg { + channelID, ok := item.Reference.(discord.ChannelID) + if !ok || !channelID.IsValid() { + return nil + } + return SelectedMsg{ChannelID: channelID} + }, CancelMsg{}) } func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { - if msg, ok := msg.(changeMsg); ok { - m.searchState.Apply(picker.Change(msg)) - } - return m, nil + var cmd tview.Cmd + m.Model, cmd = m.Model.Update(msg) + return m, cmd } -// Reset clears the query. -func (m *Model) Reset() { m.searchState.Reset() } - func (m *Model) RefreshChannels(state *ningen.State) { var items picker.Items @@ -86,8 +73,7 @@ func (m *Model) RefreshChannels(state *ningen.State) { } } - m.items = items - m.searchState.Reset() + m.SetItems(items) } func (m Model) channelItem(state *ningen.State, guild *discord.Guild, channel discord.Channel) picker.Item { diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go index 6207ed3..2e74044 100644 --- a/internal/ui/chat/guildstree/model.go +++ b/internal/ui/chat/guildstree/model.go @@ -8,6 +8,7 @@ import ( "github.com/ayn2op/arikawa/v3/gateway" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/ui" + uitree "github.com/ayn2op/discordo/internal/ui/tree" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3/states/read" "github.com/ayn2op/tview" @@ -50,26 +51,8 @@ type Msg tree.Change // View shows the tree in a box titled Guilds. func (m Model) View(focused bool) tview.Element { - theme := &m.cfg.Theme - set := theme.Border.NormalSet.BorderSet - if focused { - set = theme.Border.ActiveSet.BorderSet - } - kbs := m.cfg.Keybinds.GuildsTree - t := tree.New(m.root, &m.selectionState). - TopLevel(1). - Markers(tree.Markers{ - Expanded: m.cfg.Sidebar.Markers.Expanded, - Collapsed: m.cfg.Sidebar.Markers.Collapsed, - Leaf: m.cfg.Sidebar.Markers.Leaf, - }). - Graphics(theme.GuildsTree.Graphics). - GraphicsSet(set). - GraphicsStyle(tcell.StyleDefault.Foreground(tcell.GetColor(theme.GuildsTree.GraphicsColor))). - Keybind(ui.TreeKeybind(kbs)). - Focused(focused). - OnChange(func(a tree.Change) tview.Msg { return Msg(a) }) - return ui.Box(t, theme, focused).Title("Guilds") + onChange := func(c tree.Change) tview.Msg { return Msg(c) } + return uitree.New(m.root, &m.selectionState, m.cfg, m.cfg.Theme.GuildsTree.CommonTreeTheme, m.cfg.Keybinds.GuildsTree, focused, onChange).Title("Guilds") } func (m *Model) reset() *tree.Node { @@ -365,7 +348,7 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { m.collapseParentNode(m.selectionState.CurrentNode()) return nil case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind): - return yankID(m.selectionState.CurrentNode()) + return uitree.YankID(m.selectionState.CurrentNode()) } } return nil diff --git a/internal/ui/chat/guildstree/msg.go b/internal/ui/chat/guildstree/msg.go index 51691fb..af6b7cb 100644 --- a/internal/ui/chat/guildstree/msg.go +++ b/internal/ui/chat/guildstree/msg.go @@ -1,8 +1,6 @@ package guildstree import ( - "context" - "fmt" "log/slog" "github.com/ayn2op/arikawa/v3/discord" @@ -10,7 +8,6 @@ import ( "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" "github.com/ayn2op/tview/tree" - "golang.design/x/clipboard" ) type ChannelLoadedMsg struct { @@ -106,20 +103,3 @@ func loadChannel(state *ningen.State, limit uint, channel discord.Channel) tview return ChannelLoadedMsg{Channel: channel, Messages: messages} } } - -func yankID(node *tree.Node) tview.Cmd { - if node == nil { - return nil - } - - id, ok := node.Reference().(fmt.Stringer) - if !ok { - return nil - } - return func() tview.Msg { - if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(id.String())); err != nil { - slog.Error("failed to write to clipboard", "err", err) - } - return nil - } -} diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index 65c7de4..9cca99f 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -103,11 +103,28 @@ func (ml *Model) listView(focused bool) list.Widget { return list.New(&ml.selectionState, len(ml.items), ml.buildItem). SelectedStyle(ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style). ScrollBar(ml.scrollBar, ml.scrollBarVisibility). - Keybind(ui.ScrollKeybind(ml.cfg.Keybinds.MessagesList.ScrollKeybinds)). + Keybind(scrollKeybind(ml.cfg.Keybinds.MessagesList.ScrollKeybinds)). Focused(focused). OnChange(func(a list.Change) tview.Msg { return listMsg(a) }) } +// scrollKeybind binds kbs to scrolling the list. +func scrollKeybind(kbs config.ScrollKeybinds) func(tview.KeyMsg) (list.Action, bool) { + return func(key tview.KeyMsg) (list.Action, bool) { + switch { + case keybind.Matches(key, kbs.ScrollUp.Keybind): + return list.ActionScrollUp, true + case keybind.Matches(key, kbs.ScrollDown.Keybind): + return list.ActionScrollDown, true + case keybind.Matches(key, kbs.ScrollTop.Keybind): + return list.ActionScrollTop, true + case keybind.Matches(key, kbs.ScrollBottom.Keybind): + return list.ActionScrollBottom, true + } + return 0, false + } +} + func (ml *Model) cursor() int { return ml.selectionState.Cursor() } diff --git a/internal/ui/keybind.go b/internal/ui/keybind.go index 40f401b..8b22105 100644 --- a/internal/ui/keybind.go +++ b/internal/ui/keybind.go @@ -5,8 +5,6 @@ import ( "github.com/ayn2op/tview" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/list" - "github.com/ayn2op/tview/picker" - "github.com/ayn2op/tview/tree" ) // SelectionKeybind binds kbs to moving a list's selection. @@ -25,53 +23,3 @@ func SelectionKeybind(kbs config.SelectionKeybinds) func(tview.KeyMsg) (list.Act return 0, false } } - -// ScrollKeybind binds kbs to scrolling a list. -func ScrollKeybind(kbs config.ScrollKeybinds) func(tview.KeyMsg) (list.Action, bool) { - return func(key tview.KeyMsg) (list.Action, bool) { - switch { - case keybind.Matches(key, kbs.ScrollUp.Keybind): - return list.ActionScrollUp, true - case keybind.Matches(key, kbs.ScrollDown.Keybind): - return list.ActionScrollDown, true - case keybind.Matches(key, kbs.ScrollTop.Keybind): - return list.ActionScrollTop, true - case keybind.Matches(key, kbs.ScrollBottom.Keybind): - return list.ActionScrollBottom, true - } - return 0, false - } -} - -// TreeKeybind binds kbs to moving through a tree and selecting its nodes. -func TreeKeybind(kbs config.GuildsTreeKeybinds) func(tview.KeyMsg) (tree.Action, bool) { - return func(key tview.KeyMsg) (tree.Action, bool) { - switch { - case keybind.Matches(key, kbs.SelectUp.Keybind): - return tree.ActionUp, true - case keybind.Matches(key, kbs.SelectDown.Keybind): - return tree.ActionDown, true - case keybind.Matches(key, kbs.SelectTop.Keybind): - return tree.ActionTop, true - case keybind.Matches(key, kbs.SelectBottom.Keybind): - return tree.ActionBottom, true - case keybind.Matches(key, kbs.MoveToParentNode.Keybind): - return tree.ActionMoveToParent, true - case keybind.Matches(key, kbs.SelectCurrent.Keybind): - return tree.ActionSelect, true - } - return 0, false - } -} - -func pickerKeybind(kbs config.PickerKeybinds) func(tview.KeyMsg) (picker.Action, bool) { - return func(key tview.KeyMsg) (picker.Action, bool) { - switch { - case keybind.Matches(key, kbs.Select.Keybind): - return picker.ActionSelect, true - case keybind.Matches(key, kbs.Cancel.Keybind): - return picker.ActionCancel, true - } - return 0, false - } -} diff --git a/internal/ui/picker/picker.go b/internal/ui/picker/picker.go new file mode 100644 index 0000000..cc1c7a2 --- /dev/null +++ b/internal/ui/picker/picker.go @@ -0,0 +1,74 @@ +// Package picker holds what discordo's pickers share. +package picker + +import ( + "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/tview" + "github.com/ayn2op/tview/keybind" + "github.com/ayn2op/tview/picker" + "github.com/ayn2op/tview/scrollbar" +) + +// Model is a picker of items shown in a box titled title. +type Model struct { + items picker.Items + searchState picker.SearchState + cfg *config.Config + title string +} + +func NewModel(cfg *config.Config, title string) Model { + return Model{searchState: picker.NewSearchState(), cfg: cfg, title: title} +} + +// changeMsg changes the picker. +type changeMsg picker.Change + +func (Model) Init() tview.Cmd { return nil } + +// View shows the picker, turning the selected item into the message onSelect returns and canceling with onCancel. +func (m Model) View(onSelect func(picker.Item) tview.Msg, onCancel tview.Msg) tview.Element { + cfg := m.cfg + bar := scrollbar.New(). + TrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style). + ThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style). + GlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet). + Arrows(scrollbar.ArrowsBoth) + p := picker.New(m.items, &m.searchState). + ScrollBar(bar, cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility). + Keybind(bindKeys(cfg.Keybinds.Picker)). + ListKeybind(ui.SelectionKeybind(cfg.Keybinds.Picker.SelectionKeybinds)). + OnChange(func(c picker.Change) tview.Msg { return changeMsg(c) }). + OnSelect(onSelect). + OnCancel(onCancel) + return ui.Box(p, &cfg.Theme, true).Title(m.title) +} + +func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { + if msg, ok := msg.(changeMsg); ok { + m.searchState.Apply(picker.Change(msg)) + } + return m, nil +} + +// SetItems replaces the items and clears the query. +func (m *Model) SetItems(items picker.Items) { + m.items = items + m.searchState.Reset() +} + +// Reset clears the query. +func (m *Model) Reset() { m.searchState.Reset() } + +func bindKeys(kbs config.PickerKeybinds) func(tview.KeyMsg) (picker.Action, bool) { + return func(key tview.KeyMsg) (picker.Action, bool) { + switch { + case keybind.Matches(key, kbs.Select.Keybind): + return picker.ActionSelect, true + case keybind.Matches(key, kbs.Cancel.Keybind): + return picker.ActionCancel, true + } + return 0, false + } +} diff --git a/internal/ui/tree/tree.go b/internal/ui/tree/tree.go new file mode 100644 index 0000000..8831f9c --- /dev/null +++ b/internal/ui/tree/tree.go @@ -0,0 +1,77 @@ +// Package tree holds what discordo's trees share. +package tree + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/tview" + "github.com/ayn2op/tview/box" + "github.com/ayn2op/tview/keybind" + "github.com/ayn2op/tview/tree" + "github.com/gdamore/tcell/v3" + "golang.design/x/clipboard" +) + +// New returns a boxed tree of the nodes under root, drawn with theme and moved with kbs, that turns changes into the message onChange returns. +func New(root *tree.Node, selectionState *tree.SelectionState, cfg *config.Config, theme config.CommonTreeTheme, kbs config.TreeKeybinds, focused bool, onChange func(tree.Change) tview.Msg) box.Widget { + set := cfg.Theme.Border.NormalSet.BorderSet + if focused { + set = cfg.Theme.Border.ActiveSet.BorderSet + } + t := tree.New(root, selectionState). + TopLevel(1). + Markers(tree.Markers{ + Expanded: cfg.Sidebar.Markers.Expanded, + Collapsed: cfg.Sidebar.Markers.Collapsed, + Leaf: cfg.Sidebar.Markers.Leaf, + }). + Graphics(theme.Graphics). + GraphicsSet(set). + GraphicsStyle(tcell.StyleDefault.Foreground(tcell.GetColor(theme.GraphicsColor))). + Keybind(bindKeys(kbs)). + Focused(focused). + OnChange(onChange) + return ui.Box(t, &cfg.Theme, focused) +} + +func bindKeys(kbs config.TreeKeybinds) func(tview.KeyMsg) (tree.Action, bool) { + return func(key tview.KeyMsg) (tree.Action, bool) { + switch { + case keybind.Matches(key, kbs.SelectUp.Keybind): + return tree.ActionUp, true + case keybind.Matches(key, kbs.SelectDown.Keybind): + return tree.ActionDown, true + case keybind.Matches(key, kbs.SelectTop.Keybind): + return tree.ActionTop, true + case keybind.Matches(key, kbs.SelectBottom.Keybind): + return tree.ActionBottom, true + case keybind.Matches(key, kbs.MoveToParentNode.Keybind): + return tree.ActionMoveToParent, true + case keybind.Matches(key, kbs.SelectCurrent.Keybind): + return tree.ActionSelect, true + } + return 0, false + } +} + +// YankID copies the ID of node to the clipboard. +func YankID(node *tree.Node) tview.Cmd { + if node == nil { + return nil + } + + id, ok := node.Reference().(fmt.Stringer) + if !ok { + return nil + } + return func() tview.Msg { + if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(id.String())); err != nil { + slog.Error("failed to write to clipboard", "err", err) + } + return nil + } +} diff --git a/internal/ui/util.go b/internal/ui/util.go index 4dfea56..353c202 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -10,8 +10,6 @@ import ( "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" "github.com/ayn2op/tview/box" - "github.com/ayn2op/tview/picker" - "github.com/ayn2op/tview/scrollbar" ) // Box returns child in a box styled by the theme, highlighted while focused. @@ -36,19 +34,6 @@ func Box(child tview.Element, theme *config.Theme, focused bool) box.Widget { FooterStyle(theme.Footer.NormalStyle.Style) } -// Picker returns a picker of items searched with searchState, with the configured scroll bar and keybinds. -func Picker(items picker.Items, searchState *picker.SearchState, cfg *config.Config) picker.Widget { - bar := scrollbar.New(). - TrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style). - ThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style). - GlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet). - Arrows(scrollbar.ArrowsBoth) - return picker.New(items, searchState). - ScrollBar(bar, cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility). - Keybind(pickerKeybind(cfg.Keybinds.Picker)). - ListKeybind(SelectionKeybind(cfg.Keybinds.Picker.SelectionKeybinds)) -} - // ChannelToString returns how channel is shown in the UI: an icon for its type and its name, or the recipients' names for a DM. func ChannelToString(channel discord.Channel, icons config.Icons, state *ningen.State) string { var icon string