Files
MujicaUI/project/subtask_list.go
T

161 lines
5.1 KiB
Go

package project
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SubtaskListOptions configure a SubtaskList.
type SubtaskListOptions struct {
Disabled bool
Label string
// Version changes when subtasks change in place.
Version int
}
// subtaskDrag is the ID of a subtask being dragged.
type subtaskDrag string
// SubtaskList edits and reorders subtasks with completion progress.
func SubtaskList(c *ui.Context, subs *[]Subtask, opts SubtaskListOptions) core.ChoiceResult {
k := core.Tokens(c)
d := core.Density(c)
label := opts.Label
if label == "" {
label = core.Msg(c, "subtask_list.label")
}
changed := false
col := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
draft := ui.Local(col, "subtask-draft", func() string { return "" })
cache := ui.Local(col, "subtask-summary", func() subtaskCache { return subtaskCache{} })
var first *Subtask
if len(*subs) > 0 {
first = &(*subs)[0]
}
if !cache.ready || cache.first != first || cache.n != len(*subs) || cache.version != opts.Version {
*cache = subtaskCache{first: first, n: len(*subs), version: opts.Version, ready: true}
seen := map[string]bool{}
for _, s := range *subs {
if s.ID == "" || seen[s.ID] {
panic(fmt.Sprintf("mujica: SubtaskList subtask %q is empty or repeated", s.ID))
}
seen[s.ID] = true
if s.Done {
cache.done++
}
}
}
state := ui.Local(col, "subtask-rows", func() ui.ListState { return ui.ListState{} })
state.Key = func(i int) any { return (*subs)[i].ID }
var pending func()
move := func(from, to int, how string) {
if col.IsDisabled() || to < 0 || to >= len(*subs) || from == to {
return
}
if pending == nil {
pending = func() {
step := (*subs)[from]
*subs = slices.Insert(slices.Delete(*subs, from, from+1), to, step)
changed = true
state.ScrollIntoView(to)
slog.Debug("mujica: subtask_list move", "id", step.ID, "to", to, "by", how)
}
}
}
col.Children(func() {
done := cache.done
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
frac := 0.0
if len(*subs) > 0 {
frac = float64(done) / float64(len(*subs))
}
ui.Box(c).Grow(1).Children(func() { projectBar(c, frac, label) })
ui.Text(c, fmt.Sprintf(core.Msg(c, "subtask_list.progress"), done, len(*subs))).FontSize(core.FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).FontFeatures("tnum")
})
removed := -1
ui.List(c, state, len(*subs), func(i int) {
s := &(*subs)[i]
row := ui.Row(c).Key(s.ID).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Role(ui.RoleListItem)
if id, ok := ui.Drop[subtaskDrag](row); ok {
if from := slices.IndexFunc(*subs, func(o Subtask) bool { return o.ID == string(id) }); from >= 0 {
move(from, i, "drag")
}
}
if _, over := ui.DragOver[subtaskDrag](row); over {
row.Background(k.Selection)
}
row.Children(func() {
grip := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Center().Cursor(ui.CursorGrab).Label(fmt.Sprintf(core.Msg(c, "subtask_list.drag"), s.Title))
if !grip.IsDisabled() {
grip.Drag(subtaskDrag(s.ID))
}
grip.Children(func() { ui.Icon(c, icons.Must("grip-vertical")).Size(14, 14).TextColor(k.TextMuted) })
v := s.Done
cb := input.Checkbox(c, &v, s.Title, input.CheckboxOptions{Disabled: opts.Disabled})
if cb.Changed() && !cb.IsDisabled() {
s.Done, changed = v, true
}
if !cb.IsDisabled() {
if cb.Shortcut(ui.Alt, ui.KeyUp) {
move(i, i-1, "keys")
}
if cb.Shortcut(ui.Alt, ui.KeyDown) {
move(i, i+1, "keys")
}
}
ui.Spacer(c)
if b := core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "subtask_list.remove"), s.Title)); b.Clicked() && !b.IsDisabled() {
removed = i
}
})
}).Height(min(float32(len(*subs)), 8) * d.ControlHeight())
if removed >= 0 && !col.IsDisabled() {
slog.Debug("mujica: subtask_list remove", "id", (*subs)[removed].ID)
*subs, changed = slices.Delete(*subs, removed, removed+1), true
}
in := input.TextInput(c, draft, input.TextInputOptions{Placeholder: core.Msg(c, "subtask_list.add"), Label: core.Msg(c, "subtask_list.add"), Disabled: opts.Disabled})
if in.Submitted() && strings.TrimSpace(*draft) != "" && !col.IsDisabled() {
*subs = append(*subs, Subtask{ID: subtaskNewID(*subs), Title: strings.TrimSpace(*draft)})
*draft, changed = "", true
slog.Debug("mujica: subtask_list add")
}
})
if pending != nil {
pending()
}
if changed {
cache.ready = false
c.Invalidate()
}
return core.NewChoiceResult(col, changed)
}
// subtaskNewID returns an ID no subtask uses.
func subtaskNewID(subs []Subtask) string {
for n := len(subs) + 1; ; n++ {
id := fmt.Sprintf("sub-%d", n)
if !slices.ContainsFunc(subs, func(s Subtask) bool { return s.ID == id }) {
return id
}
}
}
type subtaskCache struct {
first *Subtask
n, version, done int
ready bool
}