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

@@ -5,7 +5,6 @@ import (
"git.mlow.ca/mlow/lmcli/pkg/api"
"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/spinner"
"github.com/charmbracelet/bubbles/textarea"
@@ -72,11 +71,10 @@ const (
)
type Model struct {
*shared.ViewState
shared.Sections
// App state
App *model.AppModel
Height int
Width int
// Chat view state
state state // current overall status of the view
@@ -106,14 +104,9 @@ type Model struct {
elapsed time.Duration
}
func (m Model) Initialized() bool {
return m.ViewState.Initialized
}
func Chat(app *model.AppModel, shared shared.ViewState) shared.ViewModel {
func Chat(app *model.AppModel) *Model {
m := Model{
App: app,
ViewState: &shared,
state: idle,
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.Base = inputFocusedStyle
m.input.BlurredStyle.Base = inputBlurredStyle
return m
return &m
}
func (m Model) Init() tea.Cmd {
m.ViewState.Initialized = true
func (m *Model) Init() tea.Cmd {
return tea.Batch(
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
var cmds []tea.Cmd
@@ -169,8 +169,8 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
m.updateContent()
case msgChatResponseError:
m.state = idle
m.ViewState.Err = error(msg)
m.updateContent()
return m, shared.WrapError(msg)
case msgToolResults:
last := len(m.App.Messages) - 1
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
// scroll below its content, which can happen when the input viewport
// height has grown, or previously entered lines have been deleted

View File

@@ -39,29 +39,6 @@ var (
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 {
icon := ""
friendly := message.Role.FriendlyRole()
@@ -254,7 +231,20 @@ func (m *Model) conversationMessagesView() 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)
var title string
if m.App.Conversation != nil && m.App.Conversation.Title != "" {
@@ -262,12 +252,12 @@ func (m *Model) headerView() string {
} else {
title = "Untitled"
}
title = tuiutil.TruncateToCellWidth(title, m.Width-styles.Header.GetHorizontalPadding(), "...")
title = tuiutil.TruncateToCellWidth(title, width-styles.Header.GetHorizontalPadding(), "...")
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)
segmentSeparator := "|"
@@ -305,7 +295,7 @@ func (m *Model) footerView() string {
right := strings.Join(rightSegments, segmentSeparator)
totalWidth := lipgloss.Width(left) + lipgloss.Width(right)
remaining := m.Width - totalWidth
remaining := width - totalWidth
var padding string
if remaining > 0 {
@@ -314,7 +304,7 @@ func (m *Model) footerView() string {
footer := left + padding + right
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 {
*shared.ViewState
shared.Sections
App *model.AppModel
cursor int
App *model.AppModel
width int
height int
cursor int
itemOffsets []int // conversation y offsets
content viewport.Model
confirmPrompt bubbles.ConfirmPrompt
}
func Conversations(app *model.AppModel, shared shared.ViewState) Model {
func Conversations(app *model.AppModel) *Model {
viewport.New(0, 0)
m := Model{
App: app,
ViewState: &shared,
content: viewport.New(0, 0),
App: app,
content: viewport.New(0, 0),
}
return m
}
func (m Model) Initialized() bool {
return m.ViewState.Initialized
return &m
}
func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
@@ -129,12 +124,11 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
return nil
}
func (m Model) Init() tea.Cmd {
m.ViewState.Initialized = true
return m.loadConversations()
func (m *Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
isInput := false
inputHandled := false
@@ -144,15 +138,14 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
isInput = true
cmd := m.handleInput(msg)
if cmd != nil {
inputHandled = true
cmds = append(cmds, cmd)
inputHandled = true
}
case shared.MsgViewEnter:
cmds = append(cmds, m.loadConversations())
m.content.SetContent(m.renderConversationList())
case tea.WindowSizeMsg:
m.Width, m.Height = msg.Width, msg.Height
m.content.Width = msg.Width
m.width, m.height = msg.Width, msg.Height
m.content.SetContent(m.renderConversationList())
case msgConversationsLoaded:
m.App.Conversations = msg
@@ -171,26 +164,13 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
}
if !isInput || !inputHandled {
var cmd tea.Cmd
m.content, cmd = m.content.Update(msg)
content, cmd := m.content.Update(msg)
m.content = content
if cmd != nil {
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 {
return m, tea.Batch(cmds...)
}
@@ -218,32 +198,22 @@ func (m *Model) deleteConversation(conv api.Conversation) tea.Cmd {
}
}
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)
}
if m.Footer != "" {
sections = append(sections, m.Footer)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
func (m *Model) headerView() string {
func (m *Model) Header(width int) string {
titleStyle := lipgloss.NewStyle().Bold(true)
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 {
@@ -321,7 +291,7 @@ func (m *Model) renderConversationList() string {
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 {
title = ">" + title[1:]
}