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:
Matt Low 2024-09-22 23:34:53 +00:00
parent b7c89a4dd1
commit 676aa7b004
6 changed files with 122 additions and 165 deletions

View File

@ -9,15 +9,12 @@ import (
type ViewModel interface { type ViewModel interface {
Init() tea.Cmd Init() tea.Cmd
Update(tea.Msg) (ViewModel, tea.Cmd) Update(tea.Msg) (ViewModel, tea.Cmd)
View() string
Initialized() bool // Return whether this view is initialized
}
type ViewState struct { // View methods
Initialized bool Header(width int) string
Width int // Render the view's main content into a container of the given dimensions
Height int Content(width, height int) string
Err error Footer(width int) string
} }
type View int type View int
@ -29,22 +26,12 @@ const (
//StateHelp //StateHelp
) )
// a convenience struct for holding rendered content for indiviudal UI
// elements
type Sections struct {
Header string
Content string
Error string
Input string
Footer string
}
type ( type (
// send to change the current state // send to change the current state
MsgViewChange View MsgViewChange View
// sent to a state when it is entered // sent to a state when it is entered
MsgViewEnter struct{} MsgViewEnter struct{}
// sent when an error occurs // sent when a recoverable error occurs (displayed to user)
MsgError error MsgError error
// sent when the view has handled a key input // sent when the view has handled a key input
MsgKeyHandled tea.KeyMsg MsgKeyHandled tea.KeyMsg

View File

@ -7,9 +7,11 @@ import (
"git.mlow.ca/mlow/lmcli/pkg/lmcli" "git.mlow.ca/mlow/lmcli/pkg/lmcli"
"git.mlow.ca/mlow/lmcli/pkg/tui/model" "git.mlow.ca/mlow/lmcli/pkg/tui/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared" "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/chat"
"git.mlow.ca/mlow/lmcli/pkg/tui/views/conversations" "git.mlow.ca/mlow/lmcli/pkg/tui/views/conversations"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
type LaunchOptions struct { type LaunchOptions struct {
@ -20,13 +22,18 @@ type LaunchOptions struct {
type Model struct { type Model struct {
App *model.AppModel 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 activeView shared.View
views map[shared.View]shared.ViewModel views map[shared.View]shared.ViewModel
} }
func initialModel(ctx *lmcli.Context, opts LaunchOptions) Model { func initialModel(ctx *lmcli.Context, opts LaunchOptions) *Model {
sharedData := shared.ViewState{}
app := &model.AppModel{ app := &model.AppModel{
Ctx: ctx, Ctx: ctx,
Conversation: opts.InitialConversation, Conversation: opts.InitialConversation,
@ -36,20 +43,24 @@ func initialModel(ctx *lmcli.Context, opts LaunchOptions) Model {
App: app, App: app,
activeView: opts.InitialView, activeView: opts.InitialView,
views: map[shared.View]shared.ViewModel{ views: map[shared.View]shared.ViewModel{
shared.ViewChat: chat.Chat(app, sharedData), shared.ViewChat: chat.Chat(app),
shared.ViewConversations: conversations.Conversations(app, sharedData), shared.ViewConversations: conversations.Conversations(app),
}, },
} }
return m return &m
} }
func (m Model) Init() tea.Cmd { func (m *Model) Init() tea.Cmd {
return tea.Batch( cmds := []tea.Cmd{
func() tea.Msg { func() tea.Msg {
return shared.MsgViewChange(m.activeView) 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 { func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
@ -66,8 +77,10 @@ func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
return nil 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) { switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyMsg: case tea.KeyMsg:
cmd := m.handleGlobalInput(msg) cmd := m.handleGlobalInput(msg)
if cmd != nil { if cmd != nil {
@ -75,15 +88,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case shared.MsgViewChange: case shared.MsgViewChange:
m.activeView = shared.View(msg) m.activeView = shared.View(msg)
view := m.views[m.activeView] return m, tea.Batch(tea.WindowSize(), shared.ViewEnter())
case shared.MsgError:
var cmds []tea.Cmd m.errs = append(m.errs, msg)
if !view.Initialized() {
cmds = append(cmds, view.Init())
}
cmds = append(cmds, tea.WindowSize(), shared.ViewEnter())
return m, tea.Batch(cmds...)
} }
view, cmd := m.views[m.activeView].Update(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 return m, cmd
} }
func (m Model) View() string { func (m *Model) View() string {
return m.views[m.activeView].View() 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) type LaunchOption func(*LaunchOptions)

View File

@ -5,7 +5,6 @@ import (
"git.mlow.ca/mlow/lmcli/pkg/api" "git.mlow.ca/mlow/lmcli/pkg/api"
"git.mlow.ca/mlow/lmcli/pkg/tui/model" "git.mlow.ca/mlow/lmcli/pkg/tui/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
"github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textarea"
@ -72,11 +71,10 @@ const (
) )
type Model struct { type Model struct {
*shared.ViewState
shared.Sections
// App state // App state
App *model.AppModel App *model.AppModel
Height int
Width int
// Chat view state // Chat view state
state state // current overall status of the view state state // current overall status of the view
@ -106,14 +104,9 @@ type Model struct {
elapsed time.Duration elapsed time.Duration
} }
func (m Model) Initialized() bool { func Chat(app *model.AppModel) *Model {
return m.ViewState.Initialized
}
func Chat(app *model.AppModel, shared shared.ViewState) shared.ViewModel {
m := Model{ m := Model{
App: app, App: app,
ViewState: &shared,
state: idle, state: idle,
persistence: true, persistence: true,
@ -166,12 +159,10 @@ func Chat(app *model.AppModel, shared shared.ViewState) shared.ViewModel {
m.input.FocusedStyle.CursorLine = lipgloss.NewStyle() m.input.FocusedStyle.CursorLine = lipgloss.NewStyle()
m.input.FocusedStyle.Base = inputFocusedStyle m.input.FocusedStyle.Base = inputFocusedStyle
m.input.BlurredStyle.Base = inputBlurredStyle m.input.BlurredStyle.Base = inputBlurredStyle
return &m
return m
} }
func (m Model) Init() tea.Cmd { func (m *Model) Init() tea.Cmd {
m.ViewState.Initialized = true
return tea.Batch( return tea.Batch(
m.waitForResponseChunk(), m.waitForResponseChunk(),
) )

View File

@ -47,7 +47,7 @@ func (m *Model) updateContent() {
} }
} }
func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
inputHandled := false inputHandled := false
var cmds []tea.Cmd var cmds []tea.Cmd
@ -169,8 +169,8 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
m.updateContent() m.updateContent()
case msgChatResponseError: case msgChatResponseError:
m.state = idle m.state = idle
m.ViewState.Err = error(msg)
m.updateContent() m.updateContent()
return m, shared.WrapError(msg)
case msgToolResults: case msgToolResults:
last := len(m.App.Messages) - 1 last := len(m.App.Messages) - 1
if last < 0 { if last < 0 {
@ -249,24 +249,6 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
} }
} }
// update views once window dimensions are known
if m.Width > 0 {
m.Header = m.headerView()
m.Footer = m.footerView()
m.Error = tuiutil.ErrorBanner(m.Err, m.Width)
fixedHeight := tuiutil.Height(m.Header) + tuiutil.Height(m.Error) + tuiutil.Height(m.Footer)
// calculate clamped input height to accomodate input text
// minimum 4 lines, maximum half of content area
newHeight := max(4, min((m.Height-fixedHeight-1)/2, m.input.LineCount()))
m.input.SetHeight(newHeight)
m.Input = m.input.View()
// remaining height towards content
m.content.Height = m.Height - fixedHeight - tuiutil.Height(m.Input)
m.Content = m.content.View()
}
// this is a pretty nasty hack to ensure the input area viewport doesn't // this is a pretty nasty hack to ensure the input area viewport doesn't
// scroll below its content, which can happen when the input viewport // scroll below its content, which can happen when the input viewport
// height has grown, or previously entered lines have been deleted // height has grown, or previously entered lines have been deleted

View File

@ -39,29 +39,6 @@ var (
footerStyle = lipgloss.NewStyle() footerStyle = lipgloss.NewStyle()
) )
func (m Model) View() string {
if m.Width == 0 {
return ""
}
sections := make([]string, 0, 6)
if m.Header != "" {
sections = append(sections, m.Header)
}
sections = append(sections, m.Content)
if m.Error != "" {
sections = append(sections, m.Error)
}
sections = append(sections, m.Input)
if m.Footer != "" {
sections = append(sections, m.Footer)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
func (m *Model) renderMessageHeading(i int, message *api.Message) string { func (m *Model) renderMessageHeading(i int, message *api.Message) string {
icon := "" icon := ""
friendly := message.Role.FriendlyRole() friendly := message.Role.FriendlyRole()
@ -254,7 +231,20 @@ func (m *Model) conversationMessagesView() string {
return sb.String() return sb.String()
} }
func (m *Model) headerView() string { func (m *Model) Content(width, height int) string {
// calculate clamped input height to accomodate input text
// minimum 4 lines, maximum half of content area
inputHeight := max(4, min(height/2, m.input.LineCount()))
m.input.SetHeight(inputHeight)
input := m.input.View()
// remaining height towards content
m.content.Width, m.content.Height = width, height - tuiutil.Height(input)
content := m.content.View()
return lipgloss.JoinVertical(lipgloss.Left, content, input)
}
func (m *Model) Header(width int) string {
titleStyle := lipgloss.NewStyle().Bold(true) titleStyle := lipgloss.NewStyle().Bold(true)
var title string var title string
if m.App.Conversation != nil && m.App.Conversation.Title != "" { if m.App.Conversation != nil && m.App.Conversation.Title != "" {
@ -262,12 +252,12 @@ func (m *Model) headerView() string {
} else { } else {
title = "Untitled" title = "Untitled"
} }
title = tuiutil.TruncateToCellWidth(title, m.Width-styles.Header.GetHorizontalPadding(), "...") title = tuiutil.TruncateToCellWidth(title, width-styles.Header.GetHorizontalPadding(), "...")
header := titleStyle.Render(title) header := titleStyle.Render(title)
return styles.Header.Width(m.Width).Render(header) return styles.Header.Width(width).Render(header)
} }
func (m *Model) footerView() string { func (m *Model) Footer(width int) string {
segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true) segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true)
segmentSeparator := "|" segmentSeparator := "|"
@ -305,7 +295,7 @@ func (m *Model) footerView() string {
right := strings.Join(rightSegments, segmentSeparator) right := strings.Join(rightSegments, segmentSeparator)
totalWidth := lipgloss.Width(left) + lipgloss.Width(right) totalWidth := lipgloss.Width(left) + lipgloss.Width(right)
remaining := m.Width - totalWidth remaining := width - totalWidth
var padding string var padding string
if remaining > 0 { if remaining > 0 {
@ -314,7 +304,7 @@ func (m *Model) footerView() string {
footer := left + padding + right footer := left + padding + right
if remaining < 0 { if remaining < 0 {
footer = tuiutil.TruncateToCellWidth(footer, m.Width, "...") footer = tuiutil.TruncateToCellWidth(footer, width, "...")
} }
return footerStyle.Width(m.Width).Render(footer) return footerStyle.Width(width).Render(footer)
} }

View File

@ -27,29 +27,24 @@ type (
) )
type Model struct { type Model struct {
*shared.ViewState App *model.AppModel
shared.Sections width int
height int
App *model.AppModel
cursor int
cursor int
itemOffsets []int // conversation y offsets itemOffsets []int // conversation y offsets
content viewport.Model content viewport.Model
confirmPrompt bubbles.ConfirmPrompt confirmPrompt bubbles.ConfirmPrompt
} }
func Conversations(app *model.AppModel, shared shared.ViewState) Model { func Conversations(app *model.AppModel) *Model {
viewport.New(0, 0)
m := Model{ m := Model{
App: app, App: app,
ViewState: &shared, content: viewport.New(0, 0),
content: viewport.New(0, 0),
} }
return m return &m
}
func (m Model) Initialized() bool {
return m.ViewState.Initialized
} }
func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd { func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
@ -129,12 +124,11 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
return nil return nil
} }
func (m Model) Init() tea.Cmd { func (m *Model) Init() tea.Cmd {
m.ViewState.Initialized = true return nil
return m.loadConversations()
} }
func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
isInput := false isInput := false
inputHandled := false inputHandled := false
@ -144,15 +138,14 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
isInput = true isInput = true
cmd := m.handleInput(msg) cmd := m.handleInput(msg)
if cmd != nil { if cmd != nil {
inputHandled = true
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
inputHandled = true
} }
case shared.MsgViewEnter: case shared.MsgViewEnter:
cmds = append(cmds, m.loadConversations()) cmds = append(cmds, m.loadConversations())
m.content.SetContent(m.renderConversationList()) m.content.SetContent(m.renderConversationList())
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.Width, m.Height = msg.Width, msg.Height m.width, m.height = msg.Width, msg.Height
m.content.Width = msg.Width
m.content.SetContent(m.renderConversationList()) m.content.SetContent(m.renderConversationList())
case msgConversationsLoaded: case msgConversationsLoaded:
m.App.Conversations = msg m.App.Conversations = msg
@ -171,26 +164,13 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
} }
if !isInput || !inputHandled { if !isInput || !inputHandled {
var cmd tea.Cmd content, cmd := m.content.Update(msg)
m.content, cmd = m.content.Update(msg) m.content = content
if cmd != nil { if cmd != nil {
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
} }
if m.Width > 0 {
wrap := lipgloss.NewStyle().Width(m.Width)
m.Header = m.headerView()
m.Footer = "" // TODO: "Press ? for help"
if m.confirmPrompt.Focused() {
m.Footer = wrap.Render(m.confirmPrompt.View())
}
m.Error = tuiutil.ErrorBanner(m.Err, m.Width)
fixedHeight := tuiutil.Height(m.Header) + tuiutil.Height(m.Error) + tuiutil.Height(m.Footer)
m.content.Height = m.Height - fixedHeight
m.Content = m.content.View()
}
if len(cmds) > 0 { if len(cmds) > 0 {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
@ -218,32 +198,22 @@ func (m *Model) deleteConversation(conv api.Conversation) tea.Cmd {
} }
} }
func (m Model) View() string { func (m *Model) Header(width int) string {
if m.Width == 0 {
return ""
}
sections := make([]string, 0, 6)
if m.Header != "" {
sections = append(sections, m.Header)
}
sections = append(sections, m.Content)
if m.Error != "" {
sections = append(sections, m.Error)
}
if m.Footer != "" {
sections = append(sections, m.Footer)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
func (m *Model) headerView() string {
titleStyle := lipgloss.NewStyle().Bold(true) titleStyle := lipgloss.NewStyle().Bold(true)
header := titleStyle.Render("Conversations") header := titleStyle.Render("Conversations")
return styles.Header.Width(m.Width).Render(header) return styles.Header.Width(width).Render(header)
}
func (m *Model) Content(width int, height int) string {
m.content.Width, m.content.Height = width, height
return m.content.View()
}
func (m *Model) Footer(width int) string {
if m.confirmPrompt.Focused() {
return lipgloss.NewStyle().Width(width).Render(m.confirmPrompt.View())
}
return ""
} }
func (m *Model) renderConversationList() string { func (m *Model) renderConversationList() string {
@ -321,7 +291,7 @@ func (m *Model) renderConversationList() string {
tStyle = tStyle.Inherit(selectedStyle) tStyle = tStyle.Inherit(selectedStyle)
} }
title := tStyle.Width(m.Width - 3).PaddingLeft(2).Render(c.Conv.Title) title := tStyle.Width(m.width - 3).PaddingLeft(2).Render(c.Conv.Title)
if i == m.cursor { if i == m.cursor {
title = ">" + title[1:] title = ">" + title[1:]
} }