From 676aa7b0045453fb61fdaeaaccb98433277a0e40 Mon Sep 17 00:00:00 2001 From: Matt Low Date: Sun, 22 Sep 2024 23:34:53 +0000 Subject: [PATCH] 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`). --- pkg/tui/shared/shared.go | 25 ++---- pkg/tui/tui.go | 79 ++++++++++++----- pkg/tui/views/chat/chat.go | 19 ++-- pkg/tui/views/chat/update.go | 22 +---- pkg/tui/views/chat/view.go | 50 +++++------ pkg/tui/views/conversations/conversations.go | 92 +++++++------------- 6 files changed, 122 insertions(+), 165 deletions(-) diff --git a/pkg/tui/shared/shared.go b/pkg/tui/shared/shared.go index 55338cd..d757d9d 100644 --- a/pkg/tui/shared/shared.go +++ b/pkg/tui/shared/shared.go @@ -9,15 +9,12 @@ import ( type ViewModel interface { Init() tea.Cmd Update(tea.Msg) (ViewModel, tea.Cmd) - View() string - Initialized() bool // Return whether this view is initialized -} -type ViewState struct { - Initialized bool - Width int - Height int - Err error + // View methods + Header(width int) string + // Render the view's main content into a container of the given dimensions + Content(width, height int) string + Footer(width int) string } type View int @@ -29,22 +26,12 @@ const ( //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 ( // send to change the current state MsgViewChange View // sent to a state when it is entered MsgViewEnter struct{} - // sent when an error occurs + // sent when a recoverable error occurs (displayed to user) MsgError error // sent when the view has handled a key input MsgKeyHandled tea.KeyMsg diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 9384b3e..16c4c6c 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -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) diff --git a/pkg/tui/views/chat/chat.go b/pkg/tui/views/chat/chat.go index 2465c4f..980289d 100644 --- a/pkg/tui/views/chat/chat.go +++ b/pkg/tui/views/chat/chat.go @@ -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(), ) diff --git a/pkg/tui/views/chat/update.go b/pkg/tui/views/chat/update.go index fb87285..abacaf3 100644 --- a/pkg/tui/views/chat/update.go +++ b/pkg/tui/views/chat/update.go @@ -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 diff --git a/pkg/tui/views/chat/view.go b/pkg/tui/views/chat/view.go index ba84a87..222ee57 100644 --- a/pkg/tui/views/chat/view.go +++ b/pkg/tui/views/chat/view.go @@ -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) } diff --git a/pkg/tui/views/conversations/conversations.go b/pkg/tui/views/conversations/conversations.go index 146be52..78c5836 100644 --- a/pkg/tui/views/conversations/conversations.go +++ b/pkg/tui/views/conversations/conversations.go @@ -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:] }