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 {
Ctx *lmcli.Context
Values *Values
Views *Views
Width int
Height int
Err error
}
// this struct holds the final rendered content of various UI components, and
// gets populated in the application's Update() method. View() simply composes
// these elements into the final output
// TODO: consider removing this, let each view be responsible
type Views struct {
// a convenience struct for holding rendered content for indiviudal UI
// elements
type Sections struct {
Header string
Content 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/conversations"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Application model
@ -31,7 +30,6 @@ func initialModel(ctx *lmcli.Context, values shared.Values) Model {
State: shared.State{
Ctx: ctx,
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 {
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 ""
}
sections := make([]string, 0, 6)
if m.State.Views.Header != "" {
sections = append(sections, m.State.Views.Header)
}
switch m.state {
case shared.StateConversations:
sections = append(sections, m.State.Views.Content)
if m.State.Views.Error != "" {
sections = append(sections, m.State.Views.Error)
}
return m.conversations.View()
case shared.StateChat:
sections = append(sections, m.State.Views.Content)
if m.State.Views.Error != "" {
sections = append(sections, m.State.Views.Error)
return m.chat.View()
default:
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 {

View File

@ -57,8 +57,7 @@ type (
type Model struct {
shared.State
width int
height int
shared.Sections
// app state
conversation *models.Conversation
@ -72,10 +71,6 @@ type Model struct {
replyChunkChan chan string
persistence bool // whether we will save new messages in the conversation
tokenCount uint
startTime time.Time
elapsed time.Duration
// ui state
focus focusState
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
messageOffsets []int
tokenCount uint
startTime time.Time
elapsed time.Duration
// ui elements
content viewport.Model
input textarea.Model
@ -228,7 +227,7 @@ func (m Model) Init() tea.Cmd {
}
func (m *Model) HandleResize(width, height int) {
m.width, m.height = width, height
m.Width, m.Height = width, height
m.content.Width = width
m.input.SetWidth(width - m.input.FocusedStyle.Base.GetHorizontalFrameSize())
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
if m.width > 0 {
m.State.Views.Header = m.headerView()
m.State.Views.Footer = m.footerView()
m.State.Views.Error = tuiutil.ErrorBanner(m.State.Err, m.width)
fixedHeight := tuiutil.Height(m.State.Views.Header) + tuiutil.Height(m.State.Views.Error) + tuiutil.Height(m.State.Views.Footer)
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()))
newHeight := max(4, min((m.Height-fixedHeight-1)/2, m.input.LineCount()))
m.input.SetHeight(newHeight)
m.State.Views.Input = m.input.View()
m.Input = m.input.View()
// remaining height towards content
m.content.Height = m.height - fixedHeight - tuiutil.Height(m.State.Views.Input)
m.State.Views.Content = m.content.View()
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
@ -630,6 +629,29 @@ func (m *Model) HandleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) {
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 {
icon := ""
friendly := message.Role.FriendlyRole()
@ -828,9 +850,9 @@ func (m *Model) headerView() string {
} else {
title = "Untitled"
}
title = tuiutil.TruncateToCellWidth(title, m.width-styles.Header.GetHorizontalPadding(), "...")
title = tuiutil.TruncateToCellWidth(title, m.Width-styles.Header.GetHorizontalPadding(), "...")
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 {
@ -868,7 +890,7 @@ func (m *Model) footerView() string {
right := strings.Join(rightSegments, segmentSeparator)
totalWidth := lipgloss.Width(left) + lipgloss.Width(right)
remaining := m.width - totalWidth
remaining := m.Width - totalWidth
var padding string
if remaining > 0 {
@ -877,9 +899,9 @@ func (m *Model) footerView() string {
footer := left + padding + right
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) {

View File

@ -29,6 +29,7 @@ type (
type Model struct {
shared.State
shared.Sections
conversations []loadedConversation
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 {
m.Views.Header = m.headerView()
m.Views.Footer = "" // TODO: show /something/
m.Views.Error = tuiutil.ErrorBanner(m.Err, m.Width)
fixedHeight := tuiutil.Height(m.Views.Header) + tuiutil.Height(m.Views.Error) + tuiutil.Height(m.Views.Footer)
m.Header = m.headerView()
m.Footer = "" // TODO: show /something/
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.Views.Content = m.content.View()
m.Content = m.content.View()
}
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 {
titleStyle := lipgloss.NewStyle().Bold(true)
header := titleStyle.Render("Conversations")