Matt Low
02228d65ac
- Allow each view to position error banners where they choose - Add global 'esc' key handler to dismiss errors
176 lines
4.0 KiB
Go
176 lines
4.0 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/conversation"
|
|
"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"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/views/settings"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type Model struct {
|
|
App *model.AppModel
|
|
|
|
// window size
|
|
width int
|
|
height int
|
|
|
|
// errors to display
|
|
// TODO: allow dismissing errors
|
|
errs []error
|
|
|
|
activeView shared.View
|
|
views map[shared.View]shared.ViewModel
|
|
}
|
|
|
|
func initialModel(ctx *lmcli.Context, opts LaunchOptions) *Model {
|
|
app := model.NewAppModel(ctx, 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),
|
|
shared.ViewSettings: settings.Settings(app),
|
|
},
|
|
}
|
|
|
|
return &m
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
for _, v := range m.views {
|
|
// Init views
|
|
cmds = append(cmds, v.Init())
|
|
}
|
|
cmds = append(cmds, func() tea.Msg {
|
|
// Initial view change
|
|
return shared.MsgViewChange(m.activeView)
|
|
})
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
|
|
switch msg.String() {
|
|
case "esc":
|
|
if len(m.errs) > 0 {
|
|
m.errs = m.errs[1:]
|
|
return shared.KeyHandled(msg)
|
|
}
|
|
}
|
|
|
|
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:
|
|
currView := m.activeView
|
|
m.activeView = shared.View(msg)
|
|
return m, tea.Batch(tea.WindowSize(), shared.ViewEnter(currView))
|
|
case shared.MsgError:
|
|
m.errs = append(m.errs, msg.Err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
var errors string
|
|
if len(errBanners) > 0 {
|
|
errors = lipgloss.JoinVertical(lipgloss.Left, errBanners...)
|
|
} else {
|
|
errors = ""
|
|
}
|
|
fixedUIHeight += tuiutil.Height(errors)
|
|
|
|
content := m.views[m.activeView].Content(m.width, m.height-fixedUIHeight, errors)
|
|
|
|
sections := make([]string, 0, 4)
|
|
if header != "" {
|
|
sections = append(sections, header)
|
|
}
|
|
if content != "" {
|
|
sections = append(sections, content)
|
|
}
|
|
if footer != "" {
|
|
sections = append(sections, footer)
|
|
}
|
|
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
|
}
|
|
|
|
type LaunchOptions struct {
|
|
InitialConversation *conversation.Conversation
|
|
InitialView shared.View
|
|
}
|
|
|
|
type LaunchOption func(*LaunchOptions)
|
|
|
|
func WithInitialConversation(conv *conversation.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
|
|
}
|