161 lines
5.1 KiB
Go
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
|
|
}
|