refactor(ui): share tree and picker widgets

This commit is contained in:
ayn2op 2026-09-28 17:27:24 -04:00
parent d9ce1260af
commit 05e622e753
11 changed files with 215 additions and 174 deletions

View file

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

View file

@ -208,8 +208,7 @@ type (
ActiveSet BorderSetWrapper `toml:"active_set"`
}
GuildsTreeTheme struct {
AutoExpandFolders bool `toml:"auto_expand_folders"`
CommonTreeTheme struct {
Graphics bool `toml:"graphics"`
GraphicsColor string `toml:"graphics_color"`
@ -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"`

View file

@ -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 {
return m.Model.View(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")
}, 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
}

View file

@ -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
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 {
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}
}).
OnCancel(CancelMsg{})
return ui.Box(p, &m.cfg.Theme, true).Title("Channels")
}, 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 {

View file

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

View file

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

View file

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

View file

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

View file

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

77
internal/ui/tree/tree.go Normal file
View file

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

View file

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