refactor(ui): update to tview's layout package and Element methods

This commit is contained in:
ayn2op 2026-10-01 22:45:52 -04:00
parent 5ee830bfaa
commit 31c5d185ca
14 changed files with 92 additions and 37 deletions

2
go.mod
View file

@ -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-20261001183738-08b3e2ae6452
github.com/ayn2op/tview v0.0.0-20261002024202-f7619deb5073
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.5.0

4
go.sum
View file

@ -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-20261001183738-08b3e2ae6452 h1:DXlBoek+AEXAxZVnOSu/ld06rwr+e7IsalIpAdegP8I=
github.com/ayn2op/tview v0.0.0-20261001183738-08b3e2ae6452/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
github.com/ayn2op/tview v0.0.0-20261002024202-f7619deb5073 h1:ipmYpvyH7prpXsLYDYWFAgXLC++xr8ss6x7nlgCOhvU=
github.com/ayn2op/tview v0.0.0-20261002024202-f7619deb5073/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
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=

View file

@ -4,6 +4,7 @@ import (
"bytes"
"cmp"
"context"
"github.com/ayn2op/tview/layout"
"io"
"log/slog"
"regexp"
@ -117,6 +118,14 @@ type view struct {
focused bool
}
func (v view) Size() (width, height layout.Length) {
return v.box(v.focused).Size()
}
func (v view) Layout(limits layout.Limits) layout.Size {
return v.box(v.focused).Layout(limits)
}
func (v view) Draw(screen tview.Screen, area tview.Rectangle) {
v.box(v.focused).Draw(screen, area)
}
@ -492,6 +501,14 @@ type mentionsPopup struct {
c *Model
}
// Size returns Fill, as the popup places the list within its whole area.
func (mentionsPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
// Layout returns the size of limits, as the popup places the list within its whole area.
func (mentionsPopup) Layout(limits layout.Limits) layout.Size {
return layout.Atomic(limits, layout.Fill, layout.Fill)
}
func (p mentionsPopup) Draw(screen tview.Screen, area tview.Rectangle) {
p.c.mentionsList.View().Draw(screen, p.area(area))
}

View file

@ -50,7 +50,7 @@ func (m *Model) CurrentNode() *tree.Node {
func (m Model) View(focused bool) tview.Element {
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.GuildsTree.CommonTreeTheme, m.cfg.Keybinds.GuildsTree, focused, onChange, onSelect).Title("Guilds")
return uitree.New(m.root, m.selectionState, m.cfg, m.cfg.Theme.GuildsTree.CommonTreeTheme, m.cfg.Keybinds.GuildsTree, focused, onChange, onSelect).Title("Guilds")
}
func (m *Model) reset() *tree.Node {

View file

@ -85,7 +85,7 @@ func (m *Model) SetChannel(channel *discord.Channel) tview.Cmd {
func (m Model) View(focused bool) tview.Element {
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.Keybinds.MembersTree, focused, onChange, onSelect).Title("Members")
return uitree.New(m.root, m.selectionState, m.cfg, m.cfg.Theme.MembersTree, m.cfg.Keybinds.MembersTree, focused, onChange, onSelect).Title("Members")
}
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {

View file

@ -4,6 +4,7 @@ import (
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/list"
"github.com/gdamore/tcell/v3"
"github.com/rivo/uniseg"
@ -30,7 +31,7 @@ func (m Model) View() tview.Element {
func (Model) Init() tview.Cmd { return nil }
func (m Model) listView() list.Widget {
return list.New(&m.selectionState, len(m.items), func(i int) list.Item {
return list.New(m.selectionState, len(m.items), func(i int) tview.Element {
item := m.items[i]
return row{text: item.DisplayText, style: item.Style}
}).
@ -89,7 +90,13 @@ type row struct {
style tcell.Style
}
func (row) Rows(int) int { return 1 }
// Size returns Fill and a height of one row.
func (row) Size() (width, height layout.Length) { return layout.Fill, layout.Fixed(1) }
// Layout returns a height of one row.
func (row) Layout(limits layout.Limits) layout.Size {
return layout.Atomic(limits, layout.Fill, layout.Fixed(1))
}
func (r row) Draw(screen tview.Screen, area tview.Rectangle) {
for x := area.X; x < area.X+area.Width; x++ {

View file

@ -2,6 +2,7 @@ package messageslist
import (
"fmt"
"github.com/ayn2op/tview/layout"
"io"
"log/slog"
"net/http"
@ -36,7 +37,7 @@ import (
type messageItem struct {
message discord.Message
view list.Item
view tview.Element
separator bool
timestamp discord.Timestamp
@ -106,7 +107,7 @@ func (ml *Model) title() string {
}
func (ml *Model) listView(focused bool) list.Widget {
return list.New(&ml.selectionState, len(ml.items), ml.buildItem).
return list.New(ml.selectionState, len(ml.items), ml.buildItem).
SelectedStyle(ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style).
ScrollBar(ml.scrollBar, ml.scrollBarVisibility).
Keybind(scrollKeybind(ml.cfg.Keybinds.MessagesList.ScrollKeybinds)).
@ -239,7 +240,7 @@ func (ml *Model) clearSelection() {
ml.setCursor(-1)
}
func (ml *Model) buildItem(index int) list.Item {
func (ml *Model) buildItem(index int) tview.Element {
if index < 0 || index >= len(ml.items) {
return nil
}
@ -284,14 +285,20 @@ func (v *messageView) at(width int) textview.Widget {
return textview.New(v.lines).Wrap(false)
}
func (v *messageView) Rows(width int) int {
v.at(width)
rows := len(v.lines)
for _, preview := range v.images {
_, height := preview.Size()
rows += height.Cells()
}
return rows
// Size returns Fill and Shrink, as the message is as tall as its content.
func (v *messageView) Size() (width, height layout.Length) { return layout.Fill, layout.Shrink }
// Layout returns the size of the message within limits, as tall as the rows it takes at the width of limits.
func (v *messageView) Layout(limits layout.Limits) layout.Size {
return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size {
v.at(limits.Max.Width)
rows := len(v.lines)
for _, preview := range v.images {
_, height := preview.Size()
rows += height.Cells()
}
return layout.Size{Height: rows}
})
}
func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) {
@ -312,7 +319,13 @@ type dateSeparator struct {
style tcell.Style
}
func (dateSeparator) Rows(int) int { return 1 }
// Size returns Fill and a height of one row.
func (dateSeparator) Size() (width, height layout.Length) { return layout.Fill, layout.Fixed(1) }
// Layout returns a height of one row.
func (dateSeparator) Layout(limits layout.Limits) layout.Size {
return layout.Atomic(limits, layout.Fill, layout.Fixed(1))
}
func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) {
line := d.date

View file

@ -3,6 +3,7 @@ package chat
import (
"cmp"
"fmt"
"github.com/ayn2op/tview/layout"
"log/slog"
"time"
@ -351,6 +352,14 @@ type paneElement struct {
child tview.Element
}
func (p paneElement) Size() (width, height layout.Length) {
return p.child.Size()
}
func (p paneElement) Layout(limits layout.Limits) layout.Size {
return p.child.Layout(limits)
}
func (p paneElement) Draw(screen tview.Screen, area tview.Rectangle) {
p.child.Draw(screen, area)
}
@ -419,7 +428,7 @@ func (m Model) View() tview.Element {
var right tview.Element = column.New(
m.paneView(paneMessagesList),
column.New(m.paneView(paneComposer)).Height(tview.Fixed(m.composer.Height())),
column.New(m.paneView(paneComposer)).Height(layout.Fixed(m.composer.Height())),
)
if mentions := m.composer.MentionsView(); mentions != nil {
right = stack.New(right, mentions)
@ -429,15 +438,15 @@ func (m Model) View() tview.Element {
if m.membersTreeShown() {
width := m.cfg.MembersTree.WidthPercent
main = row.New(
column.New(main).Width(tview.FillPortion(100-width)),
column.New(m.paneView(paneMembersTree)).Width(tview.FillPortion(width)),
column.New(main).Width(layout.FillPortion(100-width)),
column.New(m.paneView(paneMembersTree)).Width(layout.FillPortion(width)),
)
}
if m.guildsTreeVisible {
width := m.cfg.Sidebar.WidthPercent
main = row.New(
column.New(m.paneView(paneGuildsTree)).Width(tview.FillPortion(width)),
column.New(main).Width(tview.FillPortion(100-width)),
column.New(m.paneView(paneGuildsTree)).Width(layout.FillPortion(width)),
column.New(main).Width(layout.FillPortion(100-width)),
)
}
@ -448,7 +457,7 @@ func (m Model) View() tview.Element {
// The panes behind the picker take no input.
inert.New(main),
backdrop.New().Style(m.cfg.Theme.Dialog.BackgroundStyle.Style),
center.New(column.New(picker).Width(tview.Fixed(m.cfg.Picker.Width)).Height(tview.Fixed(m.cfg.Picker.Height))),
center.New(column.New(picker).Width(layout.Fixed(m.cfg.Picker.Width)).Height(layout.Fixed(m.cfg.Picker.Height))),
)
}

View file

@ -4,6 +4,7 @@ import (
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/button"
"github.com/ayn2op/tview/column"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/textinput"
@ -60,7 +61,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
}
func (m Model) View() tview.Element {
labelWidth := tview.Fixed(len("Password") + 1)
labelWidth := layout.Fixed(len("Password") + 1)
login := textinput.New(&m.login).
Focused(m.focus == focusLogin).
OnChange(func(c textinput.Change) tview.Msg { return loginMsg(c) }).
@ -72,13 +73,13 @@ func (m Model) View() tview.Element {
OnSubmit(focusMsg(focusButton))
submit := button.New().
Label("Login").
Width(tview.Fixed(len("Login") + 4)).
Height(tview.Fixed(1)).
Width(layout.Fixed(len("Login") + 4)).
Height(layout.Fixed(1)).
Focused(m.focus == focusButton).
OnClick(submitMsg{})
return column.New(
row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(tview.Fixed(1)),
row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(tview.Fixed(1)),
row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(layout.Fixed(1)),
row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(layout.Fixed(1)),
submit,
).Spacing(1)
}

View file

@ -2,6 +2,7 @@ package qr
import (
"crypto/rsa"
"github.com/ayn2op/tview/layout"
"strings"
"time"
@ -140,7 +141,7 @@ func (m Model) View() tview.Element {
ScrollState(&m.scrollState).
Wrap(false).
Alignment(tview.AlignmentCenter).
Height(tview.Fixed(len(m.code))).
Height(layout.Fixed(len(m.code))).
Focused(true).
OnChange(func(a textview.Change) tview.Msg { return scrollMsg(a) }),
)

View file

@ -4,6 +4,7 @@ import (
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/button"
"github.com/ayn2op/tview/column"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/textinput"
@ -53,12 +54,12 @@ func (m Model) View() tview.Element {
OnSubmit(focusButtonMsg{})
submit := button.New().
Label("Login").
Width(tview.Fixed(len("Login") + 4)).
Height(tview.Fixed(1)).
Width(layout.Fixed(len("Login") + 4)).
Height(layout.Fixed(1)).
Focused(m.buttonFocused).
OnClick(submitMsg{})
return column.New(
row.New(column.New(text.New("Token")).Width(tview.Fixed(len("Token")+1)), input).Height(tview.Fixed(1)),
row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Fixed(1)),
submit,
).Spacing(1)
}

View file

@ -1,6 +1,7 @@
package root
import (
"github.com/ayn2op/tview/layout"
"os"
"github.com/ayn2op/discordo/internal/config"
@ -144,6 +145,11 @@ func (m *Model) updateScreen(msg tview.Msg) tview.Cmd {
return cmd
}
// helpHeight returns the rows the help takes at an unlimited width, as the width of the screen is not known in View.
func (m Model) helpHeight() int {
return m.helpView().Layout(layout.Limits{Infinite: layout.Axes{Height: true}}).Height
}
// View shows the inner model above help, with the modal dialog on top of both when one is open.
func (m Model) View() tview.Element {
var innerView, helpView tview.Element
@ -155,7 +161,7 @@ func (m Model) View() tview.Element {
}
if m.helpVisible {
padded := box.New(m.helpView()).Padding(0, 0, m.cfg.Help.Padding[0], m.cfg.Help.Padding[1])
helpView = column.New(padded).Height(tview.Fixed(max(m.helpView().Rows(0), 1)))
helpView = column.New(padded).Height(layout.Fixed(max(m.helpHeight(), 1)))
}
content := column.New(innerView, helpView)
if m.modal == nil {

View file

@ -21,7 +21,7 @@ func TestNewModel(t *testing.T) {
if !m.helpVisible {
t.Fatal("help is hidden")
}
if height := m.helpView().Rows(0); height != 1 {
if height := m.helpHeight(); height != 1 {
t.Fatalf("height = %d, want 1", height)
}

View file

@ -12,7 +12,7 @@ import (
)
// New returns a boxed tree of the nodes under root, drawn with theme 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.CommonTreeTheme, kbs config.TreeKeybinds, focused bool, onChange func(tree.Change) tview.Msg, onSelect func(*tree.Node) tview.Msg) box.Widget {
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, onSelect func(*tree.Node) tview.Msg) box.Widget {
set := cfg.Theme.Border.NormalSet.BorderSet
if focused {
set = cfg.Theme.Border.ActiveSet.BorderSet