refactor(ui): adopt tview Change and Keybind APIs

This commit is contained in:
ayn2op 2026-09-28 16:59:23 -04:00
parent 2b651a1459
commit 9dc509e300
16 changed files with 163 additions and 80 deletions

2
go.mod
View file

@ -15,7 +15,7 @@ require (
github.com/andybalholm/brotli v1.2.5
github.com/ayn2op/arikawa/v3 v3.0.0-20260904042054-7df757872273
github.com/ayn2op/ningen/v3 v3.0.1-0.20260904045623-5978c9be899f
github.com/ayn2op/tview v0.0.0-20260928003911-758a51d24cad
github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.5.0

2
go.sum
View file

@ -164,3 +164,5 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=

19
internal/config/help.go Normal file
View file

@ -0,0 +1,19 @@
package config
import (
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
)
var _ help.KeyMap = PickerKeybinds{}
func (k PickerKeybinds) ShortHelp() []keybind.Keybind {
return []keybind.Keybind{k.SelectUp.Keybind, k.SelectDown.Keybind, k.Select.Keybind, k.Cancel.Keybind}
}
func (k PickerKeybinds) FullHelp() [][]keybind.Keybind {
return [][]keybind.Keybind{
{k.SelectUp.Keybind, k.SelectDown.Keybind, k.SelectTop.Keybind, k.SelectBottom.Keybind},
{k.Select.Keybind, k.Cancel.Keybind},
}
}

View file

@ -24,15 +24,15 @@ func NewModel(cfg *config.Config) Model {
var _ tview.Model[Model] = Model{}
// actionMsg changes the picker.
type actionMsg picker.Action
// 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).
OnAction(func(a picker.Action) tview.Msg { return actionMsg(a) }).
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 {
@ -54,8 +54,8 @@ func (m *Model) SetItems(items []Item) {
}
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
if msg, ok := msg.(actionMsg); ok {
m.searchState.Perform(picker.Action(msg))
if msg, ok := msg.(changeMsg); ok {
m.searchState.Apply(picker.Change(msg))
}
return m, nil
}

View file

@ -24,15 +24,15 @@ func NewModel(cfg *config.Config) Model {
var _ tview.Model[Model] = Model{}
// actionMsg changes the picker.
type actionMsg picker.Action
// 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).
OnAction(func(a picker.Action) tview.Msg { return actionMsg(a) }).
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() {
@ -45,8 +45,8 @@ func (m Model) View() tview.Element {
}
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
if msg, ok := msg.(actionMsg); ok {
m.searchState.Perform(picker.Action(msg))
if msg, ok := msg.(changeMsg); ok {
m.searchState.Apply(picker.Change(msg))
}
return m, nil
}

View file

@ -50,7 +50,6 @@ var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+")
type Model struct {
title, footer string
editState textarea.EditState
keybinds textarea.Keybinds
placeholder string
disabled bool
@ -88,7 +87,7 @@ type SentMsg struct{}
type editorMsg string
// editMsg edits the composer's text.
type editMsg textarea.Action
type editMsg textarea.Change
var _ help.KeyMap = Model{}
@ -103,8 +102,6 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
mentionsList: mentionslist.NewModel(cfg),
height: 3,
}
c.keybinds = textarea.DefaultKeybinds()
c.keybinds.Newline = cfg.Keybinds.Composer.Newline.Keybind
return c
}
@ -153,9 +150,9 @@ func (v view) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
func (c *Model) box(focused bool) box.Widget {
text := textarea.New(&c.editState).
Placeholder(c.placeholder).
Keybinds(c.keybinds).
Keybind(c.editAction).
Focused(focused && !c.disabled).
OnAction(func(a textarea.Action) tview.Msg { return editMsg(a) })
OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) })
return ui.Box(text, &c.cfg.Theme, focused).Title(c.title).Footer(c.footer)
}
@ -254,7 +251,7 @@ func (c *Model) update(msg tview.Msg) tview.Cmd {
c.setText(string(msg))
return nil
case editMsg:
c.editState.Perform(textarea.Action(msg))
c.editState.Apply(textarea.Change(msg))
c.resizeForContent()
typingCmd := c.sendTyping()
if c.cfg.AutocompleteLimit > 0 {
@ -953,6 +950,15 @@ func (c *Model) canAttachFiles() bool {
return c.channel != nil && c.state.HasPermissions(c.channel.ID, discord.PermissionAttachFiles)
}
// editAction binds the configured newline key and textarea's other default keys.
func (c Model) editAction(key tview.KeyMsg) (textarea.Action, bool) {
if keybind.Matches(key, c.cfg.Keybinds.Composer.Newline.Keybind) {
return textarea.ActionNewline, true
}
action, ok := textarea.DefaultKeybind(key)
return action, ok && action != textarea.ActionNewline
}
func (c Model) ShortHelp() []keybind.Keybind {
if c.mentionsVisible {
cfg := c.cfg.Keybinds.MentionsList

View file

@ -46,7 +46,7 @@ func (m *Model) CurrentNode() *tree.Node {
}
// Msg moves or scrolls the tree.
type Msg tree.Action
type Msg tree.Change
// View shows the tree in a box titled Guilds.
func (m Model) View(focused bool) tview.Element {
@ -66,16 +66,9 @@ func (m Model) View(focused bool) tview.Element {
Graphics(theme.GuildsTree.Graphics).
GraphicsSet(set).
GraphicsStyle(tcell.StyleDefault.Foreground(tcell.GetColor(theme.GuildsTree.GraphicsColor))).
Keybinds(tree.Keybinds{
Up: kbs.SelectUp.Keybind,
Down: kbs.SelectDown.Keybind,
Top: kbs.SelectTop.Keybind,
Bottom: kbs.SelectBottom.Keybind,
MoveToParent: kbs.MoveToParentNode.Keybind,
Select: kbs.SelectCurrent.Keybind,
}).
Keybind(ui.TreeKeybind(kbs)).
Focused(focused).
OnAction(func(a tree.Action) tview.Msg { return Msg(a) })
OnChange(func(a tree.Change) tview.Msg { return Msg(a) })
return ui.Box(t, theme, focused).Title("Guilds")
}
@ -356,7 +349,7 @@ func (m *Model) update(msg tview.Msg) tview.Cmd {
case NavigateMsg:
return m.navigate(msg.ChannelID)
case Msg:
m.selectionState.Perform(tree.Action(msg))
m.selectionState.Apply(tree.Change(msg))
return nil
case tree.SelectedMsg:
m.selectionState.SetCurrentNode(msg.Node)

View file

@ -1,7 +1,6 @@
package chat
import (
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
)
@ -28,7 +27,7 @@ func (m Model) FullHelp() [][]keybind.Keybind {
func (m Model) activeKeyMap() help.KeyMap {
if m.pickerOpen() {
return ui.PickerKeybinds(m.cfg)
return m.cfg.Keybinds.Picker
}
switch m.focused {

View file

@ -15,22 +15,14 @@ type Model struct {
selectionState list.SelectionState
items []Item
entries []list.Item
keybinds list.Keybinds
}
// Msg moves the list.
type Msg list.Action
type Msg list.Change
func NewModel(cfg *config.Config) Model {
m := Model{cfg: cfg, selectionState: list.NewSelectionState()}
kbs := cfg.Keybinds.MentionsList
m.keybinds = list.Keybinds{
SelectUp: kbs.SelectUp.Keybind,
SelectDown: kbs.SelectDown.Keybind,
SelectTop: kbs.SelectTop.Keybind,
SelectBottom: kbs.SelectBottom.Keybind,
}
return m
}
@ -46,15 +38,15 @@ func (Model) Init() tview.Cmd { return nil }
func (m Model) listView() list.Widget {
return list.New(&m.selectionState, len(m.entries), func(i int) list.Item { return m.entries[i] }).
SelectedStyle(tcell.StyleDefault.Reverse(true)).
Keybinds(m.keybinds).
Keybind(ui.SelectionKeybind(m.cfg.Keybinds.MentionsList.SelectionKeybinds)).
// The list is only shown while mentions are being completed, so it takes its keys first.
Focused(true).
OnAction(func(a list.Action) tview.Msg { return Msg(a) })
OnChange(func(a list.Change) tview.Msg { return Msg(a) })
}
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
if msg, ok := msg.(Msg); ok {
m.selectionState.Perform(list.Action(msg))
m.selectionState.Apply(list.Change(msg))
}
return m, nil
}

View file

@ -56,7 +56,6 @@ type Model struct {
scrollBar scrollbar.Widget
scrollBarVisibility list.ScrollBarVisibility
keybinds list.Keybinds
cfg *config.Config
state *ningen.State
@ -83,12 +82,6 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
ml.selectionState.SetTrackEnd(true)
ml.selectionState.ScrollToEnd()
ml.keybinds = list.Keybinds{
ScrollUp: cfg.Keybinds.MessagesList.ScrollUp.Keybind,
ScrollDown: cfg.Keybinds.MessagesList.ScrollDown.Keybind,
ScrollTop: cfg.Keybinds.MessagesList.ScrollTop.Keybind,
ScrollBottom: cfg.Keybinds.MessagesList.ScrollBottom.Keybind,
}
ml.scrollBarVisibility = cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility
ml.scrollBar = scrollbar.New().
TrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style).
@ -99,7 +92,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
}
// listMsg moves or scrolls the messages list.
type listMsg list.Action
type listMsg list.Change
// View shows the messages in a box titled with the channel and footed with who is typing.
func (ml Model) View(focused bool) tview.Element {
@ -110,9 +103,9 @@ 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).
Keybinds(ml.keybinds).
Keybind(ui.ScrollKeybind(ml.cfg.Keybinds.MessagesList.ScrollKeybinds)).
Focused(focused).
OnAction(func(a list.Action) tview.Msg { return listMsg(a) })
OnChange(func(a list.Change) tview.Msg { return listMsg(a) })
}
func (ml *Model) cursor() int {
@ -903,7 +896,7 @@ func (ml *Model) update(msg tview.Msg) tview.Cmd {
case attachmentActionMsg:
return msg.Action
case listMsg:
ml.selectionState.Perform(list.Action(msg))
ml.selectionState.Apply(list.Change(msg))
ml.onRowCursorChanged(ml.cursor())
}
return nil

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

@ -0,0 +1,77 @@
package ui
import (
"github.com/ayn2op/discordo/internal/config"
"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.
func SelectionKeybind(kbs config.SelectionKeybinds) func(tview.KeyMsg) (list.Action, bool) {
return func(key tview.KeyMsg) (list.Action, bool) {
switch {
case keybind.Matches(key, kbs.SelectUp.Keybind):
return list.ActionSelectUp, true
case keybind.Matches(key, kbs.SelectDown.Keybind):
return list.ActionSelectDown, true
case keybind.Matches(key, kbs.SelectTop.Keybind):
return list.ActionSelectTop, true
case keybind.Matches(key, kbs.SelectBottom.Keybind):
return list.ActionSelectBottom, true
}
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

@ -32,7 +32,7 @@ type SubmitMsg struct{}
type editMsg struct {
index int
action textinput.Action
change textinput.Change
}
type nextMsg struct{}
@ -56,7 +56,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
case editMsg:
// Earlier copies of the model share the fields, so change a copy of them.
m.fields = slices.Clone(m.fields)
m.fields[msg.index].EditState.Perform(msg.action)
m.fields[msg.index].EditState.Apply(msg.change)
case nextMsg:
m.focus = (m.focus + 1) % count
case tview.KeyMsg:
@ -81,7 +81,7 @@ func (m Model) View() tview.Element {
input := textinput.New(&f.EditState).
Mask(f.Mask).
Focused(m.focus == i).
OnAction(func(a textinput.Action) tview.Msg { return editMsg{index: i, action: a} }).
OnChange(func(a textinput.Change) tview.Msg { return editMsg{index: i, change: a} }).
OnSubmit(nextMsg{})
layout = layout.Push(row.New(column.New(text.New(f.Label)).Width(tview.Fixed(labelWidth)), input).Height(tview.Fixed(1)))
}

View file

@ -1,10 +1,27 @@
package login
import (
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/tabs"
)
var (
previousTab = keybind.NewSingleKeybind("ctrl+h", "prev tab")
nextTab = keybind.NewSingleKeybind("ctrl+l", "next tab")
)
func tabAction(key tview.KeyMsg) (tabs.Action, bool) {
switch {
case keybind.Matches(key, previousTab):
return tabs.ActionPrevious, true
case keybind.Matches(key, nextTab):
return tabs.ActionNext, true
}
return 0, false
}
var _ help.KeyMap = Model{}
func (m Model) ShortHelp() []keybind.Keybind {
@ -30,10 +47,10 @@ func (m Model) FullHelp() [][]keybind.Keybind {
func (m Model) navHelp() []keybind.Keybind {
var nav []keybind.Keybind
if m.active > 0 {
nav = append(nav, m.keybinds.Previous)
nav = append(nav, previousTab)
}
if m.active < tabCount-1 {
nav = append(nav, m.keybinds.Next)
nav = append(nav, nextTab)
}
return nav
}

View file

@ -30,7 +30,6 @@ type Model struct {
qr qr.Model
token token.Model
active tab
keybinds tabs.Keybinds
}
var _ tview.Model[Model] = Model{}
@ -41,7 +40,6 @@ func NewModel(cfg *config.Config) Model {
password: password.NewModel(),
qr: qr.NewModel(),
token: token.NewModel(),
keybinds: tabs.DefaultKeybinds(),
}
}
@ -75,7 +73,7 @@ func (m Model) View() tview.Element {
t := tabs.New(m.password.Label(), m.qr.Label(), m.token.Label()).
Active(int(m.active)).
Content(content).
Keybinds(m.keybinds).
Keybind(tabAction).
OnSelect(func(i int) tview.Msg { return selectTabMsg(i) })
return ui.Box(t, &m.cfg.Theme, false)
}

View file

@ -29,7 +29,7 @@ type Model struct {
}
// scrollMsg scrolls the code.
type scrollMsg textview.Action
type scrollMsg textview.Change
// startMsg starts connecting to the Remote Auth Gateway.
type startMsg struct{}
@ -68,7 +68,7 @@ func (m *Model) update(msg tview.Msg) tview.Cmd {
}
return nil
case scrollMsg:
m.scrollState.Perform(textview.Action(msg))
m.scrollState.Apply(textview.Change(msg))
return nil
case connCreateMsg:
@ -147,7 +147,7 @@ func (m Model) View() tview.Element {
Alignment(tview.AlignmentCenter).
Height(tview.Fixed(len(m.code))).
Focused(true).
OnAction(func(a textview.Action) tview.Msg { return scrollMsg(a) }),
OnChange(func(a textview.Change) tview.Msg { return scrollMsg(a) }),
)
}

View file

@ -45,21 +45,8 @@ func Picker(items picker.Items, searchState *picker.SearchState, cfg *config.Con
Arrows(scrollbar.ArrowsBoth)
return picker.New(items, searchState).
ScrollBar(bar, cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility).
Keybinds(PickerKeybinds(cfg))
}
// PickerKeybinds returns the configured picker keybinds.
func PickerKeybinds(cfg *config.Config) picker.Keybinds {
kbs := cfg.Keybinds.Picker
return picker.Keybinds{
Cancel: kbs.Cancel.Keybind,
Select: kbs.Select.Keybind,
SelectUp: kbs.SelectUp.Keybind,
SelectDown: kbs.SelectDown.Keybind,
SelectTop: kbs.SelectTop.Keybind,
SelectBottom: kbs.SelectBottom.Keybind,
}
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.