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`).
313 lines
8.0 KiB
Go
313 lines
8.0 KiB
Go
package conversations
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/api"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/bubbles"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/model"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/styles"
|
|
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
|
|
"git.mlow.ca/mlow/lmcli/pkg/util"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type (
|
|
// sent when conversation list is loaded
|
|
msgConversationsLoaded ([]model.LoadedConversation)
|
|
// sent when a conversation is selected
|
|
msgConversationSelected api.Conversation
|
|
// sent when a conversation is deleted
|
|
msgConversationDeleted struct{}
|
|
)
|
|
|
|
type Model struct {
|
|
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) *Model {
|
|
viewport.New(0, 0)
|
|
m := Model{
|
|
App: app,
|
|
content: viewport.New(0, 0),
|
|
}
|
|
return &m
|
|
}
|
|
|
|
func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
|
|
if m.confirmPrompt.Focused() {
|
|
var cmd tea.Cmd
|
|
m.confirmPrompt, cmd = m.confirmPrompt.Update(msg)
|
|
if cmd != nil {
|
|
return cmd
|
|
}
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "enter":
|
|
if len(m.App.Conversations) > 0 && m.cursor < len(m.App.Conversations) {
|
|
m.App.ClearConversation()
|
|
m.App.Conversation = &m.App.Conversations[m.cursor].Conv
|
|
return func() tea.Msg {
|
|
return shared.MsgViewChange(shared.ViewChat)
|
|
}
|
|
}
|
|
case "j", "down":
|
|
if m.cursor < len(m.App.Conversations)-1 {
|
|
m.cursor++
|
|
if m.cursor == len(m.App.Conversations)-1 {
|
|
m.content.GotoBottom()
|
|
} else {
|
|
// this hack positions the *next* conversatoin slightly
|
|
// *off* the screen, ensuring the entire m.cursor is shown,
|
|
// even if its height may not be constant due to wrapping.
|
|
tuiutil.ScrollIntoView(&m.content, m.itemOffsets[m.cursor+1], -1)
|
|
}
|
|
m.content.SetContent(m.renderConversationList())
|
|
} else {
|
|
m.cursor = len(m.App.Conversations) - 1
|
|
m.content.GotoBottom()
|
|
}
|
|
return shared.KeyHandled(msg)
|
|
case "k", "up":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
if m.cursor == 0 {
|
|
m.content.GotoTop()
|
|
} else {
|
|
tuiutil.ScrollIntoView(&m.content, m.itemOffsets[m.cursor], 1)
|
|
}
|
|
m.content.SetContent(m.renderConversationList())
|
|
} else {
|
|
m.cursor = 0
|
|
m.content.GotoTop()
|
|
}
|
|
return shared.KeyHandled(msg)
|
|
case "n":
|
|
m.App.ClearConversation()
|
|
return shared.ChangeView(shared.ViewChat)
|
|
case "d":
|
|
if !m.confirmPrompt.Focused() && len(m.App.Conversations) > 0 && m.cursor < len(m.App.Conversations) {
|
|
title := m.App.Conversations[m.cursor].Conv.Title
|
|
if title == "" {
|
|
title = "(untitled)"
|
|
}
|
|
m.confirmPrompt = bubbles.NewConfirmPrompt(
|
|
fmt.Sprintf("Delete '%s'?", title),
|
|
m.App.Conversations[m.cursor].Conv,
|
|
)
|
|
m.confirmPrompt.Style = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("3"))
|
|
return shared.KeyHandled(msg)
|
|
}
|
|
case "c":
|
|
// copy/clone conversation
|
|
case "r":
|
|
// show prompt to rename conversation
|
|
case "shift+r":
|
|
// show prompt to generate name for conversation
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
|
|
isInput := false
|
|
inputHandled := false
|
|
|
|
var cmds []tea.Cmd
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
isInput = true
|
|
cmd := m.handleInput(msg)
|
|
if cmd != nil {
|
|
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.SetContent(m.renderConversationList())
|
|
case msgConversationsLoaded:
|
|
m.App.Conversations = msg
|
|
m.cursor = max(0, min(len(m.App.Conversations), m.cursor))
|
|
m.content.SetContent(m.renderConversationList())
|
|
case bubbles.MsgConfirmPromptAnswered:
|
|
m.confirmPrompt.Blur()
|
|
if msg.Value {
|
|
conv, ok := msg.Payload.(api.Conversation)
|
|
if ok {
|
|
cmds = append(cmds, m.deleteConversation(conv))
|
|
}
|
|
}
|
|
case msgConversationDeleted:
|
|
cmds = append(cmds, m.loadConversations())
|
|
}
|
|
|
|
if !isInput || !inputHandled {
|
|
content, cmd := m.content.Update(msg)
|
|
m.content = content
|
|
if cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
}
|
|
|
|
if len(cmds) > 0 {
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) loadConversations() tea.Cmd {
|
|
return func() tea.Msg {
|
|
err, conversations := m.App.LoadConversations()
|
|
if err != nil {
|
|
return shared.MsgError(fmt.Errorf("Could not load conversations: %v", err))
|
|
}
|
|
return msgConversationsLoaded(conversations)
|
|
}
|
|
}
|
|
|
|
func (m *Model) deleteConversation(conv api.Conversation) tea.Cmd {
|
|
return func() tea.Msg {
|
|
err := m.App.Ctx.Store.DeleteConversation(&conv)
|
|
if err != nil {
|
|
return shared.MsgError(fmt.Errorf("Could not delete conversation: %v", err))
|
|
}
|
|
return msgConversationDeleted{}
|
|
}
|
|
}
|
|
|
|
func (m *Model) Header(width int) string {
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
|
header := titleStyle.Render("Conversations")
|
|
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 {
|
|
now := time.Now()
|
|
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
|
dayOfWeek := int(now.Weekday())
|
|
categories := []struct {
|
|
name string
|
|
cutoff time.Duration
|
|
}{
|
|
{"Today", now.Sub(midnight)},
|
|
{"Yesterday", now.Sub(midnight.AddDate(0, 0, -1))},
|
|
{"This week", now.Sub(midnight.AddDate(0, 0, -dayOfWeek))},
|
|
{"Last week", now.Sub(midnight.AddDate(0, 0, -(dayOfWeek + 7)))},
|
|
{"This month", now.Sub(monthStart)},
|
|
{"Last month", now.Sub(monthStart.AddDate(0, -1, 0))},
|
|
{"2 Months ago", now.Sub(monthStart.AddDate(0, -2, 0))},
|
|
{"3 Months ago", now.Sub(monthStart.AddDate(0, -3, 0))},
|
|
{"4 Months ago", now.Sub(monthStart.AddDate(0, -4, 0))},
|
|
{"5 Months ago", now.Sub(monthStart.AddDate(0, -5, 0))},
|
|
{"6 Months ago", now.Sub(monthStart.AddDate(0, -6, 0))},
|
|
{"Older", now.Sub(time.Time{})},
|
|
}
|
|
|
|
categoryStyle := lipgloss.NewStyle().
|
|
MarginBottom(1).
|
|
Foreground(lipgloss.Color("12")).
|
|
PaddingLeft(1).
|
|
Bold(true)
|
|
|
|
itemStyle := lipgloss.NewStyle().
|
|
MarginBottom(1)
|
|
|
|
ageStyle := lipgloss.NewStyle().Faint(true).SetString()
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
|
untitledStyle := lipgloss.NewStyle().Faint(true).Italic(true)
|
|
selectedStyle := lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("6"))
|
|
|
|
var (
|
|
currentOffset int
|
|
currentCategory string
|
|
sb strings.Builder
|
|
)
|
|
|
|
m.itemOffsets = make([]int, len(m.App.Conversations))
|
|
sb.WriteRune('\n')
|
|
currentOffset += 1
|
|
|
|
for i, c := range m.App.Conversations {
|
|
lastReplyAge := now.Sub(c.LastReply.CreatedAt)
|
|
|
|
var category string
|
|
for _, g := range categories {
|
|
if lastReplyAge < g.cutoff {
|
|
category = g.name
|
|
break
|
|
}
|
|
}
|
|
|
|
// print the category
|
|
if category != currentCategory {
|
|
currentCategory = category
|
|
heading := categoryStyle.Render(currentCategory)
|
|
sb.WriteString(heading)
|
|
currentOffset += tuiutil.Height(heading)
|
|
sb.WriteRune('\n')
|
|
}
|
|
|
|
tStyle := titleStyle
|
|
if c.Conv.Title == "" {
|
|
tStyle = tStyle.Inherit(untitledStyle).SetString("(untitled)")
|
|
}
|
|
if i == m.cursor {
|
|
tStyle = tStyle.Inherit(selectedStyle)
|
|
}
|
|
|
|
title := tStyle.Width(m.width - 3).PaddingLeft(2).Render(c.Conv.Title)
|
|
if i == m.cursor {
|
|
title = ">" + title[1:]
|
|
}
|
|
|
|
m.itemOffsets[i] = currentOffset
|
|
item := itemStyle.Render(fmt.Sprintf(
|
|
"%s\n %s",
|
|
title,
|
|
ageStyle.Render(util.HumanTimeElapsedSince(lastReplyAge)),
|
|
))
|
|
sb.WriteString(item)
|
|
currentOffset += tuiutil.Height(item)
|
|
if i < len(m.App.Conversations)-1 {
|
|
sb.WriteRune('\n')
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|