Files
MujicaUI/conversation_list.go
T
2026-10-05 14:24:42 -04:00

199 lines
6.1 KiB
Go

package mujica
import (
"cmp"
"log/slog"
"slices"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ChatConversation is one saved conversation, as the caller stores it.
type ChatConversation struct {
ID, Title string
Updated time.Time
Pinned bool
}
// ConversationListOptions configure a ConversationList.
type ConversationListOptions struct {
// Now dates the groups; zero uses the window clock.
Now time.Time
// Label names the list for assistive technology.
Label string
Disabled bool
}
// ConversationListState is the chosen conversation and the list's place.
type ConversationListState struct {
// Selected is the chosen conversation's ID; empty for none.
Selected string
rows ui.ListState
cursor int
plan conversationListPlan
}
// conversationListPlan is the grouped rows, rebuilt only when the data or day changes.
type conversationListPlan struct {
src []ChatConversation
day Date
rows []conversationListRow
built bool
}
// conversationListRow is group header g (item < 0) or an index into the items.
type conversationListRow struct {
group, item int
}
// ConversationListView is a built ConversationList and this frame's events.
type ConversationListView struct {
Element *ui.Element
changed bool
submitted bool
}
// Changed reports a newly chosen conversation, in State.Selected.
func (v ConversationListView) Changed() bool { return v.changed }
// Submitted reports Enter or a double click on the chosen conversation.
func (v ConversationListView) Submitted() bool { return v.submitted }
// ConversationList groups conversations into pinned, today, yesterday, past 7 days and older, building only rows in view.
func ConversationList(c *ui.Context, s *ConversationListState, items []ChatConversation, opts ConversationListOptions, row func(conv ChatConversation)) ConversationListView {
if s == nil {
panic("mujica: ConversationList needs a state")
}
now := opts.Now
if now.IsZero() {
now = c.Now()
}
conversationListPlanRows(&s.plan, items, DateOf(now))
rows := s.plan.rows
s.cursor = slices.IndexFunc(rows, func(r conversationListRow) bool { return r.item >= 0 && items[r.item].ID == s.Selected })
before := s.cursor
k := Tokens(c)
d := Density(c)
cursor := &s.cursor
if opts.Disabled {
// A disabled list moves a copy of the cursor.
cp := s.cursor
cursor = &cp
}
s.rows.Key = func(i int) any {
if r := rows[i]; r.item >= 0 {
return items[r.item].ID
}
return conversationListGroups[rows[i].group]
}
s.rows.Header = func(i int) bool { return rows[i].item < 0 }
s.rows.Label = func(i int) string { return conversationListTitle(c, items, rows[i]) }
s.rows.Selected = cursor
var list *ui.Element
wrap := ui.Column(c).MinHeight(0).Disabled(opts.Disabled)
wrap.Children(func() {
restore := dataSelectTheme(c, 0)
list = ui.List(c, &s.rows, len(rows), func(i int) {
r := rows[i]
if r.item < 0 {
conversationListHeader(c, k, msg(c, conversationListGroups[r.group]))
return
}
conv := items[r.item]
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, b ui.Rect) {
if conv.ID == s.Selected {
p.FillPath(internal.Diamond(b.X+b.W/2, b.Y+b.H/2, 8), k.AccentText)
}
})
if row != nil {
ui.Row(c).Grow(1).MinWidth(0).Children(func() { row(conv) })
return
}
ui.Text(c, conversationListTitle(c, items, r)).SingleLine().Grow(1).MinWidth(0)
})
}).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Radius(theme.ControlRadius).Label(opts.Label)
restore()
internal.Ring(list, k.Focus, theme.ControlRadius)
})
v := ConversationListView{Element: wrap}
if !wrap.IsDisabled() {
if s.cursor != before && s.cursor >= 0 && rows[s.cursor].item >= 0 {
s.Selected, v.changed = items[rows[s.cursor].item].ID, true
slog.Debug("mujica: conversation_list select", "id", s.Selected)
}
v.submitted = list.Submitted() && s.Selected != ""
}
return v
}
func conversationListTitle(c *ui.Context, items []ChatConversation, r conversationListRow) string {
if r.item < 0 {
return msg(c, conversationListGroups[r.group])
}
return cmp.Or(items[r.item].Title, msg(c, "conversation_list.untitled"))
}
func conversationListHeader(c *ui.Context, k theme.Tokens, title string) {
d := Density(c)
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).AlignItems(ui.Center).Background(k.Surface).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).SingleLine()
})
}
// conversationListPlanRows regroups items when the slice or the day changed.
func conversationListPlanRows(p *conversationListPlan, items []ChatConversation, today Date) {
same := p.built && p.day == today && len(p.src) == len(items) && (len(items) == 0 || &p.src[0] == &items[0])
if same {
return
}
p.src, p.day, p.built = items, today, true
order := make([]int, len(items))
for i := range order {
order[i] = i
}
slices.SortStableFunc(order, func(a, b int) int { return items[b].Updated.Compare(items[a].Updated) })
groups := [5][]int{}
for _, i := range order {
g := conversationListGroup(items[i], today)
groups[g] = append(groups[g], i)
}
p.rows = p.rows[:0]
for g, idx := range groups {
if len(idx) == 0 {
continue
}
p.rows = append(p.rows, conversationListRow{group: g, item: -1})
for _, i := range idx {
p.rows = append(p.rows, conversationListRow{item: i})
}
}
slog.Debug("mujica: conversation_list regroup", "count", len(items))
}
var conversationListGroups = [5]string{
"conversation_list.pinned", "conversation_list.today", "conversation_list.yesterday",
"conversation_list.week", "conversation_list.older",
}
// conversationListGroup is the group index of conv: pinned, today, yesterday, 7 days, older.
func conversationListGroup(conv ChatConversation, today Date) int {
day := DateOf(conv.Updated)
switch {
case conv.Pinned:
return 0
case day.Compare(today) >= 0:
return 1
case day == today.AddDays(-1):
return 2
case day.Compare(today.AddDays(-7)) >= 0:
return 3
}
return 4
}