From 6449f7d470bddf920a3e73031bfd0526ed616f0f Mon Sep 17 00:00:00 2001 From: ayn2op Date: Thu, 8 Oct 2026 19:01:30 -0400 Subject: [PATCH] refactor(ui): scroll the trees and lists with a viewport --- go.mod | 2 +- go.sum | 4 +-- internal/config/ui.go | 20 +++++------ internal/ui/chat/guildstree/model.go | 24 ++++++++++--- internal/ui/chat/guildstree/msg.go | 4 +-- internal/ui/chat/memberstree/model.go | 21 ++++++++++-- internal/ui/chat/memberstree/msg.go | 2 +- internal/ui/chat/mentionslist/model.go | 20 ++++++++--- internal/ui/chat/mentionslist/msg.go | 17 ++++++++-- internal/ui/chat/messageslist/model.go | 44 +++++++++++------------- internal/ui/chat/messageslist/msg.go | 6 +++- internal/ui/keybind.go | 18 ++++++++++ internal/ui/login/qr/model.go | 12 +++---- internal/ui/login/qr/msg.go | 4 +-- internal/ui/tree/tree.go | 47 ++++++++++++++++++-------- 15 files changed, 166 insertions(+), 79 deletions(-) diff --git a/go.mod b/go.mod index 22f7054..9fe1b01 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/andybalholm/brotli v1.2.6 github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e - github.com/ayn2op/tview v0.0.0-20261008190544-5da45c25c67d + github.com/ayn2op/tview v0.0.0-20261008230130-de4c692f9141 github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.5.0 diff --git a/go.sum b/go.sum index d4d4c49..7424113 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c h1:Qkl3W5RXzUQUR github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c/go.mod h1:SHqcr+EmZKeNtVKZybHJn4HNz+ufA1XbeKNSTigBCEE= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw= -github.com/ayn2op/tview v0.0.0-20261008190544-5da45c25c67d h1:nueCN+u4FjTTU2ZdyDfRG68eCwa9Oq4dQY3CMgRUZQc= -github.com/ayn2op/tview v0.0.0-20261008190544-5da45c25c67d/go.mod h1:sMf5NqBlGJjsaGCUycTqTyNG5vewJU6jq/3BdJIzbCU= +github.com/ayn2op/tview v0.0.0-20261008230130-de4c692f9141 h1:c3pb7UfbQqtmBn2HbJ+XRXJTdo/vHgIsK+CKuYoFPYU= +github.com/ayn2op/tview v0.0.0-20261008230130-de4c692f9141/go.mod h1:sMf5NqBlGJjsaGCUycTqTyNG5vewJU6jq/3BdJIzbCU= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= diff --git a/internal/config/ui.go b/internal/config/ui.go index a366030..cecff79 100644 --- a/internal/config/ui.go +++ b/internal/config/ui.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/ayn2op/tview" - "github.com/ayn2op/tview/list" "github.com/ayn2op/tview/scrollbar" + "github.com/ayn2op/tview/viewport" ) // enum pairs the names an option accepts with their values; a value is written back as the first of its names. @@ -56,13 +56,13 @@ var ( {"vertical", scrollbar.SymbolSetVertical()}, {"double_vertical", scrollbar.SymbolSetDoubleVertical()}, } - scrollBarVisibilities = enum[list.ScrollBarVisibility]{ - {"auto", list.ScrollBarVisibilityAutomatic}, - {"automatic", list.ScrollBarVisibilityAutomatic}, - {"always", list.ScrollBarVisibilityAlways}, - {"never", list.ScrollBarVisibilityNever}, - {"hidden", list.ScrollBarVisibilityNever}, - {"off", list.ScrollBarVisibilityNever}, + scrollBarVisibilities = enum[viewport.ScrollBarVisibility]{ + {"auto", viewport.ScrollBarVisibilityAutomatic}, + {"automatic", viewport.ScrollBarVisibilityAutomatic}, + {"always", viewport.ScrollBarVisibilityAlways}, + {"never", viewport.ScrollBarVisibilityNever}, + {"hidden", viewport.ScrollBarVisibilityNever}, + {"off", viewport.ScrollBarVisibilityNever}, } ) @@ -93,7 +93,7 @@ func (sw *SymbolSetWrapper) UnmarshalText(text []byte) (err error) { func (sw SymbolSetWrapper) MarshalText() ([]byte, error) { return symbolSets.text(sw.SymbolSet) } -type ScrollBarVisibilityWrapper struct{ list.ScrollBarVisibility } +type ScrollBarVisibilityWrapper struct{ viewport.ScrollBarVisibility } func (vw *ScrollBarVisibilityWrapper) UnmarshalText(text []byte) (err error) { vw.ScrollBarVisibility, err = scrollBarVisibilities.parse(string(text)) @@ -195,7 +195,7 @@ func defaultUI() UIConfig { Border: BorderUIConfig{Enabled: true, Padding: [4]int{0, 0, 1, 1}, NormalSet: round, ActiveSet: round}, GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true}, MembersTree: TreeUIConfig{Graphics: true}, - ScrollBar: ScrollBarUIConfig{SymbolSet: SymbolSetWrapper{boxDrawing}}, + ScrollBar: ScrollBarUIConfig{SymbolSet: SymbolSetWrapper{boxDrawing}, Visibility: ScrollBarVisibilityWrapper{viewport.ScrollBarVisibilityAutomatic}}, MessagesList: MessagesListUIConfig{ReplyIndicator: ">", ForwardedIndicator: "<"}, MentionsList: MentionsListUIConfig{MinWidth: 20}, } diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go index cf7c801..28356c7 100644 --- a/internal/ui/chat/guildstree/model.go +++ b/internal/ui/chat/guildstree/model.go @@ -14,6 +14,7 @@ import ( "github.com/ayn2op/tview" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/tree" + "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" ) @@ -22,6 +23,7 @@ type dmNode struct{} type Model struct { root *tree.Node selectionState tree.SelectionState + scrollState viewport.ScrollState cfg *config.Config state *ningen.State @@ -53,6 +55,7 @@ func (m Model) View(focused bool) tview.Widget { return uitree.New( m.root, m.selectionState, + m.scrollState, m.cfg, m.cfg.Theme.GuildsTree, m.cfg.UI.GuildsTree.Graphics, @@ -109,7 +112,7 @@ func (m *Model) rebuild(event *gateway.ReadyEvent) { m.createFolderNode(folder, guildsByID) } } - m.selectionState.SetCurrentNode(root) + m.setCurrentNode(root) } func (m Model) refreshReadStyles(event *read.UpdateEvent) { @@ -298,7 +301,13 @@ func (m *Model) collapseParentNode(node *tree.Node) { } parent := path[len(path)-2] parent.Collapse() - m.selectionState.SetCurrentNode(parent) + m.setCurrentNode(parent) +} + +// setCurrentNode selects node and scrolls to it. +func (m *Model) setCurrentNode(node *tree.Node) { + m.selectionState.SetCurrentNode(node) + m.scrollState.ScrollToTarget() } // Update returns m changed in response to msg and a command to run, or nil. @@ -323,9 +332,16 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { return m, m.navigate(msg.ChannelID) case Msg: m.selectionState.Apply(tree.Change(msg)) + m.scrollState.ScrollToTarget() + return m, nil + case uitree.ScrollMsg: + m.scrollState.Apply(msg.Change) + if msg.Msg != nil { + return m.Update(msg.Msg) + } return m, nil case SelectedMsg: - m.selectionState.SetCurrentNode(msg.Node) + m.setCurrentNode(msg.Node) return m, m.selectNode(msg.Node) case tview.KeyMsg: switch { @@ -348,7 +364,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } } m.expandPathToNode(node) - m.selectionState.SetCurrentNode(node) + m.setCurrentNode(node) } return m, nil case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind): diff --git a/internal/ui/chat/guildstree/msg.go b/internal/ui/chat/guildstree/msg.go index dc8b436..82a33cf 100644 --- a/internal/ui/chat/guildstree/msg.go +++ b/internal/ui/chat/guildstree/msg.go @@ -10,7 +10,7 @@ import ( "github.com/ayn2op/tview/tree" ) -// Msg moves or scrolls the tree. +// Msg moves the tree. type Msg tree.Change // SelectedMsg is sent when the user selects a node. @@ -37,7 +37,7 @@ func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd { return nil } m.expandPathToNode(node) - m.selectionState.SetCurrentNode(node) + m.setCurrentNode(node) if channel.Type == discord.GuildCategory { return nil } diff --git a/internal/ui/chat/memberstree/model.go b/internal/ui/chat/memberstree/model.go index 7e702a5..f7b6934 100644 --- a/internal/ui/chat/memberstree/model.go +++ b/internal/ui/chat/memberstree/model.go @@ -16,6 +16,7 @@ import ( "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/richtext" "github.com/ayn2op/tview/tree" + "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" ) @@ -40,6 +41,7 @@ func (r memberRef) String() string { type Model struct { root *tree.Node selectionState tree.SelectionState + scrollState viewport.ScrollState cfg *config.Config state *ningen.State @@ -85,7 +87,13 @@ func (m *Model) SetChannel(channel *discord.Channel) tview.Cmd { func (m Model) View(focused bool) tview.Widget { onChange := func(c tree.Change) tview.Msg { return Msg(c) } onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} } - return uitree.New(m.root, m.selectionState, m.cfg, m.cfg.Theme.MembersTree, m.cfg.UI.MembersTree.Graphics, m.cfg.Keybinds.MembersTree, focused, onChange, onSelect).Title("Members") + return uitree.New(m.root, m.selectionState, m.scrollState, m.cfg, m.cfg.Theme.MembersTree, m.cfg.UI.MembersTree.Graphics, m.cfg.Keybinds.MembersTree, focused, onChange, onSelect).Title("Members") +} + +// setCurrentNode selects node and scrolls to it. +func (m *Model) setCurrentNode(node *tree.Node) { + m.selectionState.SetCurrentNode(node) + m.scrollState.ScrollToTarget() } // Update returns m changed in response to msg and a command to run, or nil. @@ -106,9 +114,16 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { case Msg: m.selectionState.Apply(tree.Change(msg)) + m.scrollState.ScrollToTarget() return m, m.requestSelectedMembers() + case uitree.ScrollMsg: + m.scrollState.Apply(msg.Change) + if msg.Msg != nil { + return m.Update(msg.Msg) + } + return m, nil case SelectedMsg: - m.selectionState.SetCurrentNode(msg.Node) + m.setCurrentNode(msg.Node) if _, ok := msg.Node.Reference().(groupRef); ok { msg.Node.SetExpanded(!msg.Node.Expanded()) } @@ -124,7 +139,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { case keybind.Matches(msg, kbs.CollapseParentNode.Keybind): if parent := m.parent(m.selectionState.CurrentNode()); parent != nil { parent.Collapse() - m.selectionState.SetCurrentNode(parent) + m.setCurrentNode(parent) } return m, nil case keybind.Matches(msg, kbs.YankID.Keybind): diff --git a/internal/ui/chat/memberstree/msg.go b/internal/ui/chat/memberstree/msg.go index 037c867..948ad38 100644 --- a/internal/ui/chat/memberstree/msg.go +++ b/internal/ui/chat/memberstree/msg.go @@ -6,7 +6,7 @@ import ( "github.com/ayn2op/tview/tree" ) -// Msg moves or scrolls the tree. +// Msg moves the tree. type Msg tree.Change // SelectedMsg is sent when the user selects a node. diff --git a/internal/ui/chat/mentionslist/model.go b/internal/ui/chat/mentionslist/model.go index 137b273..fa9e2dd 100644 --- a/internal/ui/chat/mentionslist/model.go +++ b/internal/ui/chat/mentionslist/model.go @@ -6,6 +6,8 @@ import ( "github.com/ayn2op/tview" "github.com/ayn2op/tview/layout" "github.com/ayn2op/tview/list" + "github.com/ayn2op/tview/scrollbar" + "github.com/ayn2op/tview/viewport" "github.com/rivo/uniseg" ) @@ -13,6 +15,7 @@ import ( type Model struct { cfg *config.Config selectionState list.SelectionState + scrollState viewport.ScrollState items []Item } @@ -29,8 +32,8 @@ func (m Model) View() tview.Widget { func (Model) Init() tview.Cmd { return nil } -func (m Model) listView() list.Widget { - return list.New(m.selectionState, len(m.items), func(i int) tview.Widget { +func (m Model) listView() viewport.Widget { + l := list.New(m.selectionState, len(m.items), func(i int) tview.Widget { item := m.items[i] return row{text: item.DisplayText, style: item.Style} }). @@ -38,12 +41,20 @@ func (m Model) listView() list.Widget { 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). - OnChange(func(a list.Change) tview.Msg { return Msg(a) }) + OnChange(func(a list.Change) tview.Msg { return listMsg(a) }) + return viewport.New(l, m.scrollState). + Target(l.Target). + ScrollBar(scrollbar.New(), viewport.ScrollBarVisibilityAutomatic). + OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) }) } func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { - if msg, ok := msg.(Msg); ok { + switch msg := msg.(type) { + case listMsg: m.selectionState.Apply(list.Change(msg)) + m.scrollState.ScrollToTarget() + case scrollMsg: + m.scrollState.Apply(viewport.Change(msg)) } return m, nil } @@ -79,6 +90,7 @@ func (m Model) MaxDisplayWidth() int { func (m *Model) Rebuild() { m.selectionState.SetCursor(min(0, len(m.items)-1)) + m.scrollState.ScrollToTarget() } var _ tview.Model[Model] = Model{} diff --git a/internal/ui/chat/mentionslist/msg.go b/internal/ui/chat/mentionslist/msg.go index f2bdb79..489175b 100644 --- a/internal/ui/chat/mentionslist/msg.go +++ b/internal/ui/chat/mentionslist/msg.go @@ -1,6 +1,17 @@ package mentionslist -import "github.com/ayn2op/tview/list" +import ( + "github.com/ayn2op/tview/list" + "github.com/ayn2op/tview/viewport" +) -// Msg moves the list. -type Msg list.Change +// Msg is implemented by the messages that move or scroll the list. +type Msg interface{ mentionsList() } + +type listMsg list.Change + +func (listMsg) mentionsList() {} + +type scrollMsg viewport.Change + +func (scrollMsg) mentionsList() {} diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index b437a99..122b864 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -30,6 +30,7 @@ import ( "github.com/ayn2op/tview/richtext" "github.com/ayn2op/tview/scrollbar" "github.com/ayn2op/tview/textview" + "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3/color" "github.com/rivo/uniseg" @@ -48,6 +49,7 @@ type messageItem struct { type Model struct { selectionState list.SelectionState + scrollState viewport.ScrollState cfg *config.Config state *ningen.State @@ -75,7 +77,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model { renderer: markdown.NewRenderer(cfg), } - ml.selectionState.ScrollToEnd() + ml.scrollState.ScrollToEnd() return ml } @@ -96,43 +98,34 @@ func (m Model) title() string { return title } -func (m Model) listView(focused bool) list.Widget { +func (m Model) listView(focused bool) viewport.Widget { cfg, theme := m.cfg.UI.ScrollBar, m.cfg.Theme.ScrollBar bar := scrollbar.New(). SymbolSet(cfg.SymbolSet.SymbolSet). Style(theme.TrackStyle.Style). ThumbStyle(theme.ThumbStyle.Style) - return list.New(m.selectionState, len(m.items), m.buildItem). + // The model moves the selection itself, so the list is not focused and only takes clicks. + l := list.New(m.selectionState, len(m.items), m.buildItem). SelectedStyle(m.cfg.Theme.MessagesList.SelectedMessageStyle.Style). + OnChange(func(a list.Change) tview.Msg { return listMsg(a) }) + return viewport.New(l, m.scrollState). + Target(l.Target). ScrollBar(bar, cfg.Visibility.ScrollBarVisibility). TrackEnd(true). - Keybind(scrollKeybind(m.cfg.Keybinds.MessagesList.ScrollKeybinds)). + Keybind(ui.ScrollKeybind(m.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 { - return func(key tview.KeyMsg) list.Action { - switch { - case keybind.Matches(key, kbs.ScrollUp.Keybind): - return list.ActionScrollUp - case keybind.Matches(key, kbs.ScrollDown.Keybind): - return list.ActionScrollDown - case keybind.Matches(key, kbs.ScrollTop.Keybind): - return list.ActionScrollTop - case keybind.Matches(key, kbs.ScrollBottom.Keybind): - return list.ActionScrollBottom - } - return list.ActionNone - } + OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) }) } func (m Model) cursor() int { return m.selectionState.Cursor() } +// setCursor selects the item at index and scrolls to it if it changed, or selects none if it is negative. func (m *Model) setCursor(index int) { + if index >= 0 && index != m.cursor() { + m.scrollState.ScrollToTarget() + } m.selectionState.SetCursor(index) } @@ -150,7 +143,7 @@ func (m *Model) SetChannel(channel *discord.Channel, messages []discord.Message) // ShowNewest clears the selection and scrolls to the newest message, which then stays in view as messages arrive until the user scrolls up. func (m *Model) ShowNewest() { m.clearSelection() - m.selectionState.ScrollToEnd() + m.scrollState.ScrollToEnd() } // UpdateMessage replaces the shown message with the same ID. @@ -182,7 +175,8 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int { func (m *Model) reset() { m.items = nil m.selectionState = list.NewSelectionState() - m.selectionState.ScrollToEnd() + m.scrollState = viewport.ScrollState{} + m.scrollState.ScrollToEnd() } func (m *Model) setMessages(messages []discord.Message) { @@ -928,6 +922,8 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { // WezTerm and Konsole support kitty graphics but not Unicode placeholders. m.kitty = msg.Name == "kitty" || msg.Name == "ghostty" } + case scrollMsg: + m.scrollState.Apply(viewport.Change(msg)) case listMsg: top := m.atTop() m.selectionState.Apply(list.Change(msg)) diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go index 0a6c024..749a152 100644 --- a/internal/ui/chat/messageslist/msg.go +++ b/internal/ui/chat/messageslist/msg.go @@ -16,6 +16,7 @@ import ( "github.com/ayn2op/tview" "github.com/ayn2op/tview/list" "github.com/ayn2op/tview/picker" + "github.com/ayn2op/tview/viewport" "github.com/ncruces/zenity" "github.com/skratchdot/open-golang/open" "golang.design/x/clipboard" @@ -44,9 +45,12 @@ type EditMsg discord.Message // ShowAttachmentsMsg asks to pick one of the attachments and links of the selected message. type ShowAttachmentsMsg picker.Items -// listMsg moves or scrolls the messages list. +// listMsg moves the messages list. type listMsg list.Change +// scrollMsg scrolls the messages list. +type scrollMsg viewport.Change + func (m *Model) selectUp() tview.Cmd { cursor := m.cursor() if cursor == -1 { diff --git a/internal/ui/keybind.go b/internal/ui/keybind.go index 03e6137..6a15701 100644 --- a/internal/ui/keybind.go +++ b/internal/ui/keybind.go @@ -5,6 +5,7 @@ import ( "github.com/ayn2op/tview" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/list" + "github.com/ayn2op/tview/viewport" ) // SelectionKeybind binds kbs to moving a list's selection. @@ -23,3 +24,20 @@ func SelectionKeybind(kbs config.SelectionKeybinds) func(tview.KeyMsg) list.Acti return list.ActionNone } } + +// ScrollKeybind binds kbs to scrolling a viewport. +func ScrollKeybind(kbs config.ScrollKeybinds) func(tview.KeyMsg) viewport.Action { + return func(key tview.KeyMsg) viewport.Action { + switch { + case keybind.Matches(key, kbs.ScrollUp.Keybind): + return viewport.ActionUp + case keybind.Matches(key, kbs.ScrollDown.Keybind): + return viewport.ActionDown + case keybind.Matches(key, kbs.ScrollTop.Keybind): + return viewport.ActionTop + case keybind.Matches(key, kbs.ScrollBottom.Keybind): + return viewport.ActionBottom + } + return viewport.ActionNone + } +} diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go index a422843..fde7738 100644 --- a/internal/ui/login/qr/model.go +++ b/internal/ui/login/qr/model.go @@ -10,12 +10,13 @@ import ( "github.com/ayn2op/tview/center" "github.com/ayn2op/tview/richtext" "github.com/ayn2op/tview/textview" + "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" "github.com/skip2/go-qrcode" ) type Model struct { - scrollState textview.ScrollState + scrollState viewport.ScrollState conn *gatewayConn heartbeatInterval time.Duration @@ -55,7 +56,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } return m, nil case scrollMsg: - m.scrollState.Apply(textview.Change(msg)) + m.scrollState.Apply(viewport.Change(msg)) return m, nil case connCreateMsg: @@ -131,13 +132,10 @@ func halfBlock(top, bottom bool) rune { func (m Model) View() tview.Widget { code := m.code() return center.New( - textview.New(code). - ScrollState(&m.scrollState). - Wrap(false). - Alignment(tview.AlignmentCenter). + viewport.New(textview.New(code).Wrap(false).Alignment(tview.AlignmentCenter), m.scrollState). Height(layout.Fixed(len(code))). Focused(true). - OnChange(func(a textview.Change) tview.Msg { return scrollMsg(a) }), + OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) }), ) } diff --git a/internal/ui/login/qr/msg.go b/internal/ui/login/qr/msg.go index 16e574e..f2b0672 100644 --- a/internal/ui/login/qr/msg.go +++ b/internal/ui/login/qr/msg.go @@ -18,7 +18,7 @@ import ( "github.com/ayn2op/discordo/internal/gateway" "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/tview" - "github.com/ayn2op/tview/textview" + "github.com/ayn2op/tview/viewport" "github.com/gorilla/websocket" "github.com/skip2/go-qrcode" ) @@ -302,7 +302,7 @@ func exchangeTicket(fingerprint string, privateKey *rsa.PrivateKey, ticket strin } // scrollMsg scrolls the code. -type scrollMsg textview.Change +type scrollMsg viewport.Change // startMsg starts connecting to the Remote Auth Gateway. type startMsg struct{} diff --git a/internal/ui/tree/tree.go b/internal/ui/tree/tree.go index 2f8e328..509e564 100644 --- a/internal/ui/tree/tree.go +++ b/internal/ui/tree/tree.go @@ -7,12 +7,20 @@ import ( "github.com/ayn2op/tview" "github.com/ayn2op/tview/box" "github.com/ayn2op/tview/keybind" + "github.com/ayn2op/tview/layout" "github.com/ayn2op/tview/tree" + "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" ) -// New returns a boxed tree of the nodes under root, drawn with theme and graphics and moved with kbs, that turns changes and selected nodes into the messages onChange and onSelect return. -func New(root *tree.Node, selectionState tree.SelectionState, cfg *config.Config, theme config.TreeThemeConfig, graphics bool, kbs config.TreeKeybinds, focused bool, onChange func(tree.Change) tview.Msg, onSelect func(*tree.Node) tview.Msg) box.Widget { +// ScrollMsg scrolls the tree, before passing on Msg if the tree sent one. +type ScrollMsg struct { + Change viewport.Change + Msg tview.Msg +} + +// New returns a boxed tree of the nodes under root in a viewport. +func New(root *tree.Node, selectionState tree.SelectionState, scrollState viewport.ScrollState, cfg *config.Config, theme config.TreeThemeConfig, graphics bool, kbs config.TreeKeybinds, focused bool, onChange func(tree.Change) tview.Msg, onSelect func(*tree.Node) tview.Msg) box.Widget { set := cfg.UI.Border.NormalSet.BorderSet if focused { set = cfg.UI.Border.ActiveSet.BorderSet @@ -31,7 +39,15 @@ func New(root *tree.Node, selectionState tree.SelectionState, cfg *config.Config Focused(focused). OnChange(onChange). OnSelect(onSelect) - return ui.Box(t, cfg, focused) + v := viewport.New(t, scrollState). + Target(t.Target). + ContentWidth(t.RowsWidth). + OnChildMsg(func(c viewport.Change, msg tview.Msg) tview.Msg { return ScrollMsg{Change: c, Msg: msg} }). + Axes(layout.Axes{Width: true, Height: true}). + Keybind(bindScrollKeys(kbs)). + Focused(focused). + OnChange(func(c viewport.Change) tview.Msg { return ScrollMsg{Change: c} }) + return ui.Box(v, cfg, focused) } func bindKeys(kbs config.TreeKeybinds) func(tview.KeyMsg) tree.Action { @@ -49,19 +65,20 @@ func bindKeys(kbs config.TreeKeybinds) func(tview.KeyMsg) tree.Action { return tree.ActionMoveToParent case keybind.Matches(key, kbs.SelectCurrent.Keybind): return tree.ActionSelect - case keybind.Matches(key, kbs.ScrollUp.Keybind): - return tree.ActionScrollUp - case keybind.Matches(key, kbs.ScrollDown.Keybind): - return tree.ActionScrollDown - case keybind.Matches(key, kbs.ScrollTop.Keybind): - return tree.ActionScrollTop - case keybind.Matches(key, kbs.ScrollBottom.Keybind): - return tree.ActionScrollBottom - case keybind.Matches(key, kbs.ScrollLeft.Keybind): - return tree.ActionScrollLeft - case keybind.Matches(key, kbs.ScrollRight.Keybind): - return tree.ActionScrollRight } return tree.ActionNone } } + +func bindScrollKeys(kbs config.TreeKeybinds) func(tview.KeyMsg) viewport.Action { + scroll := ui.ScrollKeybind(kbs.ScrollKeybinds) + return func(key tview.KeyMsg) viewport.Action { + switch { + case keybind.Matches(key, kbs.ScrollLeft.Keybind): + return viewport.ActionLeft + case keybind.Matches(key, kbs.ScrollRight.Keybind): + return viewport.ActionRight + } + return scroll(key) + } +}