tui: Initial rough conversation list view

This commit is contained in:
Matt Low 2024-03-31 01:02:07 +00:00
parent cef87a55d8
commit c68cb14eb9
2 changed files with 227 additions and 20 deletions

View File

@ -0,0 +1,184 @@
package tui
import (
"fmt"
"slices"
"strings"
"time"
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
"git.mlow.ca/mlow/lmcli/pkg/util"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m *model) handleConversationListInput(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "enter":
m.state = stateConversation
m.updateContent()
// load selected conversation and switch state
case "n":
// new conversation
case "d":
// show prompt to delete conversation
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) handleConversationListUpdate(msg tea.Msg) []tea.Cmd {
var cmds []tea.Cmd
switch msg := msg.(type) {
case msgConversationsLoaded:
m.conversations = msg
m.content.SetContent(m.conversationListView())
}
var cmd tea.Cmd
m.content, cmd = m.content.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
if m.width > 0 {
m.views.header = m.headerView()
m.views.footer = m.footerView()
m.views.error = m.errorView()
fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer)
m.content.Height = m.height - fixedHeight
m.views.content = m.content.View()
}
return cmds
}
func (m *model) loadConversations() tea.Cmd {
return func() tea.Msg {
c, err := m.ctx.Store.Conversations()
if err != nil {
return msgError(fmt.Errorf("Could not load conversations: %v", err))
}
return msgConversationsLoaded(c)
}
}
func (m *model) loadConversationLastReplies(conversations []models.Conversation) tea.Cmd {
return func() tea.Msg {
//lastMessage, err := m.ctx.Store.LastMessage(&conversation)
return nil
}
}
func (m *model) setConversations(conversations []models.Conversation) {
}
func (m *model) conversationListView() string {
sb := &strings.Builder{}
type AgeGroup struct {
name string
cutoff time.Duration
}
type ConversationLine struct {
id uint
short string
title string
elapsed string
lastReplyAge time.Duration
}
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 := []AgeGroup{
{"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{})},
}
categorized := map[string][]ConversationLine{}
for _, conversation := range m.conversations {
lastMessage, err := m.ctx.Store.LastMessage(&conversation)
if lastMessage == nil || err != nil {
continue
}
messageAge := now.Sub(lastMessage.CreatedAt)
var category string
for _, c := range categories {
if messageAge < c.cutoff {
category = c.name
break
}
}
categorized[category] = append(
categorized[category],
ConversationLine{
id: conversation.ID,
title: conversation.Title,
short: conversation.ShortName.String,
lastReplyAge: messageAge,
},
)
}
// TODO: pick nice color
categoryStyle := lipgloss.NewStyle().
MarginBottom(1).
Foreground(lipgloss.Color("170")).
PaddingLeft(1).
Bold(true)
conversationHeadingStyle := lipgloss.NewStyle().
MarginBottom(1).
PaddingLeft(2)
for _, category := range categories {
conversationLines, ok := categorized[category.name]
if !ok {
continue
}
slices.SortFunc(conversationLines, func(a, b ConversationLine) int {
return int(a.lastReplyAge - b.lastReplyAge)
})
ageStyle := lipgloss.NewStyle().Faint(true)
titleStyle := lipgloss.NewStyle().Bold(true)
untitledStyle := titleStyle.Copy().Italic(true).SetString("(untitled)")
fmt.Fprintf(sb, "%s\n", categoryStyle.Render(category.name))
for _, c := range conversationLines {
tstyle := titleStyle
if c.title == "" {
tstyle = untitledStyle
}
heading := fmt.Sprintf(
"%s\n%s",
tstyle.Render(c.title),
ageStyle.Render(util.HumanTimeElapsedSince(c.lastReplyAge)),
)
sb.WriteString(conversationHeadingStyle.Render(heading))
sb.WriteRune('\n')
}
}
return sb.String()
}

View File

@ -32,7 +32,7 @@ type appState int
const ( const (
stateConversation = iota stateConversation = iota
//stateConversationList stateConversationList
//stateModelSelect // stateOptions? //stateModelSelect // stateOptions?
//stateHelp //stateHelp
) )
@ -60,6 +60,8 @@ type model struct {
// application state // application state
state appState state appState
conversations []models.Conversation
lastReplies []models.Message
conversation *models.Conversation conversation *models.Conversation
messages []models.Message messages []models.Message
selectedMessage int selectedMessage int
@ -143,6 +145,8 @@ type (
msgConversationTitleChanged string msgConversationTitleChanged string
// sent when a conversation's messages are laoded // sent when a conversation's messages are laoded
msgMessagesLoaded []models.Message msgMessagesLoaded []models.Message
// send when conversation list is loaded
msgConversationsLoaded []models.Conversation
// sent when an error occurs // sent when an error occurs
msgError error msgError error
) )
@ -154,13 +158,21 @@ func wrapError(err error) tea.Cmd {
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
return tea.Batch( cmds := []tea.Cmd{
textarea.Blink, textarea.Blink,
m.spinner.Tick, m.spinner.Tick,
m.loadConversation(m.convShortname),
m.waitForChunk(), m.waitForChunk(),
m.waitForReply(), m.waitForReply(),
) }
switch m.state {
case stateConversation:
if m.convShortname != "" {
cmds = append(cmds, m.loadConversation(m.convShortname))
}
case stateConversationList:
cmds = append(cmds, m.loadConversations())
}
return tea.Batch(cmds...)
} }
func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd { func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
@ -181,6 +193,8 @@ func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
switch m.state { switch m.state {
case stateConversation: case stateConversation:
return m.handleConversationInput(msg) return m.handleConversationInput(msg)
case stateConversationList:
return m.handleConversationListInput(msg)
} }
} }
return nil return nil
@ -188,6 +202,9 @@ func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
func (m *model) handleConversationInput(msg tea.KeyMsg) tea.Cmd { func (m *model) handleConversationInput(msg tea.KeyMsg) tea.Cmd {
switch msg.String() { switch msg.String() {
case "esc":
m.state = stateConversationList
return m.loadConversations()
case "ctrl+p": case "ctrl+p":
m.persistence = !m.persistence m.persistence = !m.persistence
case "ctrl+t": case "ctrl+t":
@ -209,10 +226,6 @@ func (m *model) handleConversationInput(msg tea.KeyMsg) tea.Cmd {
return nil return nil
} }
func (m *model) handleConversationListInput(msg tea.KeyMsg) tea.Cmd {
return nil
}
func (m *model) handleConversationUpdate(msg tea.Msg) []tea.Cmd { func (m *model) handleConversationUpdate(msg tea.Msg) []tea.Cmd {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
@ -395,6 +408,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
switch m.state { switch m.state {
case stateConversationList:
cmds = append(cmds, m.handleConversationListUpdate(msg)...)
case stateConversation: case stateConversation:
cmds = append(cmds, m.handleConversationUpdate(msg)...) cmds = append(cmds, m.handleConversationUpdate(msg)...)
} }
@ -439,6 +454,11 @@ func (m model) View() string {
} }
switch m.state { switch m.state {
case stateConversationList:
sections = append(sections, m.views.content)
if m.views.error != "" {
sections = append(sections, m.views.error)
}
case stateConversation: case stateConversation:
sections = append(sections, m.views.content) sections = append(sections, m.views.content)
if m.views.error != "" { if m.views.error != "" {
@ -451,23 +471,26 @@ func (m model) View() string {
sections = append(sections, m.views.footer) sections = append(sections, m.views.footer)
} }
return lipgloss.JoinVertical( return lipgloss.JoinVertical(lipgloss.Left, sections...)
lipgloss.Left,
sections...,
)
} }
func (m *model) headerView() string { func (m *model) headerView() string {
titleStyle := lipgloss.NewStyle().Bold(true) titleStyle := lipgloss.NewStyle().Bold(true)
var title string var header string
if m.conversation != nil && m.conversation.Title != "" { switch m.state {
title = m.conversation.Title case stateConversation:
} else { var title string
title = "Untitled" if m.conversation != nil && m.conversation.Title != "" {
title = m.conversation.Title
} else {
title = "Untitled"
}
title = truncateToCellWidth(title, m.width-headerStyle.GetHorizontalPadding(), "...")
header = titleStyle.Render(title)
case stateConversationList:
header = titleStyle.Render("Conversations")
} }
title = truncateToCellWidth(title, m.width-headerStyle.GetHorizontalPadding(), "...") return headerStyle.Width(m.width).Render(header)
part := titleStyle.Render(title)
return headerStyle.Width(m.width).Render(part)
} }
func (m *model) errorView() string { func (m *model) errorView() string {