Private
Public Access
1
0

Refactor TUI rendering handling and general cleanup

Improves render handling by moving the responsibility of laying out the
whole UI from each view and into the main `tui` model. Our `ViewModel`
interface has now diverged from bubbletea's `Model` and introduces
individual `Header`, `Content`, and `Footer` methods for rendering those
UI elements.

Also moved away from using value receivers on our Update and View
functions (as is common across Bubbletea) to pointer receivers, which
cleaned up some of the weirder aspects of the code (e.g. before we
essentially had no choice but to do our rendering in `Update` in order
to calculate and update the final height of the main content's
`viewport`).
This commit is contained in:
2024-09-22 23:34:53 +00:00
parent b7c89a4dd1
commit 676aa7b004
6 changed files with 122 additions and 165 deletions

View File

@@ -7,9 +7,11 @@ import (
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
"git.mlow.ca/mlow/lmcli/pkg/tui/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
"git.mlow.ca/mlow/lmcli/pkg/tui/views/chat"
"git.mlow.ca/mlow/lmcli/pkg/tui/views/conversations"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type LaunchOptions struct {
@@ -20,13 +22,18 @@ type LaunchOptions struct {
type Model struct {
App *model.AppModel
// window size
width int
height int
// errors we will display to the user and allow them to dismiss
errs []error
activeView shared.View
views map[shared.View]shared.ViewModel
}
func initialModel(ctx *lmcli.Context, opts LaunchOptions) Model {
sharedData := shared.ViewState{}
func initialModel(ctx *lmcli.Context, opts LaunchOptions) *Model {
app := &model.AppModel{
Ctx: ctx,
Conversation: opts.InitialConversation,
@@ -36,20 +43,24 @@ func initialModel(ctx *lmcli.Context, opts LaunchOptions) Model {
App: app,
activeView: opts.InitialView,
views: map[shared.View]shared.ViewModel{
shared.ViewChat: chat.Chat(app, sharedData),
shared.ViewConversations: conversations.Conversations(app, sharedData),
shared.ViewChat: chat.Chat(app),
shared.ViewConversations: conversations.Conversations(app),
},
}
return m
return &m
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
func (m *Model) Init() tea.Cmd {
cmds := []tea.Cmd{
func() tea.Msg {
return shared.MsgViewChange(m.activeView)
},
)
}
for _, v := range m.views {
cmds = append(cmds, v.Init())
}
return tea.Batch(cmds...)
}
func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
@@ -66,8 +77,10 @@ func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyMsg:
cmd := m.handleGlobalInput(msg)
if cmd != nil {
@@ -75,15 +88,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case shared.MsgViewChange:
m.activeView = shared.View(msg)
view := m.views[m.activeView]
var cmds []tea.Cmd
if !view.Initialized() {
cmds = append(cmds, view.Init())
}
cmds = append(cmds, tea.WindowSize(), shared.ViewEnter())
return m, tea.Batch(cmds...)
return m, tea.Batch(tea.WindowSize(), shared.ViewEnter())
case shared.MsgError:
m.errs = append(m.errs, msg)
}
view, cmd := m.views[m.activeView].Update(msg)
@@ -91,8 +98,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
func (m Model) View() string {
return m.views[m.activeView].View()
func (m *Model) View() string {
if m.width == 0 || m.height == 0 {
// we're dimensionless!
return ""
}
header := m.views[m.activeView].Header(m.width)
footer := m.views[m.activeView].Footer(m.width)
fixedUIHeight := tuiutil.Height(header) + tuiutil.Height(footer)
errBanners := make([]string, len(m.errs))
for idx, err := range m.errs {
errBanners[idx] = tuiutil.ErrorBanner(err, m.width)
fixedUIHeight += tuiutil.Height(errBanners[idx])
}
content := m.views[m.activeView].Content(m.width, m.height-fixedUIHeight)
sections := make([]string, 0, 4)
if header != "" {
sections = append(sections, header)
}
if content != "" {
sections = append(sections, content)
}
if len(errBanners) > 0 {
sections = append(sections, lipgloss.JoinVertical(lipgloss.Left, errBanners...))
}
if footer != "" {
sections = append(sections, footer)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
type LaunchOption func(*LaunchOptions)