Matt Low
676aa7b004
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`).
163 lines
3.6 KiB
Go
163 lines
3.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/api"
|
|
"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 {
|
|
InitialConversation *api.Conversation
|
|
InitialView shared.View
|
|
}
|
|
|
|
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 {
|
|
app := &model.AppModel{
|
|
Ctx: ctx,
|
|
Conversation: opts.InitialConversation,
|
|
}
|
|
|
|
m := Model{
|
|
App: app,
|
|
activeView: opts.InitialView,
|
|
views: map[shared.View]shared.ViewModel{
|
|
shared.ViewChat: chat.Chat(app),
|
|
shared.ViewConversations: conversations.Conversations(app),
|
|
},
|
|
}
|
|
|
|
return &m
|
|
}
|
|
|
|
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 {
|
|
view, cmd := m.views[m.activeView].Update(msg)
|
|
m.views[m.activeView] = view
|
|
if cmd != nil {
|
|
return cmd
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "ctrl+q":
|
|
return tea.Quit
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 {
|
|
return m, cmd
|
|
}
|
|
case shared.MsgViewChange:
|
|
m.activeView = shared.View(msg)
|
|
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)
|
|
m.views[m.activeView] = view
|
|
return m, cmd
|
|
}
|
|
|
|
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)
|
|
|
|
func WithInitialConversation(conv *api.Conversation) LaunchOption {
|
|
return func(opts *LaunchOptions) {
|
|
opts.InitialConversation = conv
|
|
}
|
|
}
|
|
|
|
func WithInitialView(view shared.View) LaunchOption {
|
|
return func(opts *LaunchOptions) {
|
|
opts.InitialView = view
|
|
}
|
|
}
|
|
|
|
func Launch(ctx *lmcli.Context, options ...LaunchOption) error {
|
|
opts := &LaunchOptions{
|
|
InitialView: shared.ViewChat,
|
|
}
|
|
for _, opt := range options {
|
|
opt(opts)
|
|
}
|
|
|
|
program := tea.NewProgram(initialModel(ctx, *opts), tea.WithAltScreen())
|
|
if _, err := program.Run(); err != nil {
|
|
return fmt.Errorf("Error running program: %v", err)
|
|
}
|
|
return nil
|
|
}
|