Clean up tui View handling

This commit is contained in:
Matt Low 2024-05-30 07:04:55 +00:00
parent c1792f27ff
commit ed784bb1cf
4 changed files with 78 additions and 57 deletions

View File

@ -12,17 +12,14 @@ type Values struct {
type State struct { type State struct {
Ctx *lmcli.Context Ctx *lmcli.Context
Values *Values Values *Values
Views *Views
Width int Width int
Height int Height int
Err error Err error
} }
// this struct holds the final rendered content of various UI components, and // a convenience struct for holding rendered content for indiviudal UI
// gets populated in the application's Update() method. View() simply composes // elements
// these elements into the final output type Sections struct {
// TODO: consider removing this, let each view be responsible
type Views struct {
Header string Header string
Content string Content string
Error string Error string

View File

@ -14,7 +14,6 @@ import (
"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"
) )
// Application model // Application model
@ -31,7 +30,6 @@ func initialModel(ctx *lmcli.Context, values shared.Values) Model {
State: shared.State{ State: shared.State{
Ctx: ctx, Ctx: ctx,
Values: &values, Values: &values,
Views: &shared.Views{},
}, },
} }
@ -126,36 +124,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) View() string { func (m Model) View() string {
if m.State.Width == 0 { if m.State.Width == 0 {
// this is the case upon initial startup, but it's also a safe bet that
// we can just skip rendering if the terminal is really 0 width...
// without this, the m.*View() functions may crash
return "" return ""
} }
sections := make([]string, 0, 6)
if m.State.Views.Header != "" {
sections = append(sections, m.State.Views.Header)
}
switch m.state { switch m.state {
case shared.StateConversations: case shared.StateConversations:
sections = append(sections, m.State.Views.Content) return m.conversations.View()
if m.State.Views.Error != "" {
sections = append(sections, m.State.Views.Error)
}
case shared.StateChat: case shared.StateChat:
sections = append(sections, m.State.Views.Content) return m.chat.View()
if m.State.Views.Error != "" { default:
sections = append(sections, m.State.Views.Error) return ""
}
sections = append(sections, m.State.Views.Input)
} }
if m.State.Views.Footer != "" {
sections = append(sections, m.State.Views.Footer)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
} }
func Launch(ctx *lmcli.Context, convShortname string) error { func Launch(ctx *lmcli.Context, convShortname string) error {

View File

@ -57,8 +57,7 @@ type (
type Model struct { type Model struct {
shared.State shared.State
width int shared.Sections
height int
// app state // app state
conversation *models.Conversation conversation *models.Conversation
@ -72,10 +71,6 @@ type Model struct {
replyChunkChan chan string replyChunkChan chan string
persistence bool // whether we will save new messages in the conversation persistence bool // whether we will save new messages in the conversation
tokenCount uint
startTime time.Time
elapsed time.Duration
// ui state // ui state
focus focusState focus focusState
wrap bool // whether message content is wrapped to viewport width wrap bool // whether message content is wrapped to viewport width
@ -84,6 +79,10 @@ type Model struct {
messageCache []string // cache of syntax highlighted and wrapped message content messageCache []string // cache of syntax highlighted and wrapped message content
messageOffsets []int messageOffsets []int
tokenCount uint
startTime time.Time
elapsed time.Duration
// ui elements // ui elements
content viewport.Model content viewport.Model
input textarea.Model input textarea.Model
@ -228,7 +227,7 @@ func (m Model) Init() tea.Cmd {
} }
func (m *Model) HandleResize(width, height int) { func (m *Model) HandleResize(width, height int) {
m.width, m.height = width, height m.Width, m.Height = width, height
m.content.Width = width m.content.Width = width
m.input.SetWidth(width - m.input.FocusedStyle.Base.GetHorizontalFrameSize()) m.input.SetWidth(width - m.input.FocusedStyle.Base.GetHorizontalFrameSize())
if len(m.messages) > 0 { if len(m.messages) > 0 {
@ -390,21 +389,21 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
// update views once window dimensions are known // update views once window dimensions are known
if m.width > 0 { if m.Width > 0 {
m.State.Views.Header = m.headerView() m.Header = m.headerView()
m.State.Views.Footer = m.footerView() m.Footer = m.footerView()
m.State.Views.Error = tuiutil.ErrorBanner(m.State.Err, m.width) m.Error = tuiutil.ErrorBanner(m.Err, m.Width)
fixedHeight := tuiutil.Height(m.State.Views.Header) + tuiutil.Height(m.State.Views.Error) + tuiutil.Height(m.State.Views.Footer) fixedHeight := tuiutil.Height(m.Header) + tuiutil.Height(m.Error) + tuiutil.Height(m.Footer)
// calculate clamped input height to accomodate input text // calculate clamped input height to accomodate input text
// minimum 4 lines, maximum half of content area // minimum 4 lines, maximum half of content area
newHeight := max(4, min((m.height-fixedHeight-1)/2, m.input.LineCount())) newHeight := max(4, min((m.Height-fixedHeight-1)/2, m.input.LineCount()))
m.input.SetHeight(newHeight) m.input.SetHeight(newHeight)
m.State.Views.Input = m.input.View() m.Input = m.input.View()
// remaining height towards content // remaining height towards content
m.content.Height = m.height - fixedHeight - tuiutil.Height(m.State.Views.Input) m.content.Height = m.Height - fixedHeight - tuiutil.Height(m.Input)
m.State.Views.Content = m.content.View() 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
@ -630,6 +629,29 @@ func (m *Model) HandleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) {
return false, nil return false, nil
} }
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 *models.Message) string { func (m *Model) renderMessageHeading(i int, message *models.Message) string {
icon := "" icon := ""
friendly := message.Role.FriendlyRole() friendly := message.Role.FriendlyRole()
@ -828,9 +850,9 @@ func (m *Model) headerView() string {
} else { } else {
title = "Untitled" title = "Untitled"
} }
title = tuiutil.TruncateToCellWidth(title, m.width-styles.Header.GetHorizontalPadding(), "...") title = tuiutil.TruncateToCellWidth(title, m.Width-styles.Header.GetHorizontalPadding(), "...")
header := titleStyle.Render(title) header := titleStyle.Render(title)
return styles.Header.Width(m.width).Render(header) return styles.Header.Width(m.Width).Render(header)
} }
func (m *Model) footerView() string { func (m *Model) footerView() string {
@ -868,7 +890,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 := m.Width - totalWidth
var padding string var padding string
if remaining > 0 { if remaining > 0 {
@ -877,9 +899,9 @@ 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, m.Width, "...")
} }
return footerStyle.Width(m.width).Render(footer) return footerStyle.Width(m.Width).Render(footer)
} }
func (m *Model) setMessage(i int, msg models.Message) { func (m *Model) setMessage(i int, msg models.Message) {

View File

@ -29,6 +29,7 @@ type (
type Model struct { type Model struct {
shared.State shared.State
shared.Sections
conversations []loadedConversation conversations []loadedConversation
cursor int // index of the currently selected conversation cursor int // index of the currently selected conversation
@ -129,12 +130,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
if m.Width > 0 { if m.Width > 0 {
m.Views.Header = m.headerView() m.Header = m.headerView()
m.Views.Footer = "" // TODO: show /something/ m.Footer = "" // TODO: show /something/
m.Views.Error = tuiutil.ErrorBanner(m.Err, m.Width) m.Error = tuiutil.ErrorBanner(m.Err, m.Width)
fixedHeight := tuiutil.Height(m.Views.Header) + tuiutil.Height(m.Views.Error) + tuiutil.Height(m.Views.Footer) fixedHeight := tuiutil.Height(m.Header) + tuiutil.Height(m.Error) + tuiutil.Height(m.Footer)
m.content.Height = m.Height - fixedHeight m.content.Height = m.Height - fixedHeight
m.Views.Content = m.content.View() m.Content = m.content.View()
} }
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
@ -156,6 +157,28 @@ func (m *Model) loadConversations() 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) headerView() string {
titleStyle := lipgloss.NewStyle().Bold(true) titleStyle := lipgloss.NewStyle().Bold(true)
header := titleStyle.Render("Conversations") header := titleStyle.Render("Conversations")