Files
MujicaUI/task_list.go
T

216 lines
6.7 KiB
Go

package mujica
import (
"fmt"
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// TaskGrouping chooses the sections of a TaskList.
type TaskGrouping int
const (
TaskGroupNone TaskGrouping = iota
TaskGroupStatus
TaskGroupPriority
)
// TaskFilter shows all, open or done tasks.
type TaskFilter int
const (
TaskShowAll TaskFilter = iota
TaskShowOpen
TaskShowDone
)
// TaskListOptions configure a TaskList.
type TaskListOptions struct {
GroupBy TaskGrouping
// Statuses order the status groups; required with TaskGroupStatus.
Statuses []WorkflowStatus
Members []Member
// Version must change when tasks change in place.
Version int
// Today anchors overdue marks; zero uses the system clock.
Today Date
Disabled bool
Label string
}
// taskRow identifies a heading or task.
type taskRow struct {
head string
task int
}
// taskListCache keeps rows until the data, grouping or filter change.
type taskListCache struct {
first *Task
n, ver int
group TaskGrouping
filter TaskFilter
rows []taskRow
counted [3]int
statuses []WorkflowStatus
locale Locale
}
// TaskList groups and filters tasks, virtualizing visible rows.
func TaskList(c *ui.Context, tasks []Task, filter *TaskFilter, opts TaskListOptions) ProjectResult {
if opts.GroupBy < TaskGroupNone || opts.GroupBy > TaskGroupPriority || *filter < TaskShowAll || *filter > TaskShowDone {
panic(fmt.Sprintf("mujica: TaskList grouping %d or filter %d out of range", opts.GroupBy, *filter))
}
if opts.GroupBy == TaskGroupStatus && len(opts.Statuses) == 0 {
panic("mujica: TaskList grouping by status needs Statuses")
}
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "task_list.label")
}
r := ProjectResult{}
root := ui.Column(c).Grow(1).MinHeight(0).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
cache := ui.Local(root, "task-list", func() *taskListCache { return &taskListCache{} })
var first *Task
if len(tasks) > 0 {
first = &tasks[0]
}
if x := *cache; x.rows == nil || x.first != first || x.n != len(tasks) || x.ver != opts.Version || x.group != opts.GroupBy || x.filter != *filter || !slices.Equal(x.statuses, opts.Statuses) || x.locale != current(c).locale {
*cache = &taskListCache{first: first, n: len(tasks), ver: opts.Version, group: opts.GroupBy, filter: *filter, statuses: slices.Clone(opts.Statuses), locale: current(c).locale}
(*cache).rows, (*cache).counted = taskListRows(c, tasks, opts, *filter)
}
rows := (*cache).rows
state := ui.Local(root, "task-list-state", func() *ui.ListState { return &ui.ListState{} })
st := *state
st.Key = func(i int) any {
if rows[i].task < 0 {
return "head:" + rows[i].head
}
return tasks[rows[i].task].ID
}
st.Header = func(i int) bool { return rows[i].task < 0 }
root.Children(func() {
counts := (*cache).counted
sel := int(*filter)
taskListFilter(c, &sel, []string{
fmt.Sprintf(msg(c, "task_list.all"), counts[0]),
fmt.Sprintf(msg(c, "task_list.open"), counts[1]),
fmt.Sprintf(msg(c, "task_list.done"), counts[2]),
})
if sel != int(*filter) && !root.IsDisabled() {
*filter = TaskFilter(sel)
slog.Debug("mujica: task_list filter", "filter", *filter)
}
list := ui.List(c, st, len(rows), func(i int) {
row := rows[i]
if row.task < 0 {
h := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).PaddingX(d.Unit() * 2).Background(k.Background).Role(ui.RoleColumnHeader)
h.Children(func() { ui.Text(c, row.head).Font(theme.SerifFont).TextColor(k.Text) })
h.DrawOver(func(p *ui.Painter, rr ui.Rect) { internal.Rule(p, rr.X, rr.X+rr.W, rr.Y+rr.H-1, k.Ornament) })
return
}
ir := TaskItem(c, &tasks[row.task], TaskItemOptions{Members: opts.Members, Today: opts.Today, Disabled: opts.Disabled})
if id, ok := ir.Changed(); ok {
r.changed = id
(*cache).rows = nil
c.Invalidate()
}
if id, ok := ir.Opened(); ok {
r.opened = id
}
}).Grow(1).MinHeight(d.ControlHeight() * 4)
list.Children(func() {
if len(rows) == 0 {
ui.Text(c, msg(c, "task_list.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 3)
}
})
})
return r
}
// taskListRows computes groups and completion counts.
func taskListRows(c *ui.Context, tasks []Task, opts TaskListOptions, filter TaskFilter) ([]taskRow, [3]int) {
var counts [3]int
keep := make([]int, 0, len(tasks))
for i, t := range tasks {
counts[0]++
if t.Done {
counts[2]++
} else {
counts[1]++
}
if filter == TaskShowAll || (filter == TaskShowDone) == t.Done {
keep = append(keep, i)
}
}
rows := make([]taskRow, 0, len(keep)+8)
switch opts.GroupBy {
case TaskGroupNone:
for _, i := range keep {
rows = append(rows, taskRow{task: i})
}
case TaskGroupStatus:
for _, s := range opts.Statuses {
rows = taskListSection(rows, s.Name, keep, func(i int) bool { return tasks[i].Status == s.ID })
}
for _, i := range keep {
projectStatus(opts.Statuses, tasks[i].Status)
}
case TaskGroupPriority:
for p := PriorityUrgent; p >= PriorityNone; p-- {
rows = taskListSection(rows, PriorityName(c, p), keep, func(i int) bool { return tasks[i].Priority == p })
}
}
return slices.Clip(rows), counts
}
// taskListSection appends a heading and its tasks when any match.
func taskListSection(rows []taskRow, head string, keep []int, in func(int) bool) []taskRow {
start := len(rows)
for _, i := range keep {
if in(i) {
if len(rows) == start {
rows = append(rows, taskRow{head: head, task: -1})
}
rows = append(rows, taskRow{task: i})
}
}
return rows
}
// taskListFilter marks the selected completion filter with an inset.
func taskListFilter(c *ui.Context, value *int, labels []string) {
k, d := Tokens(c), Density(c)
parts := ui.SegmentedBase(c, value, len(labels))
parts.Track.MinHeight(max(d.ControlHeight(), theme.MinHitSize+4)).AlignItems(ui.Stretch).Padding(2).Gap(2).Radius(theme.ControlRadius+2).
Background(k.Background).Border(theme.BorderWidth, k.ControlBorder).Label(msg(c, "task_list.filter"))
parts.Track.Children(func() {
for i, label := range labels {
selected := i == *value
seg := parts.Segment(i).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize).PaddingX(d.Unit() * 3).Radius(theme.ControlRadius).Label(label)
fg := k.TextMuted
if selected {
fg = k.AccentText
seg.Background(k.Selection).FontWeight(600)
seg.Draw(func(p *ui.Painter, r ui.Rect) {
p.Stroke(ui.Rect{X: r.X + 2, Y: r.Y + 2, W: r.W - 4, H: r.H - 4}, k.AccentText, theme.ControlRadius, 1)
})
} else if seg.Hovered() {
fg = k.Text
seg.Background(k.SurfaceHover)
}
internal.Ease(seg, Motion(c, theme.StateDuration), fg)
internal.Ring(seg, k.Focus, theme.ControlRadius)
seg.Children(func() { ui.Text(c, label).SingleLine() })
}
})
}