Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,238 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AssigneePickerOptions configure an AssigneePicker.
|
||||
type AssigneePickerOptions struct {
|
||||
// Max limits selections; zero allows any number.
|
||||
Max int
|
||||
Disabled bool
|
||||
Label string
|
||||
// Version changes when members or selection change in place.
|
||||
Version int
|
||||
}
|
||||
|
||||
type assigneeState struct {
|
||||
first *ProjectMember
|
||||
n, version int
|
||||
ready, open bool
|
||||
query, filtered string
|
||||
options, shown []input.SelectOption[string]
|
||||
members map[string]ProjectMember
|
||||
selected map[string]bool
|
||||
selectedFirst *string
|
||||
selectedN, count int
|
||||
list ui.ListState
|
||||
focus string
|
||||
}
|
||||
|
||||
// AssigneePicker searches and selects members, showing their avatars.
|
||||
func AssigneePicker(c *ui.Context, ids *[]string, members []ProjectMember, opts AssigneePickerOptions) core.ChoiceResult {
|
||||
if opts.Max < 0 {
|
||||
panic("mujica: AssigneePicker Max must be non-negative")
|
||||
}
|
||||
d, k := core.Density(c), core.Tokens(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "assignee_picker.label")
|
||||
}
|
||||
row := ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Disabled(opts.Disabled)
|
||||
st := ui.Local(row, "assignees", func() assigneeState { return assigneeState{} })
|
||||
var first *ProjectMember
|
||||
var selectedFirst *string
|
||||
if len(members) > 0 {
|
||||
first = &members[0]
|
||||
}
|
||||
if len(*ids) > 0 {
|
||||
selectedFirst = &(*ids)[0]
|
||||
}
|
||||
dataChanged := !st.ready || st.first != first || st.n != len(members) || st.version != opts.Version
|
||||
if dataChanged {
|
||||
st.first, st.n, st.version, st.ready = first, len(members), opts.Version, true
|
||||
st.members = make(map[string]ProjectMember, len(members))
|
||||
st.options = make([]input.SelectOption[string], len(members))
|
||||
for i, m := range members {
|
||||
if _, exists := st.members[m.ID]; exists || m.ID == "" {
|
||||
panic("mujica: AssigneePicker needs unique non-empty IDs")
|
||||
}
|
||||
st.members[m.ID] = m
|
||||
st.options[i] = input.SelectOption[string]{Value: m.ID, Label: m.Name}
|
||||
}
|
||||
st.shown = nil
|
||||
}
|
||||
before := *ids
|
||||
var trigger *ui.Element
|
||||
row.Children(func() {
|
||||
if len(*ids) > 0 {
|
||||
items := make([]display.AvatarItem, min(3, len(*ids)))
|
||||
for i := range items {
|
||||
items[i] = display.AvatarItem{Name: st.members[(*ids)[i]].Name}
|
||||
}
|
||||
display.AvatarGroup(c, items, display.AvatarGroupOptions{Max: 3, Size: 28})
|
||||
}
|
||||
trigger = ui.ButtonBase(c).Disabled(row.IsDisabled()).Role(ui.RolePopUpButton).Label(label)
|
||||
if trigger.IsDisabled() {
|
||||
st.open = false
|
||||
}
|
||||
if !trigger.IsDisabled() && (trigger.Clicked() || !st.open && trigger.Shortcut(0, ui.KeyDown)) {
|
||||
st.open, st.query = !st.open, ""
|
||||
}
|
||||
if len(*ids) > 0 && !trigger.IsDisabled() && trigger.Shortcut(0, ui.KeyBackspace) {
|
||||
*ids = slices.Clone((*ids)[:len(*ids)-1])
|
||||
}
|
||||
input.SelectField(c, trigger, st.open, func() {
|
||||
options := make([]input.SelectOption[string], min(2, len(*ids)))
|
||||
for i := range options {
|
||||
options[i] = input.SelectOption[string]{Value: (*ids)[i], Label: st.members[(*ids)[i]].Name}
|
||||
}
|
||||
input.MultiSelectTags(c, ids, options, input.MultiSelectOptions{MaxTags: 2, Disabled: trigger.IsDisabled(), Placeholder: core.Msg(c, "assignee_picker.placeholder")})
|
||||
})
|
||||
ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) {
|
||||
input.SelectPanel(c, panel)
|
||||
ww, wh := c.Size()
|
||||
panel.MinWidth(min(ww-8, trigger.Bounds().W)).MaxWidth(max(1, ww-8)).MaxHeight(max(1, wh-8))
|
||||
ui.Scroll(c).MaxHeight(max(1, wh-24)).Children(func() {
|
||||
search := input.SelectSearch(c, &st.query, core.Msg(c, "multi_select.search"))
|
||||
q := strings.ToLower(strings.TrimSpace(st.query))
|
||||
if st.shown == nil || st.filtered != q {
|
||||
st.shown = make([]input.SelectOption[string], 0)
|
||||
for _, o := range st.options {
|
||||
if strings.Contains(strings.ToLower(o.Label), q) {
|
||||
st.shown = append(st.shown, o)
|
||||
}
|
||||
}
|
||||
st.filtered = q
|
||||
st.list.ScrollTo(0, ui.Start)
|
||||
dataChanged = true
|
||||
}
|
||||
if dataChanged || st.selectedFirst != selectedFirst || st.selectedN != len(*ids) || st.selected == nil {
|
||||
st.selected = make(map[string]bool, len(*ids))
|
||||
for _, id := range *ids {
|
||||
if _, ok := st.members[id]; !ok {
|
||||
panic("mujica: AssigneePicker selection contains an unknown ID")
|
||||
}
|
||||
if st.selected[id] {
|
||||
panic("mujica: AssigneePicker selection contains duplicate IDs")
|
||||
}
|
||||
st.selected[id] = true
|
||||
}
|
||||
st.selectedFirst, st.selectedN, st.count = selectedFirst, len(*ids), 0
|
||||
for _, o := range st.shown {
|
||||
if st.selected[o.Value] {
|
||||
st.count++
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(st.shown) == 0 {
|
||||
input.SelectNote(c, core.Msg(c, "multi_select.no_results"))
|
||||
return
|
||||
}
|
||||
on, full := st.count == len(st.shown), opts.Max > 0 && len(*ids) >= opts.Max
|
||||
mark := internal.Unchecked
|
||||
if on {
|
||||
mark = internal.Checked
|
||||
} else if st.count > 0 {
|
||||
mark = internal.Mixed
|
||||
}
|
||||
var all *ui.Element
|
||||
ui.Row(c).MinHeight(d.ControlHeight()).Children(func() {
|
||||
all = input.MultiSelectCheck(c, k, &on, mark, core.Msg(c, "multi_select.all"), full && st.count == 0)
|
||||
})
|
||||
if search.Shortcut(0, ui.KeyDown) {
|
||||
all.Focus()
|
||||
}
|
||||
if all.Changed() && !all.IsDisabled() {
|
||||
*ids = input.MultiSelectAll(*ids, st.shown, on, opts.Max)
|
||||
}
|
||||
if all.Shortcut(0, ui.KeyDown) && !all.IsDisabled() {
|
||||
for i, o := range st.shown {
|
||||
if full && !st.selected[o.Value] {
|
||||
continue
|
||||
}
|
||||
st.focus = o.Value
|
||||
st.list.ScrollIntoView(i)
|
||||
c.Invalidate()
|
||||
break
|
||||
}
|
||||
}
|
||||
st.list.Key = func(i int) any { return st.shown[i].Value }
|
||||
ui.List(c, &st.list, len(st.shown), func(i int) {
|
||||
o := st.shown[i]
|
||||
selected := st.selected[o.Value]
|
||||
state := internal.Unchecked
|
||||
if selected {
|
||||
state = internal.Checked
|
||||
}
|
||||
var cb *ui.Element
|
||||
ui.Row(c).MinHeight(d.ControlHeight()).Children(func() { cb = input.MultiSelectCheck(c, k, &selected, state, o.Label, full && !selected) })
|
||||
if st.focus == o.Value && !cb.IsDisabled() {
|
||||
cb.Focus()
|
||||
st.focus = ""
|
||||
}
|
||||
if cb.Changed() && !cb.IsDisabled() {
|
||||
*ids = input.MultiSelectSet(*ids, o.Value, selected)
|
||||
}
|
||||
if cb.IsDisabled() {
|
||||
return
|
||||
}
|
||||
to := i
|
||||
switch {
|
||||
case cb.Shortcut(0, ui.KeyDown):
|
||||
to = assigneeNext(st, i, 1, full)
|
||||
case cb.Shortcut(0, ui.KeyUp):
|
||||
to = assigneeNext(st, i, -1, full)
|
||||
case cb.Shortcut(0, ui.KeyHome):
|
||||
to = assigneeNext(st, -1, 1, full)
|
||||
case cb.Shortcut(0, ui.KeyEnd):
|
||||
to = assigneeNext(st, len(st.shown), -1, full)
|
||||
}
|
||||
if to != i {
|
||||
st.focus = st.shown[to].Value
|
||||
st.list.ScrollIntoView(to)
|
||||
c.Invalidate()
|
||||
}
|
||||
}).Height(min(240, min(float32(len(st.shown))*d.ControlHeight(), max(d.ControlHeight(), wh-140)))).Padding(4)
|
||||
if opts.Max > 0 {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "multi_select.limit"), len(*ids), opts.Max)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
changed := !slices.Equal(before, *ids)
|
||||
if dataChanged && !st.open {
|
||||
for _, id := range *ids {
|
||||
if _, ok := st.members[id]; !ok {
|
||||
panic("mujica: AssigneePicker selection contains an unknown ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
st.selected = nil
|
||||
slog.Debug("mujica: assignee_picker change", "count", len(*ids))
|
||||
c.Invalidate()
|
||||
}
|
||||
return core.NewChoiceResult(row, changed)
|
||||
}
|
||||
|
||||
// assigneeNext skips choices disabled by the selection limit.
|
||||
func assigneeNext(st *assigneeState, from, direction int, full bool) int {
|
||||
for i := from + direction; i >= 0 && i < len(st.shown); i += direction {
|
||||
if !full || st.selected[st.shown[i].Value] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return from
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func assigneeTester(ids *[]string, opts *AssigneePickerOptions, n *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
if AssigneePicker(c, ids, projMembers, *opts).Changed() {
|
||||
*n++
|
||||
}
|
||||
})
|
||||
}, 520, 520)
|
||||
}
|
||||
|
||||
func TestAssigneePickerSearchAndAvatars(t *testing.T) {
|
||||
ids, n := []string{"saki"}, 0
|
||||
tt := assigneeTester(&ids, &AssigneePickerOptions{}, &n)
|
||||
if !tt.HasText("Sakiko Togawa") {
|
||||
t.Fatalf("avatar label missing: %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Assignees")
|
||||
tt.Type("mutsu")
|
||||
if tt.HasText("Uika Misumi") {
|
||||
t.Fatalf("search should filter: %v", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !slices.Contains(ids, "mutsu") || n == 0 {
|
||||
t.Fatalf("ids %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneePickerMaxDisabledInvalid(t *testing.T) {
|
||||
ids, n := []string{"saki"}, 0
|
||||
tt := assigneeTester(&ids, &AssigneePickerOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Assignees")
|
||||
tt.Type("mutsu")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(ids) != 1 || n != 0 {
|
||||
t.Fatal("disabled picker changed")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for an unknown member")
|
||||
}
|
||||
}()
|
||||
bad := []string{"ghost"}
|
||||
assigneeTester(&bad, &AssigneePickerOptions{}, &n)
|
||||
}
|
||||
|
||||
func TestAssigneePickerSnapshot(t *testing.T) {
|
||||
ids := []string{"saki", "mutsu", "uika", "nyamu"}
|
||||
snapshot(t, "assignee_picker", 560, 80, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { AssigneePicker(c, &ids, projMembers, AssigneePickerOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssigneePickerAncestorDisableCloses(t *testing.T) {
|
||||
ids, disabled := []string{}, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() { AssigneePicker(c, &ids, projMembers, AssigneePickerOptions{}) })
|
||||
}, 500, 520)
|
||||
if err := tt.Click("Assignees"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Select all") {
|
||||
t.Fatal("ancestor-disabled popup remains visible")
|
||||
}
|
||||
if err := tt.Click("Sakiko Togawa"); err == nil && len(ids) != 0 {
|
||||
t.Fatal("disabled popup selected a member")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneePickerLargeData(t *testing.T) {
|
||||
members := make([]ProjectMember, 100000)
|
||||
for i := range members {
|
||||
members[i] = ProjectMember{ID: fmt.Sprint(i), Name: fmt.Sprintf("Member %d", i)}
|
||||
}
|
||||
var ids []string
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
AssigneePicker(c, &ids, members, AssigneePickerOptions{})
|
||||
}, 500, 520)
|
||||
if err := tt.Click("Assignees"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if built := len(tt.Texts()); built > 200 {
|
||||
t.Fatalf("built %d labels", built)
|
||||
}
|
||||
tt.Type("Member 99999")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !slices.Equal(ids, []string{"99999"}) {
|
||||
t.Fatalf("selected %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneePickerFullKeyboardSkipsDisabled(t *testing.T) {
|
||||
ids, changes := []string{"mutsu"}, 0
|
||||
tt := assigneeTester(&ids, &AssigneePickerOptions{Max: 1}, &changes)
|
||||
if err := tt.Click("Assignees"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(ids) != 0 || changes != 1 {
|
||||
t.Fatalf("selected = %v, changes = %d", ids, changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneePickerVersionRefreshAndBulkClear(t *testing.T) {
|
||||
ids, changes := []string{"saki"}, 0
|
||||
opts := &AssigneePickerOptions{}
|
||||
tt := assigneeTester(&ids, opts, &changes)
|
||||
if err := tt.Click("Assignees"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[0], opts.Version = "mutsu", 1
|
||||
tt.Frame()
|
||||
tt.Type("Mutsumi")
|
||||
if err := tt.Click("Select all"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 0 || changes != 1 {
|
||||
t.Fatalf("selected = %v, changes = %d", ids, changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneePickerCompactHitAreas(t *testing.T) {
|
||||
var ids []string
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Density: theme.Compact})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
AssigneePicker(c, &ids, []ProjectMember{{ID: "only", Name: "Only Member"}}, AssigneePickerOptions{})
|
||||
})
|
||||
}, 500, 520)
|
||||
if err := tt.Click("Assignees"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, label := range []string{"Select all", "Only Member"} {
|
||||
r, ok := tt.Find(label)
|
||||
if !ok || r.H < 28 {
|
||||
t.Fatalf("%s hit height = %g", label, r.H)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// BurndownChartOptions configure a BurndownChart.
|
||||
type BurndownChartOptions struct {
|
||||
Frame chart.ChartFrame
|
||||
// Start and End are the sprint's first and last days.
|
||||
Start, End core.Date
|
||||
// Total starts the ideal line, which reaches zero.
|
||||
Total float64
|
||||
// Format writes amounts; nil uses up to two decimals.
|
||||
Format func(v float64) string
|
||||
// Version changes when remaining work changes in place.
|
||||
Version int
|
||||
}
|
||||
|
||||
// BurndownChart compares remaining work with the ideal line.
|
||||
func BurndownChart(c *ui.Context, remaining []float64, opts BurndownChartOptions) chart.ChartResult {
|
||||
core.DatetimeCheckDate("BurndownChart Start", opts.Start)
|
||||
core.DatetimeCheckDate("BurndownChart End", opts.End)
|
||||
if opts.Start.IsZero() || opts.End.Compare(opts.Start) <= 0 || !(opts.Total > 0) || math.IsInf(opts.Total, 0) {
|
||||
panic(fmt.Sprintf("mujica: BurndownChart needs Start < End and a positive Total, got %v..%v %v", opts.Start, opts.End, opts.Total))
|
||||
}
|
||||
days := int((opts.End.Civil().Unix()-opts.Start.Civil().Unix())/86400) + 1
|
||||
if len(remaining) > days {
|
||||
panic(fmt.Sprintf("mujica: BurndownChart has %d values for %d days", len(remaining), days))
|
||||
}
|
||||
format := opts.Format
|
||||
if format == nil {
|
||||
format = func(v float64) string {
|
||||
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(v, 'f', 2, 64), "0"), ".")
|
||||
}
|
||||
}
|
||||
ch := chart.NewChart(c, opts.Frame)
|
||||
summary := chart.ChartMemo(ch, "burndown-domain", struct {
|
||||
data string
|
||||
total float64
|
||||
version int
|
||||
}{chart.ChartID(remaining), opts.Total, opts.Version}, func() float64 {
|
||||
hi := opts.Total
|
||||
for _, v := range remaining {
|
||||
if v < 0 || math.IsInf(v, 0) {
|
||||
panic("mujica: BurndownChart amounts must be non-negative and finite or NaN")
|
||||
}
|
||||
if !math.IsNaN(v) {
|
||||
hi = max(hi, v)
|
||||
}
|
||||
}
|
||||
return hi
|
||||
})
|
||||
cache := ui.Local(ch.Res.Element, "burndown-samples", func() burndownCache { return burndownCache{} })
|
||||
k := core.Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
ideal := func(i int) float64 { return opts.Total * (1 - float64(i)/float64(days-1)) }
|
||||
actual := func(i int) float64 {
|
||||
if i < len(remaining) {
|
||||
return remaining[i]
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
lo, top, step := internal.Nice(0, summary, 4)
|
||||
ticks := internal.Ticks(lo, top, step)
|
||||
keys := []chart.ChartLegendItem{{Name: core.Msg(c, "burndown_chart.actual"), Color: pal[0], Shape: 0}, {Name: core.Msg(c, "burndown_chart.ideal"), Color: pal[5], Shape: 1}}
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
|
||||
plot := chart.ChartInset(r, 48, 26)
|
||||
return plot, internal.Linear{D0: 0, D1: float64(days - 1), R0: float64(plot.X), R1: float64(plot.X + plot.W)},
|
||||
internal.Linear{D0: lo, D1: top, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
n := days
|
||||
if len(remaining) == 0 {
|
||||
n = 0
|
||||
}
|
||||
spec := chart.ChartSpec{Marks: n, Legend: keys, Hidden: ch.Toggles(true)}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
plot, xs, _ := geom(r)
|
||||
if x < plot.X-8 || x > plot.X+plot.W+8 {
|
||||
return -1
|
||||
}
|
||||
return max(0, min(days-1, int(math.Round(xs.Invert(x)))))
|
||||
}
|
||||
spec.Readout = func(i int) string {
|
||||
day := opts.Start.AddDays(i)
|
||||
parts := core.DatetimeMonthName(c, day.Month) + " " + strconv.Itoa(day.Day)
|
||||
if v := actual(i); !math.IsNaN(v) && !ch.Hidden(keys[0].Name) {
|
||||
parts += " · " + keys[0].Name + " " + format(v)
|
||||
}
|
||||
if !ch.Hidden(keys[1].Name) {
|
||||
parts += " · " + keys[1].Name + " " + format(math.Round(ideal(i)*100)/100)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, xs, ys := geom(r)
|
||||
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k)
|
||||
var tx []float32
|
||||
var tl []string
|
||||
stride := max(1, int(math.Ceil(float64(days)/max(float64(plot.W)/70, 1))))
|
||||
for i := 0; i < days; i += stride {
|
||||
tx = append(tx, xs.Map(float64(i)))
|
||||
tl = append(tl, strconv.Itoa(opts.Start.AddDays(i).Day))
|
||||
}
|
||||
internal.AxisX(p, plot, tx, tl, size, k)
|
||||
pt := func(i int, v float64) (float32, float32) {
|
||||
return internal.Snap(p, xs.Map(float64(i))), internal.Snap(p, ys.Map(v))
|
||||
}
|
||||
if !ch.Hidden(keys[1].Name) {
|
||||
x0, y0 := pt(0, ideal(0))
|
||||
x1, y1 := pt(days-1, 0)
|
||||
burndownDashed(p, x0, y0, x1, y1, keys[1].Color)
|
||||
}
|
||||
if !ch.Hidden(keys[0].Name) {
|
||||
var line ui.Path
|
||||
open := false
|
||||
width := max(1, int(plot.W*p.Scale()))
|
||||
data := chart.ChartID(remaining)
|
||||
if cache.data != data || cache.version != opts.Version || cache.days != days || cache.width != width {
|
||||
cache.data, cache.version, cache.days, cache.width = data, opts.Version, days, width
|
||||
cache.points = burndownSamples(remaining, days, width)
|
||||
}
|
||||
lastRun := -1
|
||||
for _, point := range cache.points {
|
||||
x, y := pt(point.index, remaining[point.index])
|
||||
if open && lastRun == point.run {
|
||||
line.LineTo(x, y)
|
||||
} else {
|
||||
line.MoveTo(x, y)
|
||||
open = true
|
||||
}
|
||||
lastRun = point.run
|
||||
internal.Marker(p, 0, x, y, 7, keys[0].Color)
|
||||
}
|
||||
p.StrokePath(&line, 2, keys[0].Color)
|
||||
}
|
||||
if active >= 0 {
|
||||
ax, _ := pt(active, 0)
|
||||
p.Line(ax, plot.Y, ax, plot.Y+plot.H, 1, k.TextMuted)
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// burndownDashed draws the ideal line with a distinct pattern.
|
||||
func burndownDashed(p *ui.Painter, x0, y0, x1, y1 float32, col ui.Color) {
|
||||
l := float32(math.Hypot(float64(x1-x0), float64(y1-y0)))
|
||||
var path ui.Path
|
||||
for t := float32(0); t < l; t += 10 {
|
||||
e := min(t+6, l)
|
||||
path.MoveTo(internal.Snap(p, x0+(x1-x0)*t/l), internal.Snap(p, y0+(y1-y0)*t/l))
|
||||
path.LineTo(internal.Snap(p, x0+(x1-x0)*e/l), internal.Snap(p, y0+(y1-y0)*e/l))
|
||||
}
|
||||
p.StrokePath(&path, 1.5, col)
|
||||
}
|
||||
|
||||
type burndownPoint struct{ index, run int }
|
||||
type burndownCache struct {
|
||||
data string
|
||||
version, days, width int
|
||||
points []burndownPoint
|
||||
}
|
||||
|
||||
// burndownSamples keeps four finite points per device pixel.
|
||||
func burndownSamples(values []float64, days, width int) []burndownPoint {
|
||||
var out []burndownPoint
|
||||
run := 0
|
||||
for i := 0; i < len(values); {
|
||||
bucket := i * width / max(days-1, 1)
|
||||
var first, low, high, last burndownPoint
|
||||
count := 0
|
||||
for i < len(values) && i*width/max(days-1, 1) == bucket {
|
||||
v := values[i]
|
||||
if math.IsNaN(v) {
|
||||
run++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
point := burndownPoint{index: i, run: run}
|
||||
if count == 0 {
|
||||
first, low, high = point, point, point
|
||||
}
|
||||
if v < values[low.index] {
|
||||
low = point
|
||||
}
|
||||
if v > values[high.index] {
|
||||
high = point
|
||||
}
|
||||
last = point
|
||||
count++
|
||||
i++
|
||||
}
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
points := []burndownPoint{first, low, high, last}
|
||||
slices.SortFunc(points, func(a, b burndownPoint) int { return a.index - b.index })
|
||||
for j, point := range points {
|
||||
if j == 0 || points[j-1].index != point.index {
|
||||
out = append(out, point)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
var burndownRemaining = []float64{40, 38, 35, 35, 30, 26, math.NaN(), 20, 19}
|
||||
|
||||
func burndownTester(remaining []float64, opts BurndownChartOptions, points *[]int) *ui.Tester {
|
||||
opts.Frame.Label = "Sprint 12 burndown"
|
||||
opts.Start, opts.End = oct(1), oct(14)
|
||||
if opts.Total == 0 {
|
||||
opts.Total = 40
|
||||
}
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Width(600).Children(func() {
|
||||
if r := BurndownChart(c, remaining, opts); r.Changed() {
|
||||
*points = append(*points, r.Point)
|
||||
}
|
||||
})
|
||||
}, 640, 400)
|
||||
}
|
||||
|
||||
func TestBurndownChartReadoutAndKeys(t *testing.T) {
|
||||
var points []int
|
||||
tt := burndownTester(burndownRemaining, BurndownChartOptions{}, &points)
|
||||
if !tt.HasText("Remaining") || !tt.HasText("Ideal") {
|
||||
t.Fatalf("legend %v", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("October 3 · Remaining 35 · Ideal 33.85") {
|
||||
t.Fatalf("readout %v", tt.Texts())
|
||||
}
|
||||
if a := tt.Announcements(); len(a) == 0 || a[len(a)-1] != "October 3 · Remaining 35 · Ideal 33.85" {
|
||||
t.Fatalf("announce %q", a)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if !tt.HasText("October 14 · Ideal 0") {
|
||||
t.Fatalf("future day shows only the ideal: %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Ideal")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if !tt.HasText("October 1 · Remaining 40") || tt.HasText("Ideal 40") {
|
||||
t.Fatal("hidden series in readout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBurndownChartEmptyAndInvalid(t *testing.T) {
|
||||
var points []int
|
||||
tt := burndownTester(nil, BurndownChartOptions{Frame: chart.ChartFrame{Status: data.DataLoading}}, &points)
|
||||
if !tt.HasText("Loading") || tt.HasText("October") {
|
||||
t.Fatalf("loading state %v", tt.Texts())
|
||||
}
|
||||
for _, bad := range []BurndownChartOptions{{Total: -1}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("expected panic")
|
||||
}
|
||||
}()
|
||||
burndownTester(nil, bad, &points)
|
||||
}()
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for too many days")
|
||||
}
|
||||
}()
|
||||
burndownTester(make([]float64, 20), BurndownChartOptions{}, &points)
|
||||
}
|
||||
|
||||
func TestBurndownChartSnapshot(t *testing.T) {
|
||||
snapshot(t, "burndown_chart", 620, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
BurndownChart(c, burndownRemaining, BurndownChartOptions{Frame: chart.ChartFrame{Label: "Burndown"}, Start: oct(1), End: oct(14), Total: 40})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestBurndownChartInvalidAmounts(t *testing.T) {
|
||||
for _, values := range [][]float64{{-1}, {math.Inf(1)}} {
|
||||
t.Run("amount", func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected invalid amount panic")
|
||||
}
|
||||
}()
|
||||
var points []int
|
||||
burndownTester(values, BurndownChartOptions{}, &points)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBurndownChartEmptyFailedDisabled(t *testing.T) {
|
||||
var points []int
|
||||
tt := burndownTester(nil, BurndownChartOptions{}, &points)
|
||||
if !tt.HasText("No data") {
|
||||
t.Fatal("missing empty state")
|
||||
}
|
||||
tt = burndownTester(nil, BurndownChartOptions{Frame: chart.ChartFrame{Status: data.DataFailed, Error: "Unavailable"}}, &points)
|
||||
if !tt.HasText("Unavailable") {
|
||||
t.Fatal("missing error state")
|
||||
}
|
||||
tt = ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(true).Children(func() {
|
||||
r := BurndownChart(c, burndownRemaining, BurndownChartOptions{Frame: chart.ChartFrame{Label: "Burndown"}, Start: oct(1), End: oct(14), Total: 40})
|
||||
if _, ok := r.Toggled(); ok {
|
||||
t.Fatal("disabled legend toggled")
|
||||
}
|
||||
})
|
||||
}, 600, 340)
|
||||
_ = tt.Click("Ideal")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if len(tt.Announcements()) != 0 {
|
||||
t.Fatal("disabled chart announced a point")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBurndownChartLargeData(t *testing.T) {
|
||||
values := make([]float64, 100000)
|
||||
for i := range values {
|
||||
values[i] = float64(100000 - i)
|
||||
}
|
||||
opts := BurndownChartOptions{Frame: chart.ChartFrame{Label: "Large burndown"}, Start: core.Date{Year: 1900, Month: 1, Day: 1}, End: core.Date{Year: 1900, Month: 1, Day: 1}.AddDays(99999), Total: 100000}
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); BurndownChart(c, values, opts) }, 640, 360)
|
||||
points := burndownSamples(values, 100000, 600)
|
||||
if len(points) > 2404 {
|
||||
t.Fatalf("built %d points", len(points))
|
||||
}
|
||||
if points[0].index != 0 || points[len(points)-1].index != 99999 {
|
||||
t.Fatal("downsampling lost endpoints")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package project holds MujicaUI project management: issues, boards and time tracking.
|
||||
package project
|
||||
@@ -0,0 +1,79 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// DueDatePickerOptions configure a DueDatePicker.
|
||||
type DueDatePickerOptions struct {
|
||||
// Done stops a past date from showing as overdue.
|
||||
Done bool
|
||||
WeekStart datetime.WeekStart
|
||||
// Today anchors shortcuts and overdue marks; zero uses today.
|
||||
Today core.Date
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// DueDatePicker combines date shortcuts, a calendar and overdue marks.
|
||||
func DueDatePicker(c *ui.Context, value *core.Date, opts DueDatePickerOptions) datetime.DateTimeResult {
|
||||
core.DatetimeCheckDate("DueDatePicker value", *value)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
today := core.DatetimeToday(c, opts.Today)
|
||||
first := datetime.CalendarFirstWeekday(c, opts.WeekStart)
|
||||
nextWeek := today.AddDays(7 - (int(today.Weekday())-int(first)+7)%7)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "due_date_picker.label")
|
||||
}
|
||||
old := *value
|
||||
col := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
var r datetime.DateTimeResult
|
||||
col.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit()).Wrap().Children(func() {
|
||||
for _, q := range []struct {
|
||||
key string
|
||||
day core.Date
|
||||
}{{"due_date_picker.today", today}, {"due_date_picker.tomorrow", today.AddDays(1)}, {"due_date_picker.next_week", nextWeek}} {
|
||||
variant := input.Secondary
|
||||
text := core.Msg(c, q.key)
|
||||
if *value == q.day {
|
||||
variant = input.Outline
|
||||
text = "✓ " + text
|
||||
}
|
||||
b := input.Button(c, text, input.ButtonOptions{Variant: variant}).Label(core.Msg(c, q.key))
|
||||
if *value == q.day {
|
||||
b.Background(k.Selection).TextColor(k.Text)
|
||||
}
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
*value = q.day
|
||||
}
|
||||
}
|
||||
})
|
||||
r = datetime.DatePicker(c, value, datetime.DatePickerOptions{Label: label, WeekStart: opts.WeekStart, Today: today, Clearable: true, Disabled: col.IsDisabled()})
|
||||
if text, late := projectDueText(c, *value, today, opts.Done); text != "" {
|
||||
fg, icon := k.TextMuted, "calendar"
|
||||
if late {
|
||||
fg, icon = k.Danger, "triangle-alert"
|
||||
}
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(fg).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
}
|
||||
})
|
||||
changed := *value != old
|
||||
if changed {
|
||||
slog.Debug("mujica: due_date_picker change", "from", old, "to", *value)
|
||||
}
|
||||
return datetime.NewDateTimeResult(col, r.Input, changed, false)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func dueTester(v *core.Date, opts *DueDatePickerOptions, n *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
o := *opts
|
||||
o.Today = calendarToday
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
if DueDatePicker(c, v, o).Changed() {
|
||||
*n++
|
||||
}
|
||||
})
|
||||
}, 480, 520)
|
||||
}
|
||||
|
||||
func TestDueDatePickerShortcuts(t *testing.T) {
|
||||
v, n := core.Date{}, 0
|
||||
tt := dueTester(&v, &DueDatePickerOptions{}, &n)
|
||||
_ = tt.Click("Tomorrow")
|
||||
if v != oct(6) || n != 1 || !tt.HasText("Due tomorrow") {
|
||||
t.Fatalf("tomorrow %v", v)
|
||||
}
|
||||
_ = tt.Click("Next week")
|
||||
if v != oct(11) {
|
||||
t.Fatalf("next week (Sunday start) %v", v)
|
||||
}
|
||||
_ = tt.Click("Today")
|
||||
if v != oct(5) || !tt.HasText("Due today") {
|
||||
t.Fatalf("today %v", v)
|
||||
}
|
||||
_ = tt.Click("Open calendar")
|
||||
_ = tt.Click(datetimeTestLabel(oct(20)))
|
||||
if v != oct(20) || n != 4 {
|
||||
t.Fatalf("calendar pick %v (%d)", v, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueDatePickerOverdueAndDone(t *testing.T) {
|
||||
v, n := oct(2), 0
|
||||
opts := &DueDatePickerOptions{}
|
||||
tt := dueTester(&v, opts, &n)
|
||||
if !tt.HasText("Overdue by 3 days") {
|
||||
t.Fatalf("overdue missing: %v", tt.Texts())
|
||||
}
|
||||
opts.Done = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Overdue") {
|
||||
t.Fatal("a done task is not overdue")
|
||||
}
|
||||
opts.Disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Today")
|
||||
if v != oct(2) || n != 0 {
|
||||
t.Fatal("disabled picker changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueDatePickerSnapshot(t *testing.T) {
|
||||
v := oct(2)
|
||||
snapshot(t, "due_date_picker", 360, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { DueDatePicker(c, &v, DueDatePickerOptions{Today: calendarToday}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestDueDatePickerSelectedShortcutMark(t *testing.T) {
|
||||
v, n := oct(5), 0
|
||||
tt := dueTester(&v, &DueDatePickerOptions{}, &n)
|
||||
if !tt.HasText("✓ Today") {
|
||||
t.Fatal("selected shortcut needs a non-color mark")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if v != oct(6) || n != 1 {
|
||||
t.Fatalf("keyboard shortcut date %v, changes %d", v, n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package project_test
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/project"
|
||||
)
|
||||
|
||||
func ExampleKanbanBoard() {
|
||||
statuses := []project.WorkflowStatus{{ID: "todo", Name: "To do", Group: project.StatusUnstarted}, {ID: "done", Name: "Done", Group: project.StatusCompleted}}
|
||||
tasks := []project.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := project.KanbanBoard(c, &tasks, project.KanbanBoardOptions{Columns: []project.KanbanColumn{{Status: "todo"}, {Status: "done", WIP: 2}}, Statuses: statuses})
|
||||
if move, ok := r.Moved(); ok {
|
||||
log.Println("move", move.ID, move.To, move.Index)
|
||||
}
|
||||
if id, ok := r.Opened(); ok {
|
||||
log.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleTaskList() {
|
||||
statuses := []project.WorkflowStatus{{ID: "todo", Name: "To do", Group: project.StatusUnstarted}, {ID: "done", Name: "Done", Group: project.StatusCompleted}}
|
||||
tasks := []project.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
|
||||
filter := project.TaskShowAll
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := project.TaskList(c, tasks, &filter, project.TaskListOptions{GroupBy: project.TaskGroupStatus, Statuses: statuses})
|
||||
if id, ok := r.Changed(); ok {
|
||||
log.Println("edit", id)
|
||||
}
|
||||
if id, ok := r.Opened(); ok {
|
||||
log.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleTaskItem() {
|
||||
task := project.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: project.PriorityHigh}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := project.TaskItem(c, &task, project.TaskItemOptions{})
|
||||
if id, ok := r.Changed(); ok {
|
||||
log.Println("edit", id)
|
||||
}
|
||||
if id, ok := r.Opened(); ok {
|
||||
log.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleTaskDetailPanel() {
|
||||
statuses := []project.WorkflowStatus{{ID: "todo", Name: "To do", Group: project.StatusUnstarted}, {ID: "done", Name: "Done", Group: project.StatusCompleted}}
|
||||
task := project.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: project.PriorityHigh}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := project.TaskDetailPanel(c, &task, project.TaskDetailPanelOptions{Statuses: statuses, Activity: func() { ui.Text(c, "Release planned") }})
|
||||
if r.Changed() {
|
||||
log.Println("task edited", task.ID)
|
||||
}
|
||||
if r.Closed() {
|
||||
log.Println("close", task.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleSubtaskList() {
|
||||
subtasks := []project.Subtask{{ID: "one", Title: "Review", Done: true}, {ID: "two", Title: "Ship"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if project.SubtaskList(c, &subtasks, project.SubtaskListOptions{}).Changed() {
|
||||
log.Println("subtasks changed", len(subtasks))
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleSprintBoard() {
|
||||
statuses := []project.WorkflowStatus{{ID: "todo", Name: "To do", Group: project.StatusUnstarted}, {ID: "done", Name: "Done", Group: project.StatusCompleted}}
|
||||
tasks := []project.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
|
||||
sprint := project.Sprint{Name: "Release sprint", Goal: "Ship", Start: core.Date{Year: 2026, Month: 10, Day: 1}, End: core.Date{Year: 2026, Month: 10, Day: 14}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := project.SprintBoard(c, sprint, &tasks, project.SprintBoardOptions{Board: project.KanbanBoardOptions{Columns: []project.KanbanColumn{{Status: "todo"}, {Status: "done", WIP: 2}}, Statuses: statuses}})
|
||||
if move, ok := r.Moved(); ok {
|
||||
log.Println("move", move.ID, move.To)
|
||||
}
|
||||
if id, ok := r.Opened(); ok {
|
||||
log.Println("open", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleBurndownChart() {
|
||||
remaining := []float64{40, 32, 25, 18}
|
||||
opts := project.BurndownChartOptions{Frame: chart.ChartFrame{Label: "Release burndown"}, Start: core.Date{Year: 2026, Month: 10, Day: 1}, End: core.Date{Year: 2026, Month: 10, Day: 14}, Total: 40}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := project.BurndownChart(c, remaining, opts)
|
||||
if r.Changed() {
|
||||
log.Println("point", r.Point)
|
||||
}
|
||||
if name, ok := r.Toggled(); ok {
|
||||
log.Println("series", name)
|
||||
}
|
||||
if r.Retried() {
|
||||
log.Println("retry")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExamplePriorityIndicator() {
|
||||
priority := project.PriorityHigh
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
project.PriorityIndicator(c, priority, project.PriorityIndicatorOptions{})
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleStatusSelect() {
|
||||
statuses := []project.WorkflowStatus{{ID: "todo", Name: "To do", Group: project.StatusUnstarted}, {ID: "done", Name: "Done", Group: project.StatusCompleted}}
|
||||
value := "todo"
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if project.StatusSelect(c, &value, statuses, project.StatusSelectOptions{}).Changed() {
|
||||
log.Println("status", value)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleAssigneePicker() {
|
||||
members := []project.ProjectMember{{ID: "ann", Name: "Ann"}, {ID: "ben", Name: "Ben"}}
|
||||
ids := []string{"ann"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if project.AssigneePicker(c, &ids, members, project.AssigneePickerOptions{Max: 2}).Changed() {
|
||||
log.Println("assignees", ids)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleDueDatePicker() {
|
||||
var due core.Date
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if project.DueDatePicker(c, &due, project.DueDatePickerOptions{}).Changed() {
|
||||
log.Println("due", due)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleLabelManager() {
|
||||
labels := []project.TaskLabel{{ID: "ui", Name: "UI", Color: datetime.EventInfo}}
|
||||
selected := []string{}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if project.LabelManager(c, &labels, &selected, project.LabelManagerOptions{}).Changed() {
|
||||
log.Println("labels changed", len(labels), selected)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleIssueCard() {
|
||||
statuses := []project.WorkflowStatus{{ID: "todo", Name: "To do", Group: project.StatusUnstarted}, {ID: "done", Name: "Done", Group: project.StatusCompleted}}
|
||||
task := project.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: project.PriorityHigh}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if project.IssueCard(c, task, project.IssueCardOptions{Statuses: statuses}).Clicked() {
|
||||
log.Println("open", task.ID)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleIssueIdBadge() {
|
||||
id := "ENG-123"
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if project.IssueIdBadge(c, id, project.IssueIdBadgeOptions{Copyable: true}).Changed() {
|
||||
log.Println("copied", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleMilestoneProgress() {
|
||||
milestone := project.Milestone{Title: "Release", Due: core.Date{Year: 2026, Month: 10, Day: 14}, Done: 3, Total: 8}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
project.MilestoneProgress(c, milestone, project.MilestoneProgressOptions{})
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleWorkloadView() {
|
||||
loads := []project.Workload{{Member: project.ProjectMember{ID: "ann", Name: "Ann"}, Assigned: 46, Capacity: 40}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
project.WorkloadView(c, loads, project.WorkloadViewOptions{})
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExampleTimeTracker() {
|
||||
var state project.TimeTrackerState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if project.TimeTracker(c, &state, project.TimeTrackerOptions{}).Changed() {
|
||||
log.Println("tracked", state.Total(c.Now()))
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
|
||||
func ExamplePomodoroTimer() {
|
||||
var state project.PomodoroState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := project.PomodoroTimer(c, &state, project.PomodoroTimerOptions{Focus: 25 * time.Minute})
|
||||
if r.Changed() {
|
||||
log.Println("timer", state.Phase(), state.Running())
|
||||
}
|
||||
if phase, ok := r.Finished(); ok {
|
||||
log.Println("finished", phase)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 1000, 700, 1)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
func oct(day int) core.Date { return core.Date{Year: 2026, Month: time.October, Day: day} }
|
||||
|
||||
var calendarToday = core.Date{Year: 2026, Month: time.October, Day: 5}
|
||||
|
||||
// datetimeTestLabel is the en-US spoken name of a day cell.
|
||||
func datetimeTestLabel(d core.Date) string {
|
||||
return fmt.Sprintf("%v, %v %d, %d", d.Weekday(), d.Month, d.Day, d.Year)
|
||||
}
|
||||
|
||||
func datetimeNear(c color.RGBA, w ui.Color) bool {
|
||||
d := func(a, b uint8) int {
|
||||
if a > b {
|
||||
return int(a - b)
|
||||
}
|
||||
return int(b - a)
|
||||
}
|
||||
return d(c.R, w.R)+d(c.G, w.G)+d(c.B, w.B) < 60
|
||||
}
|
||||
|
||||
func calDrag(tt *ui.Tester, x0, y0, x1, y1 float32) {
|
||||
tt.Press(x0, y0)
|
||||
for i := 1; i <= 4; i++ {
|
||||
f := float32(i) / 4
|
||||
tt.Move(x0+(x1-x0)*f, y0+(y1-y0)*f)
|
||||
}
|
||||
tt.Release(x1, y1)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// IssueCardOptions configure an IssueCard.
|
||||
type IssueCardOptions struct {
|
||||
Statuses []WorkflowStatus
|
||||
Members []ProjectMember
|
||||
Labels []TaskLabel
|
||||
Selected bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// IssueCard displays task fields in a selectable card.
|
||||
func IssueCard(c *ui.Context, task Task, opts IssueCardOptions) *ui.Element {
|
||||
projectCheckPriority(task.Priority)
|
||||
st := projectStatus(opts.Statuses, task.Status)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
b := ui.ButtonBase(c).Column().AlignItems(ui.Stretch).Justify(ui.Start).Padding(d.Unit()*3).Gap(d.Unit()*2).
|
||||
Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).Background(k.Surface).
|
||||
Label(task.ID + " " + task.Title).Description(fmt.Sprintf(core.Msg(c, "issue_card.description"), st.Name, PriorityName(c, task.Priority))).Disabled(opts.Disabled)
|
||||
internal.Ring(b, k.Focus, theme.CardRadius)
|
||||
switch {
|
||||
case opts.Selected:
|
||||
b.Background(k.Selection)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder)
|
||||
}
|
||||
selected := opts.Selected
|
||||
b.Transition(core.ColorFade).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
if selected {
|
||||
core.DatetimeSelectedMark(p, r, k.AccentText)
|
||||
}
|
||||
})
|
||||
b.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
statusGlyphBox(c, st.Group)
|
||||
ui.Text(c, task.ID).Font(theme.MonoFont).FontSize(small).TextColor(k.TextMuted).Grow(1)
|
||||
PriorityIndicator(c, task.Priority, PriorityIndicatorOptions{IconOnly: true})
|
||||
})
|
||||
t := ui.Text(c, task.Title).TextColor(k.Text).MaxLines(2).Ellipsis("…")
|
||||
if st.Group == StatusCanceled {
|
||||
t.Strikethrough().TextColor(k.TextMuted)
|
||||
}
|
||||
if len(task.Labels) == 0 && len(task.Assignees) == 0 {
|
||||
return
|
||||
}
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Wrap().Children(func() {
|
||||
for _, id := range task.Labels {
|
||||
issueLabelChip(c, projectLabel(opts.Labels, id))
|
||||
}
|
||||
ui.Spacer(c)
|
||||
if len(task.Assignees) > 0 {
|
||||
items := make([]display.AvatarItem, len(task.Assignees))
|
||||
for i, id := range task.Assignees {
|
||||
items[i] = display.AvatarItem{Name: projectMember(opts.Members, id).Name}
|
||||
}
|
||||
display.AvatarGroup(c, items, display.AvatarGroupOptions{Max: 3, Size: 22})
|
||||
}
|
||||
})
|
||||
})
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
slog.Debug("mujica: issue_card open", "id", task.ID)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// issueLabelChip displays a label with its color dot.
|
||||
func issueLabelChip(c *ui.Context, l TaskLabel) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
col := datetime.EventTone(k, l.Color)
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Padding(1, d.Unit()*1.5).Radius(theme.ControlRadius).
|
||||
Border(theme.BorderWidth, k.Border).Children(func() {
|
||||
ui.Box(c).Size(8, 8).Radius(4).Background(col)
|
||||
ui.Text(c, l.Name).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func issueCardTester(task Task, opts IssueCardOptions, n *int) *ui.Tester {
|
||||
opts.Statuses, opts.Members, opts.Labels = projStatuses, projMembers, projLabels
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Width(300).Children(func() {
|
||||
if IssueCard(c, task, opts).Clicked() {
|
||||
*n++
|
||||
}
|
||||
})
|
||||
}, 360, 260)
|
||||
}
|
||||
|
||||
func TestIssueCardShowsFieldsAndActivates(t *testing.T) {
|
||||
n := 0
|
||||
tt := issueCardTester(projTasks()[1], IssueCardOptions{}, &n)
|
||||
for _, s := range []string{"AVE-2", "Tune the organ", "Bug", "Urgent", "Mutsumi Wakaba", "Uika Misumi"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %v", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("AVE-2 Tune the organ")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if n != 2 {
|
||||
t.Fatalf("activations %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCardDisabledAndInvalid(t *testing.T) {
|
||||
n := 0
|
||||
tt := issueCardTester(projTasks()[0], IssueCardOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("AVE-1 Write the overture")
|
||||
if n != 0 {
|
||||
t.Fatal("disabled card clicked")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for an unknown status")
|
||||
}
|
||||
}()
|
||||
bad := projTasks()[0]
|
||||
bad.Status = "lost"
|
||||
issueCardTester(bad, IssueCardOptions{}, &n)
|
||||
}
|
||||
|
||||
func TestIssueCardSnapshot(t *testing.T) {
|
||||
tasks := projTasks()
|
||||
snapshot(t, "issue_card", 640, 200, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
for i, task := range tasks[:3] {
|
||||
ui.Box(c).Width(190).Children(func() {
|
||||
IssueCard(c, task, IssueCardOptions{Statuses: projStatuses, Members: projMembers, Labels: projLabels, Selected: i == 1})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueCardFocusRing(t *testing.T) {
|
||||
n := 0
|
||||
tt := issueCardTester(projTasks()[0], IssueCardOptions{}, &n)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
r, ok := tt.Find("AVE-1 Write the overture")
|
||||
if !ok {
|
||||
t.Fatal("card missing")
|
||||
}
|
||||
img, found := tt.Image(), false
|
||||
for x := int(r.X) - 5; x < int(r.X)-1; x++ {
|
||||
for y := int(r.Y) + 8; y < int(r.Y+r.H)-8; y++ {
|
||||
if datetimeNear(img.RGBAAt(x, y), theme.Light().Focus) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("card keyboard focus has no outer ring")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
var issueIDPattern = regexp.MustCompile(`^[A-Z][A-Z0-9]*-[1-9][0-9]*$`)
|
||||
|
||||
// IssueIdBadgeOptions configure an IssueIdBadge.
|
||||
type IssueIdBadgeOptions struct {
|
||||
// Copyable makes a click or Enter copy the key.
|
||||
Copyable bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// issueCopiedFor is how long "Copied" shows after a copy.
|
||||
const issueCopiedFor = 1500 * time.Millisecond
|
||||
|
||||
// IssueIdBadge displays a monospace issue key with optional copying.
|
||||
func IssueIdBadge(c *ui.Context, id string, opts IssueIdBadgeOptions) core.ChoiceResult {
|
||||
if !issueIDPattern.MatchString(id) {
|
||||
panic(fmt.Sprintf("mujica: IssueIdBadge %q is not a key like ENG-123", id))
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
if !opts.Copyable {
|
||||
e := ui.Row(c).Padding(d.Unit()/2, d.Unit()*1.5).Radius(theme.ControlRadius).Background(k.Background).
|
||||
Border(theme.BorderWidth, k.Border).Shrink(0).Label(id)
|
||||
e.Children(func() { ui.Text(c, id).Font(theme.MonoFont).FontSize(small).TextColor(k.TextMuted).SingleLine() })
|
||||
return core.ChoiceResult{Element: e}
|
||||
}
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Gap(d.Unit()).PaddingX(d.Unit()*1.5).Radius(theme.ControlRadius).
|
||||
Background(k.Background).Border(theme.BorderWidth, k.Border).Disabled(opts.Disabled)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
copied := ui.Local(b, "issue-copied", func() time.Time { return time.Time{} })
|
||||
changed := false
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
c.WriteClipboard(id)
|
||||
c.Announce(fmt.Sprintf(core.Msg(c, "issue_id_badge.copied_label"), id))
|
||||
*copied, changed = c.Now(), true
|
||||
slog.Debug("mujica: issue_id_badge copy", "id", id)
|
||||
}
|
||||
showing := !copied.IsZero() && c.Now().Sub(*copied) < issueCopiedFor
|
||||
if showing {
|
||||
c.After(issueCopiedFor - c.Now().Sub(*copied))
|
||||
}
|
||||
b.Label(fmt.Sprintf(core.Msg(c, "issue_id_badge.copy"), id))
|
||||
if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Transition(core.ColorFade).Children(func() {
|
||||
ui.Text(c, id).Font(theme.MonoFont).FontSize(small).TextColor(k.Text).SingleLine()
|
||||
icon, col := "copy", k.TextMuted
|
||||
if showing {
|
||||
icon, col = "check", k.Success
|
||||
}
|
||||
ui.Icon(c, icons.Must(icon)).Size(12, 12).TextColor(col)
|
||||
if showing {
|
||||
ui.Text(c, core.Msg(c, "issue_id_badge.copied")).FontSize(small).TextColor(k.Success)
|
||||
}
|
||||
})
|
||||
return core.NewChoiceResult(b, changed)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestIssueIdBadgeCopy(t *testing.T) {
|
||||
n, disabled := 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
if IssueIdBadge(c, "ENG-123", IssueIdBadgeOptions{Copyable: true, Disabled: disabled}).Changed() {
|
||||
n++
|
||||
}
|
||||
IssueIdBadge(c, "OPS-7", IssueIdBadgeOptions{})
|
||||
})
|
||||
}, 300, 120)
|
||||
_ = tt.Click("Copy ENG-123")
|
||||
if tt.Clipboard() != "ENG-123" || n != 1 || !tt.HasText("Copied") {
|
||||
t.Fatalf("copy %q %d %v", tt.Clipboard(), n, tt.Texts())
|
||||
}
|
||||
if a := tt.Announcements(); len(a) != 1 || a[0] != "ENG-123 copied" {
|
||||
t.Fatalf("announce %q", a)
|
||||
}
|
||||
tt.SetClipboard("")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if tt.Clipboard() != "ENG-123" || n != 2 {
|
||||
t.Fatal("Enter should copy")
|
||||
}
|
||||
if !tt.HasText("OPS-7") || tt.HasText("Copy OPS-7") {
|
||||
t.Fatal("plain badge should not be a button")
|
||||
}
|
||||
disabled = true
|
||||
tt.SetClipboard("")
|
||||
tt.Frame()
|
||||
_ = tt.Click("Copy ENG-123")
|
||||
if tt.Clipboard() != "" || n != 2 {
|
||||
t.Fatal("disabled badge copied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueIdBadgeCopiedFades(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IssueIdBadge(c, "ENG-1", IssueIdBadgeOptions{Copyable: true})
|
||||
}, 300, 120)
|
||||
_ = tt.Click("Copy ENG-1")
|
||||
if !tt.HasText("Copied") {
|
||||
t.Fatal("no Copied note")
|
||||
}
|
||||
time.Sleep(issueCopiedFor + 50*time.Millisecond)
|
||||
tt.Frame()
|
||||
if tt.HasText("Copied") {
|
||||
t.Fatal("Copied should go away")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueIdBadgeInvalid(t *testing.T) {
|
||||
for _, id := range []string{"eng-1", "ENG-0", "ENG123", ""} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%q: expected panic", id)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); IssueIdBadge(c, id, IssueIdBadgeOptions{}) }, 10, 10)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueIdBadgeSnapshot(t *testing.T) {
|
||||
snapshot(t, "issue_id_badge", 300, 60, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
IssueIdBadge(c, "ENG-123", IssueIdBadgeOptions{Copyable: true})
|
||||
IssueIdBadge(c, "OPS-7", IssueIdBadgeOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// KanbanColumn is one column, holding the tasks in Status.
|
||||
type KanbanColumn struct {
|
||||
Status string
|
||||
// WIP limits card count; zero allows any count.
|
||||
WIP int
|
||||
}
|
||||
|
||||
// KanbanBoardOptions configure a KanbanBoard.
|
||||
type KanbanBoardOptions struct {
|
||||
Columns []KanbanColumn
|
||||
Statuses []WorkflowStatus
|
||||
Members []ProjectMember
|
||||
Labels []TaskLabel
|
||||
// Version must change when tasks change in place.
|
||||
Version int
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// KanbanMove identifies a moved card and its destination.
|
||||
type KanbanMove struct {
|
||||
ID, From, To string
|
||||
Index int
|
||||
}
|
||||
|
||||
// KanbanResult is the board and what the user did.
|
||||
type KanbanResult struct {
|
||||
Element *ui.Element
|
||||
move *KanbanMove
|
||||
opened string
|
||||
}
|
||||
|
||||
// Moved returns this frame's applied card move.
|
||||
func (r KanbanResult) Moved() (KanbanMove, bool) {
|
||||
if r.move == nil {
|
||||
return KanbanMove{}, false
|
||||
}
|
||||
return *r.move, true
|
||||
}
|
||||
|
||||
// Opened returns the card the user activated.
|
||||
func (r KanbanResult) Opened() (string, bool) { return r.opened, r.opened != "" }
|
||||
|
||||
// kanbanDrag is the ID of a card being dragged.
|
||||
type kanbanDrag string
|
||||
|
||||
// kanbanState caches column indexes and pending focus.
|
||||
type kanbanState struct {
|
||||
first *Task
|
||||
n, ver int
|
||||
rev, at int
|
||||
cols [][]int
|
||||
columns []KanbanColumn
|
||||
focus string
|
||||
problem string
|
||||
// problemAt is the column the problem belongs to.
|
||||
problemAt string
|
||||
lists []*ui.ListState
|
||||
}
|
||||
|
||||
// KanbanBoard supports status columns, WIP limits and card moves.
|
||||
func KanbanBoard(c *ui.Context, tasks *[]Task, opts KanbanBoardOptions) KanbanResult {
|
||||
if len(opts.Columns) == 0 {
|
||||
panic("mujica: KanbanBoard needs Columns")
|
||||
}
|
||||
for i, col := range opts.Columns {
|
||||
projectStatus(opts.Statuses, col.Status)
|
||||
if col.WIP < 0 || slices.ContainsFunc(opts.Columns[:i], func(o KanbanColumn) bool { return o.Status == col.Status }) {
|
||||
panic(fmt.Sprintf("mujica: KanbanBoard column %q repeats or has a negative WIP", col.Status))
|
||||
}
|
||||
}
|
||||
d := core.Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "kanban_board.label")
|
||||
}
|
||||
r := KanbanResult{}
|
||||
board := ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Stretch).Grow(1).MinHeight(0).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
r.Element = board
|
||||
st := *ui.Local(board, "kanban", func() *kanbanState { return &kanbanState{} })
|
||||
cols := kanbanColumns(st, *tasks, opts)
|
||||
apply := func(id, to, before, how string) {
|
||||
from := slices.IndexFunc(*tasks, func(t Task) bool { return t.ID == id })
|
||||
ci := slices.IndexFunc(opts.Columns, func(c KanbanColumn) bool { return c.Status == to })
|
||||
if from < 0 || ci < 0 {
|
||||
return
|
||||
}
|
||||
t := (*tasks)[from]
|
||||
if wip := opts.Columns[ci].WIP; wip > 0 && t.Status != to && len(cols[ci]) >= wip {
|
||||
st.problem, st.problemAt = fmt.Sprintf(core.Msg(c, "kanban_board.full"), projectStatus(opts.Statuses, to).Name, wip), to
|
||||
slog.Debug("mujica: kanban_board wip refused", "id", id, "to", to)
|
||||
return
|
||||
}
|
||||
var dest []string
|
||||
for _, i := range cols[ci] {
|
||||
if i != from {
|
||||
dest = append(dest, (*tasks)[i].ID)
|
||||
}
|
||||
}
|
||||
rest := slices.Delete(*tasks, from, from+1)
|
||||
pos := func(id string) int { return slices.IndexFunc(rest, func(x Task) bool { return x.ID == id }) }
|
||||
index, at := len(dest), len(rest)
|
||||
switch {
|
||||
case before != "":
|
||||
index, at = slices.Index(dest, before), pos(before)
|
||||
case len(dest) > 0:
|
||||
at = pos(dest[len(dest)-1]) + 1
|
||||
}
|
||||
from0 := t.Status
|
||||
t.Status = to
|
||||
*tasks = slices.Insert(rest, at, t)
|
||||
st.rev++
|
||||
st.focus, st.problem = id, ""
|
||||
st.lists[ci].ScrollIntoView(index)
|
||||
r.move = &KanbanMove{ID: id, From: from0, To: to, Index: index}
|
||||
slog.Debug("mujica: kanban_board move", "id", id, "from", from0, "to", to, "index", index, "by", how)
|
||||
}
|
||||
// Apply moves after building all rows.
|
||||
var pending func()
|
||||
move := func(id, to, before, how string) {
|
||||
if !board.IsDisabled() && id != before && pending == nil {
|
||||
pending = func() { apply(id, to, before, how) }
|
||||
}
|
||||
}
|
||||
board.Children(func() {
|
||||
for ci, col := range opts.Columns {
|
||||
kanbanColumn(c, st, ci, col, func(i int) []int { return cols[i] }, *tasks, opts, &r, move)
|
||||
}
|
||||
})
|
||||
if pending != nil {
|
||||
pending()
|
||||
c.Invalidate()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// kanbanColumns returns each column's task indexes, rebuilt when tasks change.
|
||||
func kanbanColumns(st *kanbanState, tasks []Task, opts KanbanBoardOptions) [][]int {
|
||||
var first *Task
|
||||
if len(tasks) > 0 {
|
||||
first = &tasks[0]
|
||||
}
|
||||
if st.cols != nil && st.first == first && st.n == len(tasks) && st.ver == opts.Version && st.at == st.rev && slices.Equal(st.columns, opts.Columns) {
|
||||
return st.cols
|
||||
}
|
||||
st.first, st.n, st.ver, st.at = first, len(tasks), opts.Version, st.rev
|
||||
st.columns = slices.Clone(opts.Columns)
|
||||
st.cols = make([][]int, len(opts.Columns))
|
||||
for i, t := range tasks {
|
||||
ci := slices.IndexFunc(opts.Columns, func(c KanbanColumn) bool { return c.Status == t.Status })
|
||||
if ci < 0 {
|
||||
projectStatus(opts.Statuses, t.Status)
|
||||
continue
|
||||
}
|
||||
st.cols[ci] = append(st.cols[ci], i)
|
||||
}
|
||||
for len(st.lists) < len(opts.Columns) {
|
||||
st.lists = append(st.lists, &ui.ListState{})
|
||||
}
|
||||
return st.cols
|
||||
}
|
||||
|
||||
// kanbanColumn draws a header, WIP count and virtual cards.
|
||||
func kanbanColumn(c *ui.Context, st *kanbanState, ci int, col KanbanColumn, colsOf func(int) []int, tasks []Task, opts KanbanBoardOptions,
|
||||
r *KanbanResult, move func(id, to, before, how string)) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
idx := colsOf(ci)
|
||||
status := projectStatus(opts.Statuses, col.Status)
|
||||
over := col.WIP > 0 && len(idx) > col.WIP
|
||||
full := col.WIP > 0 && len(idx) >= col.WIP
|
||||
box := ui.Column(c).Key(col.Status).Width(d.ControlHeight()*7.5).Shrink(0).Gap(d.Unit()*2).Padding(d.Unit()*2).
|
||||
Radius(theme.CardRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).Label(status.Name)
|
||||
if id, ok := ui.Drop[kanbanDrag](box); ok && !box.IsDisabled() {
|
||||
move(string(id), col.Status, "", "drag")
|
||||
}
|
||||
if _, ok := ui.DragOver[kanbanDrag](box); ok {
|
||||
box.Border(theme.BorderWidth, k.AccentText)
|
||||
}
|
||||
box.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
|
||||
statusGlyphBox(c, status.Group)
|
||||
ui.Text(c, status.Name).Font(theme.SerifFont).TextColor(k.Text).Grow(1).SingleLine()
|
||||
count := fmt.Sprint(len(idx))
|
||||
if col.WIP > 0 {
|
||||
count = fmt.Sprintf("%d / %d", len(idx), col.WIP)
|
||||
}
|
||||
fg := k.TextMuted
|
||||
if over {
|
||||
fg = k.Danger
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14).TextColor(fg)
|
||||
}
|
||||
wip := ui.Text(c, count).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(fg).FontFeatures("tnum")
|
||||
if col.WIP > 0 {
|
||||
wip.Label(fmt.Sprintf(core.Msg(c, "kanban_board.wip"), len(idx), col.WIP))
|
||||
}
|
||||
})
|
||||
if over {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "kanban_board.over"), len(idx)-col.WIP)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Danger).Role(ui.RoleStatus)
|
||||
} else if full && st.problem != "" && st.problemAt == col.Status {
|
||||
ui.Text(c, st.problem).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Danger).Role(ui.RoleStatus)
|
||||
}
|
||||
ls := st.lists[ci]
|
||||
ls.Key = func(i int) any { return tasks[idx[i]].ID }
|
||||
at := func(cidx []int, i int) string {
|
||||
if i < 0 || i >= len(cidx) {
|
||||
return ""
|
||||
}
|
||||
return tasks[cidx[i]].ID
|
||||
}
|
||||
ui.List(c, ls, len(idx), func(i int) {
|
||||
t := tasks[idx[i]]
|
||||
cell := ui.Box(c).PaddingY(d.Unit())
|
||||
if id, ok := ui.Drop[kanbanDrag](cell); ok && !cell.IsDisabled() {
|
||||
move(string(id), col.Status, t.ID, "drag")
|
||||
}
|
||||
if _, ok := ui.DragOver[kanbanDrag](cell); ok {
|
||||
cell.DrawOver(func(p *ui.Painter, rr ui.Rect) { p.Fill(ui.Rect{X: rr.X, Y: rr.Y, W: rr.W, H: 2}, k.AccentText, 1) })
|
||||
}
|
||||
cell.Children(func() {
|
||||
card := IssueCard(c, t, IssueCardOptions{Statuses: opts.Statuses, Members: opts.Members, Labels: opts.Labels})
|
||||
if card.IsDisabled() {
|
||||
return
|
||||
}
|
||||
card.Drag(kanbanDrag(t.ID))
|
||||
if st.focus == t.ID {
|
||||
card.Focus()
|
||||
st.focus = ""
|
||||
}
|
||||
if card.Clicked() {
|
||||
r.opened = t.ID
|
||||
}
|
||||
switch {
|
||||
case card.Shortcut(ui.Alt, ui.KeyUp) && i > 0:
|
||||
move(t.ID, col.Status, at(idx, i-1), "keys")
|
||||
case card.Shortcut(ui.Alt, ui.KeyDown) && i < len(idx)-1:
|
||||
move(t.ID, col.Status, at(idx, i+2), "keys")
|
||||
case card.Shortcut(ui.Alt, ui.KeyLeft) && ci > 0:
|
||||
move(t.ID, opts.Columns[ci-1].Status, at(colsOf(ci-1), i), "keys")
|
||||
case card.Shortcut(ui.Alt, ui.KeyRight) && ci < len(opts.Columns)-1:
|
||||
move(t.ID, opts.Columns[ci+1].Status, at(colsOf(ci+1), i), "keys")
|
||||
}
|
||||
})
|
||||
}).Grow(1).MinHeight(d.ControlHeight() * 3).Children(func() {
|
||||
if len(idx) == 0 {
|
||||
ui.Text(c, core.Msg(c, "kanban_board.empty")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit() * 2)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var kanbanColumns3 = []KanbanColumn{{Status: "todo"}, {Status: "doing", WIP: 3}, {Status: "review"}, {Status: "done"}}
|
||||
|
||||
type kanbanProbe struct {
|
||||
tasks []Task
|
||||
cols []KanbanColumn
|
||||
disabled bool
|
||||
moves []KanbanMove
|
||||
opened []string
|
||||
}
|
||||
|
||||
func (p *kanbanProbe) tester() *ui.Tester {
|
||||
if p.cols == nil {
|
||||
p.cols = kanbanColumns3
|
||||
}
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Height(560).Padding(8).Children(func() {
|
||||
r := KanbanBoard(c, &p.tasks, KanbanBoardOptions{Columns: p.cols, Statuses: projStatuses, Members: projMembers,
|
||||
Labels: projLabels, Disabled: p.disabled})
|
||||
if m, ok := r.Moved(); ok {
|
||||
p.moves = append(p.moves, m)
|
||||
}
|
||||
if id, ok := r.Opened(); ok {
|
||||
p.opened = append(p.opened, id)
|
||||
}
|
||||
})
|
||||
}, 1240, 600)
|
||||
}
|
||||
|
||||
// kanbanOrder lists IDs per status in slice order.
|
||||
func kanbanOrder(tasks []Task, status string) string {
|
||||
var out []string
|
||||
for _, t := range tasks {
|
||||
if t.Status == status {
|
||||
out = append(out, t.ID)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func TestKanbanBoardDragAcrossAndWithin(t *testing.T) {
|
||||
p := &kanbanProbe{tasks: projTasks()}
|
||||
tt := p.tester()
|
||||
src, _ := tt.Find("AVE-1 Write the overture")
|
||||
dst, _ := tt.Find("AVE-4 Rehearse act two")
|
||||
calDrag(tt, src.X+20, src.Y+20, dst.X+20, dst.Y+10)
|
||||
if kanbanOrder(p.tasks, "review") != "AVE-1,AVE-4" || kanbanOrder(p.tasks, "todo") != "" || len(p.moves) != 1 ||
|
||||
p.moves[0] != (KanbanMove{ID: "AVE-1", From: "todo", To: "review", Index: 0}) {
|
||||
t.Fatalf("drag onto a card: %v %+v", kanbanOrder(p.tasks, "review"), p.moves)
|
||||
}
|
||||
src, _ = tt.Find("AVE-2 Tune the organ")
|
||||
col, _ := tt.Find("In Progress")
|
||||
calDrag(tt, src.X+20, src.Y+20, col.X+20, col.Y+400)
|
||||
if kanbanOrder(p.tasks, "doing") != "AVE-3,AVE-2" {
|
||||
t.Fatalf("drag to the column end: %v", kanbanOrder(p.tasks, "doing"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKanbanBoardKeyboardMoves(t *testing.T) {
|
||||
p := &kanbanProbe{tasks: projTasks()}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("AVE-2 Tune the organ")
|
||||
tt.Key(ui.Alt, ui.KeyDown)
|
||||
if kanbanOrder(p.tasks, "doing") != "AVE-3,AVE-2" || !tt.Focused("AVE-2 Tune the organ") {
|
||||
t.Fatalf("Alt+Down %v", kanbanOrder(p.tasks, "doing"))
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if kanbanOrder(p.tasks, "review") != "AVE-4,AVE-2" || !tt.Focused("AVE-2 Tune the organ") {
|
||||
t.Fatalf("Alt+Right %v", kanbanOrder(p.tasks, "review"))
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if kanbanOrder(p.tasks, "review") != "AVE-2,AVE-4" {
|
||||
t.Fatalf("Alt+Up %v", kanbanOrder(p.tasks, "review"))
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(p.opened) != 2 || p.opened[1] != "AVE-2" {
|
||||
t.Fatalf("opened %v", p.opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKanbanBoardWIPLimit(t *testing.T) {
|
||||
p := &kanbanProbe{tasks: projTasks(), cols: []KanbanColumn{{Status: "todo"}, {Status: "doing", WIP: 2}, {Status: "review"}, {Status: "done"}}}
|
||||
tt := p.tester()
|
||||
if !tt.HasText("2 of 2 cards") {
|
||||
t.Fatalf("WIP count %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("AVE-1 Write the overture")
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if kanbanOrder(p.tasks, "todo") != "AVE-1" || !tt.HasText("In Progress is at its limit of 2 cards.") {
|
||||
t.Fatalf("WIP not enforced: %v", tt.Texts())
|
||||
}
|
||||
p.cols[1].WIP = 1
|
||||
tt.Frame()
|
||||
if !tt.HasText("1 over the limit") {
|
||||
t.Fatal("over-limit warning missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKanbanBoardDisabledAndInvalid(t *testing.T) {
|
||||
p := &kanbanProbe{tasks: projTasks(), disabled: true}
|
||||
tt := p.tester()
|
||||
src, _ := tt.Find("AVE-1 Write the overture")
|
||||
dst, _ := tt.Find("AVE-4 Rehearse act two")
|
||||
calDrag(tt, src.X+20, src.Y+20, dst.X+20, dst.Y+10)
|
||||
_ = tt.Click("AVE-1 Write the overture")
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if len(p.moves) != 0 || len(p.opened) != 0 || kanbanOrder(p.tasks, "todo") != "AVE-1" {
|
||||
t.Fatal("disabled board changed")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for a repeated column")
|
||||
}
|
||||
}()
|
||||
q := &kanbanProbe{tasks: projTasks(), cols: []KanbanColumn{{Status: "todo"}, {Status: "todo"}}}
|
||||
q.tester()
|
||||
}
|
||||
|
||||
func TestKanbanBoardLargeData(t *testing.T) {
|
||||
p := &kanbanProbe{}
|
||||
for i := range 100000 {
|
||||
p.tasks = append(p.tasks, Task{ID: fmt.Sprintf("BIG-%d", i+1), Title: "Card", Status: kanbanColumns3[i%4].Status})
|
||||
}
|
||||
p.cols = []KanbanColumn{{Status: "todo"}, {Status: "doing"}, {Status: "review"}, {Status: "done"}}
|
||||
tt := p.tester()
|
||||
if n := len(tt.Texts()); n > 600 {
|
||||
t.Fatalf("built %d labels", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKanbanBoardSnapshot(t *testing.T) {
|
||||
tasks := projTasks()
|
||||
snapshot(t, "kanban_board", 1240, 460, func(c *ui.Context) {
|
||||
ui.Column(c).Height(460).Padding(16).Children(func() {
|
||||
KanbanBoard(c, &tasks, KanbanBoardOptions{Columns: kanbanColumns3, Statuses: projStatuses, Members: projMembers, Labels: projLabels})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestKanbanBoardColumnChanges(t *testing.T) {
|
||||
p := &kanbanProbe{tasks: projTasks(), cols: []KanbanColumn{{Status: "todo"}, {Status: "doing"}}}
|
||||
tt := p.tester()
|
||||
p.cols = []KanbanColumn{{Status: "done"}, {Status: "review"}, {Status: "doing"}}
|
||||
tt.Frame()
|
||||
if !tt.HasText("AVE-5 Book the hall") || !tt.HasText("AVE-4 Rehearse act two") {
|
||||
t.Fatal("columns retain old indexes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKanbanBoardForeignDrop(t *testing.T) {
|
||||
left := []Task{{ID: "A-1", Title: "First", Status: "todo"}}
|
||||
right := []Task{{ID: "B-1", Title: "Second", Status: "todo"}}
|
||||
opts := KanbanBoardOptions{Columns: []KanbanColumn{{Status: "todo"}}, Statuses: projStatuses}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Height(400).Children(func() {
|
||||
ui.Box(c).Key("left").Width(300).Height(400).Children(func() { KanbanBoard(c, &left, opts) })
|
||||
ui.Box(c).Key("right").Width(300).Height(400).Children(func() { KanbanBoard(c, &right, opts) })
|
||||
})
|
||||
}, 640, 420)
|
||||
src, _ := tt.Find("A-1 First")
|
||||
dst, _ := tt.Find("B-1 Second")
|
||||
calDrag(tt, src.X+20, src.Y+20, dst.X+20, dst.Y+10)
|
||||
if len(left) != 1 || left[0].ID != "A-1" || len(right) != 1 || right[0].ID != "B-1" {
|
||||
t.Fatal("foreign drop changed another board")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// LabelManagerOptions configure a LabelManager.
|
||||
type LabelManagerOptions struct {
|
||||
Disabled bool
|
||||
Label string
|
||||
// Version changes when labels or selection change in place.
|
||||
Version int
|
||||
}
|
||||
|
||||
// labelManagerState is the label being edited and the new-label draft.
|
||||
type labelManagerState struct {
|
||||
editing string
|
||||
name string
|
||||
draft string
|
||||
problem string
|
||||
}
|
||||
|
||||
// LabelManager edits labels and stores chosen IDs in selected.
|
||||
func LabelManager(c *ui.Context, labels *[]TaskLabel, selected *[]string, opts LabelManagerOptions) core.ChoiceResult {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "label_manager.label")
|
||||
}
|
||||
changed := false
|
||||
col := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
st := ui.Local(col, "label-manager", func() *labelManagerState { return &labelManagerState{} })
|
||||
s := *st
|
||||
cache := ui.Local(col, "label-data", func() labelDataCache { return labelDataCache{} })
|
||||
var first *TaskLabel
|
||||
var selectedFirst *string
|
||||
if len(*labels) > 0 {
|
||||
first = &(*labels)[0]
|
||||
}
|
||||
if len(*selected) > 0 {
|
||||
selectedFirst = &(*selected)[0]
|
||||
}
|
||||
if !cache.ready || cache.first != first || cache.n != len(*labels) || cache.selectedFirst != selectedFirst || cache.selectedN != len(*selected) || cache.version != opts.Version {
|
||||
*cache = labelDataCache{first: first, n: len(*labels), selectedFirst: selectedFirst, selectedN: len(*selected), version: opts.Version, ready: true, selected: map[string]bool{}}
|
||||
known := map[string]bool{}
|
||||
for _, l := range *labels {
|
||||
datetime.EventTone(k, l.Color)
|
||||
if l.ID == "" || known[l.ID] {
|
||||
panic("mujica: LabelManager needs unique non-empty IDs")
|
||||
}
|
||||
known[l.ID] = true
|
||||
}
|
||||
for _, id := range *selected {
|
||||
if !known[id] {
|
||||
panic("mujica: LabelManager selection contains an unknown ID")
|
||||
}
|
||||
cache.selected[id] = true
|
||||
}
|
||||
}
|
||||
state := ui.Local(col, "label-rows", func() ui.ListState { return ui.ListState{} })
|
||||
state.Key = func(i int) any { return (*labels)[i].ID }
|
||||
|
||||
live := func() bool { return !col.IsDisabled() }
|
||||
taken := func(name, except string) bool {
|
||||
return slices.ContainsFunc(*labels, func(l TaskLabel) bool { return l.ID != except && strings.EqualFold(l.Name, name) })
|
||||
}
|
||||
col.Children(func() {
|
||||
removed := -1
|
||||
ui.List(c, state, len(*labels), func(i int) {
|
||||
l := &(*labels)[i]
|
||||
ui.Row(c).Key(l.ID).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
on := cache.selected[l.ID]
|
||||
box := ui.CheckboxBase(c, &on).Size(theme.MinHitSize, theme.MinHitSize).Center().Label(l.Name)
|
||||
internal.Ring(box, k.Focus, theme.ControlRadius)
|
||||
if box.Draw(func(p *ui.Painter, r ui.Rect) { labelSwatch(p, r, datetime.EventTone(k, l.Color), on, k) }).Changed() && live() {
|
||||
if on {
|
||||
*selected = append(*selected, l.ID)
|
||||
} else {
|
||||
*selected = slices.DeleteFunc(*selected, func(id string) bool { return id == l.ID })
|
||||
}
|
||||
changed = true
|
||||
slog.Debug("mujica: label_manager select", "id", l.ID, "selected", on)
|
||||
}
|
||||
if s.editing == l.ID && live() {
|
||||
labelEditRow(c, s, l, taken, &changed)
|
||||
return
|
||||
}
|
||||
ui.Text(c, l.Name).Grow(1).TextColor(k.Text).SingleLine().Ellipsis("…")
|
||||
if b := core.IconAction(c, icons.Must("palette"), fmt.Sprintf(core.Msg(c, "label_manager.edit"), l.Name)); b.Clicked() && live() {
|
||||
s.editing, s.name, s.problem = l.ID, l.Name, ""
|
||||
}
|
||||
if b := core.IconAction(c, icons.Must("trash-2"), fmt.Sprintf(core.Msg(c, "label_manager.delete"), l.Name)); b.Clicked() && live() {
|
||||
removed = i
|
||||
}
|
||||
})
|
||||
}).Height(min(float32(len(*labels)), 8) * d.ControlHeight())
|
||||
if removed >= 0 {
|
||||
id := (*labels)[removed].ID
|
||||
slog.Debug("mujica: label_manager delete", "id", id)
|
||||
*labels = slices.Delete(*labels, removed, removed+1)
|
||||
*selected = slices.DeleteFunc(*selected, func(x string) bool { return x == id })
|
||||
changed = true
|
||||
}
|
||||
if len(*labels) == 0 {
|
||||
ui.Text(c, core.Msg(c, "label_manager.empty")).TextColor(k.TextMuted)
|
||||
}
|
||||
in := input.TextInput(c, &s.draft, input.TextInputOptions{Placeholder: core.Msg(c, "label_manager.new"), Label: core.Msg(c, "label_manager.new"),
|
||||
Disabled: opts.Disabled, Error: s.problem})
|
||||
if in.Submitted() && live() {
|
||||
name := strings.TrimSpace(s.draft)
|
||||
switch {
|
||||
case name == "":
|
||||
case taken(name, ""):
|
||||
s.problem = fmt.Sprintf(core.Msg(c, "label_manager.taken"), name)
|
||||
default:
|
||||
l := TaskLabel{ID: labelNewID(*labels), Name: name, Color: datetime.EventColor(len(*labels) % 6)}
|
||||
*labels = append(*labels, l)
|
||||
s.draft, s.problem, changed = "", "", true
|
||||
slog.Debug("mujica: label_manager create", "id", l.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
if changed {
|
||||
cache.ready = false
|
||||
c.Invalidate()
|
||||
}
|
||||
return core.NewChoiceResult(col, changed)
|
||||
}
|
||||
|
||||
// labelEditRow renames a label and picks its color.
|
||||
func labelEditRow(c *ui.Context, s *labelManagerState, l *TaskLabel, taken func(string, string) bool, changed *bool) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
ui.Column(c).Grow(1).Gap(d.Unit()).Children(func() {
|
||||
in := input.TextInput(c, &s.name, input.TextInputOptions{Label: core.Msg(c, "label_manager.name"), Error: s.problem})
|
||||
in.Input.AutoFocus()
|
||||
ui.Row(c).Gap(d.Unit()).Role(ui.RoleRadioGroup).Label(core.Msg(c, "label_manager.color")).Children(func() {
|
||||
for i := range 6 {
|
||||
col := datetime.EventColor(i)
|
||||
sel := l.Color
|
||||
b := ui.RadioBase(c, &sel, col).Size(theme.MinHitSize, theme.MinHitSize).Label(core.Msg(c, labelColorKeys[i]))
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
on := l.Color == col
|
||||
b.Draw(func(p *ui.Painter, r ui.Rect) { labelSwatch(p, r, datetime.EventTone(k, col), on, k) })
|
||||
if b.Changed() && !b.IsDisabled() {
|
||||
l.Color, *changed = col, true
|
||||
slog.Debug("mujica: label_manager color", "id", l.ID, "color", col)
|
||||
}
|
||||
}
|
||||
})
|
||||
done := in.Submitted() || in.Input.Shortcut(0, ui.KeyEscape)
|
||||
if in.Submitted() {
|
||||
name := strings.TrimSpace(s.name)
|
||||
switch {
|
||||
case name == "":
|
||||
done = false
|
||||
case taken(name, l.ID):
|
||||
s.problem, done = fmt.Sprintf(core.Msg(c, "label_manager.taken"), name), false
|
||||
case name != l.Name:
|
||||
l.Name, *changed = name, true
|
||||
slog.Debug("mujica: label_manager rename", "id", l.ID)
|
||||
}
|
||||
}
|
||||
if done {
|
||||
s.editing, s.problem = "", ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var labelColorKeys = [...]string{"label_manager.accent", "label_manager.info", "label_manager.success", "label_manager.warning",
|
||||
"label_manager.danger", "label_manager.neutral"}
|
||||
|
||||
// labelSwatch draws a color dot with a selection check.
|
||||
func labelSwatch(p *ui.Painter, r ui.Rect, col ui.Color, on bool, k theme.Tokens) {
|
||||
box := ui.Rect{X: r.X + r.W/2 - 8, Y: r.Y + r.H/2 - 8, W: 16, H: 16}
|
||||
p.Fill(box, col, 8)
|
||||
if on {
|
||||
internal.Mark(p, ui.Rect{X: box.X + 2, Y: box.Y + 2, W: 12, H: 12}, "check", k.Surface)
|
||||
}
|
||||
}
|
||||
|
||||
// labelNewID returns an ID no label uses.
|
||||
func labelNewID(labels []TaskLabel) string {
|
||||
for n := len(labels) + 1; ; n++ {
|
||||
id := fmt.Sprintf("label-%d", n)
|
||||
if !slices.ContainsFunc(labels, func(l TaskLabel) bool { return l.ID == id }) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type labelDataCache struct {
|
||||
first *TaskLabel
|
||||
selectedFirst *string
|
||||
n, selectedN, version int
|
||||
ready bool
|
||||
selected map[string]bool
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
)
|
||||
|
||||
func labelTester(labels *[]TaskLabel, sel *[]string, opts *LabelManagerOptions, n *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
if LabelManager(c, labels, sel, *opts).Changed() {
|
||||
*n++
|
||||
}
|
||||
})
|
||||
}, 480, 480)
|
||||
}
|
||||
|
||||
func TestLabelManagerSelectCreateDelete(t *testing.T) {
|
||||
labels, sel, n := slices.Clone(projLabels), []string{}, 0
|
||||
tt := labelTester(&labels, &sel, &LabelManagerOptions{}, &n)
|
||||
_ = tt.Click("UI")
|
||||
if !slices.Equal(sel, []string{"ui"}) {
|
||||
t.Fatalf("select %v", sel)
|
||||
}
|
||||
_ = tt.Click("New label")
|
||||
tt.Type("Docs")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(labels) != 3 || !tt.HasText(`A label named "Docs" exists.`) {
|
||||
t.Fatalf("duplicate accepted: %v", labels)
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Type("Performance")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(labels) != 4 || labels[3].Name != "Performance" || labels[3].ID != "label-4" {
|
||||
t.Fatalf("create %+v", labels)
|
||||
}
|
||||
_ = tt.Click("Delete UI")
|
||||
if len(labels) != 3 || len(sel) != 0 || n != 3 {
|
||||
t.Fatalf("delete left %+v sel %v (%d)", labels, sel, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelManagerRenameAndColor(t *testing.T) {
|
||||
labels, sel, n := slices.Clone(projLabels), []string{}, 0
|
||||
tt := labelTester(&labels, &sel, &LabelManagerOptions{}, &n)
|
||||
_ = tt.Click("Edit Bug")
|
||||
_ = tt.Click("Green")
|
||||
_ = tt.Click("Label name")
|
||||
tt.Command("selectAll")
|
||||
tt.Type("UI")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if labels[0].Color != datetime.EventSuccess || labels[0].Name != "Bug" || !tt.HasText(`A label named "UI" exists.`) {
|
||||
t.Fatalf("color/duplicate %+v", labels[0])
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Type("Defect")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if labels[0].Name != "Defect" || tt.HasText("Label name") {
|
||||
t.Fatalf("rename %+v", labels[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelManagerDisabledAndInvalid(t *testing.T) {
|
||||
labels, sel, n := slices.Clone(projLabels), []string{}, 0
|
||||
tt := labelTester(&labels, &sel, &LabelManagerOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("UI")
|
||||
_ = tt.Click("Delete Bug")
|
||||
_ = tt.Click("Edit Bug")
|
||||
if n != 0 || len(labels) != 3 || len(sel) != 0 || tt.HasText("Label name") {
|
||||
t.Fatal("disabled manager changed")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for an unknown selected label")
|
||||
}
|
||||
}()
|
||||
bad := []string{"ghost"}
|
||||
labelTester(&labels, &bad, &LabelManagerOptions{}, &n)
|
||||
}
|
||||
|
||||
func TestLabelManagerSnapshot(t *testing.T) {
|
||||
labels, sel := slices.Clone(projLabels), []string{"bug"}
|
||||
snapshot(t, "label_manager", 400, 220, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { LabelManager(c, &labels, &sel, LabelManagerOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabelManagerLargeData(t *testing.T) {
|
||||
labels := make([]TaskLabel, 100000)
|
||||
for i := range labels {
|
||||
labels[i] = TaskLabel{ID: fmt.Sprint(i), Name: "Tag"}
|
||||
}
|
||||
selected, n := []string{}, 0
|
||||
tt := labelTester(&labels, &selected, &LabelManagerOptions{}, &n)
|
||||
if built := len(tt.Texts()); built > 200 {
|
||||
t.Fatalf("built %d labels", built)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Milestone holds a goal, due date and completion counts.
|
||||
type Milestone struct {
|
||||
Title string
|
||||
Due core.Date
|
||||
Done, Total int
|
||||
}
|
||||
|
||||
// MilestoneProgressOptions configure a MilestoneProgress.
|
||||
type MilestoneProgressOptions struct {
|
||||
// Today anchors the date note; zero uses the system clock.
|
||||
Today core.Date
|
||||
}
|
||||
|
||||
// MilestoneProgress shows completion counts, progress and due dates.
|
||||
func MilestoneProgress(c *ui.Context, m Milestone, opts MilestoneProgressOptions) *ui.Element {
|
||||
core.DatetimeCheckDate("MilestoneProgress Due", m.Due)
|
||||
if m.Total < 0 || m.Done < 0 || m.Done > m.Total {
|
||||
panic(fmt.Sprintf("mujica: MilestoneProgress %d of %d is not valid", m.Done, m.Total))
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
today := core.DatetimeToday(c, opts.Today)
|
||||
frac := 0.0
|
||||
if m.Total > 0 {
|
||||
frac = float64(m.Done) / float64(m.Total)
|
||||
}
|
||||
reached := m.Total > 0 && m.Done == m.Total
|
||||
pct := fmt.Sprintf("%d%%", int(frac*100))
|
||||
col := ui.Column(c).Gap(d.Unit()*2).Padding(d.Unit()*3).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).
|
||||
Background(k.Surface).Role(ui.RoleGroup).Label(m.Title).Description(pct)
|
||||
col.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Box(c).Size(12, 12).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), k.Ornament)
|
||||
})
|
||||
ui.Text(c, m.Title).Font(theme.SerifFont).TextColor(k.Text).Grow(1).SingleLine().Ellipsis("…")
|
||||
ui.Text(c, pct).TextColor(k.AccentText).FontFeatures("tnum")
|
||||
})
|
||||
projectBar(c, frac, m.Title)
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "milestone_progress.count"), m.Done, m.Total)).FontSize(core.FontSize(c, theme.CaptionSize)).
|
||||
TextColor(k.TextMuted).Grow(1).FontFeatures("tnum")
|
||||
text, late := projectDueText(c, m.Due, today, reached)
|
||||
fg, icon := k.TextMuted, "calendar"
|
||||
switch {
|
||||
case reached:
|
||||
text, fg, icon = core.Msg(c, "milestone_progress.reached"), k.Success, "circle-check"
|
||||
case late:
|
||||
fg, icon = k.Danger, "triangle-alert"
|
||||
}
|
||||
if text != "" {
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(fg).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
return col
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func milestoneTester(m Milestone) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Box(c).Width(360).Children(func() { MilestoneProgress(c, m, MilestoneProgressOptions{Today: calendarToday}) })
|
||||
}, 400, 200)
|
||||
}
|
||||
|
||||
func TestMilestoneProgressStates(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
m Milestone
|
||||
want []string
|
||||
}{
|
||||
{Milestone{Title: "Act one", Due: oct(12), Done: 3, Total: 8}, []string{"37%", "3 of 8 tasks done", "Due October 12"}},
|
||||
{Milestone{Title: "Act two", Due: oct(1), Done: 5, Total: 8}, []string{"62%", "Overdue by 4 days"}},
|
||||
{Milestone{Title: "Premiere", Due: oct(1), Done: 4, Total: 4}, []string{"100%", "Reached"}},
|
||||
{Milestone{Title: "Encore", Total: 0}, []string{"0%", "0 of 0 tasks done"}},
|
||||
} {
|
||||
tt := milestoneTester(c.m)
|
||||
for _, s := range c.want {
|
||||
if !tt.HasText(s) {
|
||||
t.Errorf("%s: missing %q in %v", c.m.Title, s, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
if tt := milestoneTester(Milestone{Title: "Premiere", Due: oct(1), Done: 4, Total: 4}); tt.HasText("Overdue") {
|
||||
t.Error("a reached milestone is not overdue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneProgressInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
milestoneTester(Milestone{Title: "x", Done: 5, Total: 3})
|
||||
}
|
||||
|
||||
func TestMilestoneProgressSnapshot(t *testing.T) {
|
||||
snapshot(t, "milestone_progress", 760, 140, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(16).Children(func() {
|
||||
for _, m := range []Milestone{{Title: "Act one", Due: oct(12), Done: 3, Total: 8}, {Title: "Act two", Due: oct(1), Done: 5, Total: 8}} {
|
||||
ui.Box(c).Width(350).Children(func() { MilestoneProgress(c, m, MilestoneProgressOptions{Today: calendarToday}) })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// PomodoroPhase is the part of the cycle running.
|
||||
type PomodoroPhase int
|
||||
|
||||
const (
|
||||
PomodoroFocus PomodoroPhase = iota
|
||||
PomodoroBreak
|
||||
PomodoroLongBreak
|
||||
)
|
||||
|
||||
// PomodoroState holds the cycle, sessions and running clock.
|
||||
type PomodoroState struct {
|
||||
phase PomodoroPhase
|
||||
focused int
|
||||
running bool
|
||||
since time.Time
|
||||
spent time.Duration
|
||||
}
|
||||
|
||||
// Phase returns the current phase.
|
||||
func (s *PomodoroState) Phase() PomodoroPhase { return s.phase }
|
||||
|
||||
// Sessions returns how many focus sessions finished.
|
||||
func (s *PomodoroState) Sessions() int { return s.focused }
|
||||
|
||||
// Running reports whether the clock runs.
|
||||
func (s *PomodoroState) Running() bool { return s.running }
|
||||
|
||||
func (s *PomodoroState) elapsed(now time.Time) time.Duration {
|
||||
if s.running {
|
||||
return s.spent + now.Sub(s.since)
|
||||
}
|
||||
return s.spent
|
||||
}
|
||||
|
||||
// PomodoroTimerOptions configure a PomodoroTimer.
|
||||
type PomodoroTimerOptions struct {
|
||||
// Focus/Break/LongBreak default to 25/5/15 minutes.
|
||||
Focus, Break, LongBreak time.Duration
|
||||
// LongEvery counts sessions per long break; zero means four.
|
||||
LongEvery int
|
||||
// Now fixes the clock; zero uses the system clock.
|
||||
Now time.Time
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// PomodoroResult is the timer and what changed this frame.
|
||||
type PomodoroResult struct {
|
||||
Element *ui.Element
|
||||
changed bool
|
||||
finished PomodoroPhase
|
||||
done bool
|
||||
}
|
||||
|
||||
// Changed reports a start, pause, skip or reset.
|
||||
func (r PomodoroResult) Changed() bool { return r.changed }
|
||||
|
||||
// Finished returns the phase that ran out this frame.
|
||||
func (r PomodoroResult) Finished() (PomodoroPhase, bool) { return r.finished, r.done }
|
||||
|
||||
// PomodoroTimer cycles focus and breaks with pause, skip and reset.
|
||||
func PomodoroTimer(c *ui.Context, s *PomodoroState, opts PomodoroTimerOptions) PomodoroResult {
|
||||
lengths := [3]time.Duration{opts.Focus, opts.Break, opts.LongBreak}
|
||||
for i, def := range []time.Duration{25 * time.Minute, 5 * time.Minute, 15 * time.Minute} {
|
||||
if lengths[i] < 0 || lengths[i]%time.Second != 0 {
|
||||
panic(fmt.Sprintf("mujica: PomodoroTimer length %v is negative or finer than a second", lengths[i]))
|
||||
}
|
||||
if lengths[i] == 0 {
|
||||
lengths[i] = def
|
||||
}
|
||||
}
|
||||
every := opts.LongEvery
|
||||
if every < 0 {
|
||||
panic("mujica: PomodoroTimer LongEvery is negative")
|
||||
}
|
||||
if every == 0 {
|
||||
every = 4
|
||||
}
|
||||
now := opts.Now
|
||||
live := now.IsZero()
|
||||
if live {
|
||||
now = c.Now()
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "pomodoro_timer.label")
|
||||
}
|
||||
r := PomodoroResult{}
|
||||
next := func() {
|
||||
if s.phase == PomodoroFocus {
|
||||
s.focused++
|
||||
s.phase = PomodoroBreak
|
||||
if s.focused%every == 0 {
|
||||
s.phase = PomodoroLongBreak
|
||||
}
|
||||
} else {
|
||||
s.phase = PomodoroFocus
|
||||
}
|
||||
s.running, s.spent = false, 0
|
||||
}
|
||||
if s.running && s.elapsed(now) >= lengths[s.phase] {
|
||||
r.finished, r.done = s.phase, true
|
||||
c.Announce(fmt.Sprintf(core.Msg(c, "pomodoro_timer.done"), core.Msg(c, pomodoroKeys[s.phase])))
|
||||
slog.Debug("mujica: pomodoro_timer finished", "phase", s.phase)
|
||||
next()
|
||||
}
|
||||
col := ui.Column(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
r.Element = col
|
||||
act := func(what string, fn func()) {
|
||||
if col.IsDisabled() {
|
||||
return
|
||||
}
|
||||
fn()
|
||||
r.changed = true
|
||||
slog.Debug("mujica: pomodoro_timer "+what, "phase", s.phase)
|
||||
}
|
||||
left := lengths[s.phase] - s.elapsed(now)
|
||||
secs := int(math.Ceil(left.Seconds()))
|
||||
frac := 1 - float32(secs)/float32(lengths[s.phase]/time.Second)
|
||||
col.Children(func() {
|
||||
phase := core.Msg(c, pomodoroKeys[s.phase])
|
||||
text := fmt.Sprintf("%02d:%02d", secs/60, secs%60)
|
||||
ring := ui.Box(c).Size(160, 160).Center().Role(ui.RoleStatus).Label(fmt.Sprintf(core.Msg(c, "pomodoro_timer.left"), phase, datetime.CountdownText(c, time.Duration(secs)*time.Second, datetime.CountdownWords)))
|
||||
tone := k.AccentText
|
||||
if s.phase != PomodoroFocus {
|
||||
tone = k.Success
|
||||
}
|
||||
ring.Draw(func(p *ui.Painter, rr ui.Rect) {
|
||||
cx, cy := internal.Snap(p, rr.X+rr.W/2), internal.Snap(p, rr.Y+rr.H/2)
|
||||
p.FillPath(pomodoroSector(p, cx, cy, 70, 76, 2*math.Pi), k.Border)
|
||||
if frac > 0 && !c.Preferences().ReduceMotion {
|
||||
p.FillPath(pomodoroSector(p, cx, cy, 70, 76, 2*math.Pi*float64(frac)), tone)
|
||||
}
|
||||
})
|
||||
ring.Children(func() {
|
||||
ui.Column(c).AlignItems(ui.Center).Gap(2).Children(func() {
|
||||
ui.Text(c, text).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.H1Size)).TextColor(k.Text).FontFeatures("tnum")
|
||||
ui.Text(c, phase).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(tone)
|
||||
})
|
||||
})
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "pomodoro_timer.sessions"), s.focused%every, every)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
ui.Row(c).Gap(d.Unit() * 2).Children(func() {
|
||||
key, icon, variant := "pomodoro_timer.start", "play", input.Primary
|
||||
if s.running {
|
||||
key, icon, variant = "pomodoro_timer.pause", "pause", input.Secondary
|
||||
}
|
||||
if input.Button(c, core.Msg(c, key), input.ButtonOptions{Variant: variant, Icon: icons.Must(icon)}).Clicked() {
|
||||
act("toggle", func() {
|
||||
if s.running {
|
||||
s.spent, s.running = s.elapsed(now), false
|
||||
} else {
|
||||
s.since, s.running = now, true
|
||||
}
|
||||
})
|
||||
}
|
||||
if input.Button(c, core.Msg(c, "pomodoro_timer.skip"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
|
||||
act("skip", next)
|
||||
}
|
||||
if input.Button(c, core.Msg(c, "pomodoro_timer.reset"), input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("rotate-ccw")}).Clicked() {
|
||||
act("reset", func() { *s = PomodoroState{} })
|
||||
}
|
||||
})
|
||||
})
|
||||
if s.running && live {
|
||||
delay := left % time.Second
|
||||
if delay == 0 {
|
||||
delay = time.Second
|
||||
}
|
||||
c.After(delay)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
var pomodoroKeys = [...]string{"pomodoro_timer.focus", "pomodoro_timer.break", "pomodoro_timer.long_break"}
|
||||
|
||||
// pomodoroSector snaps every changing vertex to the cache grid.
|
||||
func pomodoroSector(p *ui.Painter, cx, cy, inner, outer float32, end float64) *ui.Path {
|
||||
var path ui.Path
|
||||
n := max(2, int(math.Ceil(end/(math.Pi/48))))
|
||||
for i := 0; i <= n; i++ {
|
||||
x, y := internal.Polar(cx, cy, outer, end*float64(i)/float64(n))
|
||||
x, y = internal.Snap(p, x), internal.Snap(p, y)
|
||||
if i == 0 {
|
||||
path.MoveTo(x, y)
|
||||
} else {
|
||||
path.LineTo(x, y)
|
||||
}
|
||||
}
|
||||
for i := n; i >= 0; i-- {
|
||||
x, y := internal.Polar(cx, cy, inner, end*float64(i)/float64(n))
|
||||
path.LineTo(internal.Snap(p, x), internal.Snap(p, y))
|
||||
}
|
||||
path.Close()
|
||||
return &path
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type pomodoroProbe struct {
|
||||
s PomodoroState
|
||||
now time.Time
|
||||
opts PomodoroTimerOptions
|
||||
finished []PomodoroPhase
|
||||
changes int
|
||||
}
|
||||
|
||||
func (p *pomodoroProbe) tester() *ui.Tester {
|
||||
p.now = time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
o := p.opts
|
||||
o.Now = p.now
|
||||
r := PomodoroTimer(c, &p.s, o)
|
||||
if ph, ok := r.Finished(); ok {
|
||||
p.finished = append(p.finished, ph)
|
||||
}
|
||||
if r.Changed() {
|
||||
p.changes++
|
||||
}
|
||||
}, 400, 400)
|
||||
}
|
||||
|
||||
func TestPomodoroTimerCycle(t *testing.T) {
|
||||
p := &pomodoroProbe{opts: PomodoroTimerOptions{Focus: 2 * time.Second, Break: time.Second, LongBreak: 3 * time.Second, LongEvery: 2}}
|
||||
tt := p.tester()
|
||||
if !tt.HasText("Focus, 2 s left") {
|
||||
t.Fatalf("start %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Start")
|
||||
p.now = p.now.Add(900 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Focus, 2 s left") {
|
||||
t.Fatal("seconds round up")
|
||||
}
|
||||
_ = tt.Click("Pause")
|
||||
p.now = p.now.Add(time.Hour)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Focus, 2 s left") || p.s.Running() {
|
||||
t.Fatal("pause should hold the clock")
|
||||
}
|
||||
_ = tt.Click("Start")
|
||||
p.now = p.now.Add(1100 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if len(p.finished) != 1 || p.finished[0] != PomodoroFocus || p.s.Phase() != PomodoroBreak || p.s.Running() || p.s.Sessions() != 1 {
|
||||
t.Fatalf("focus end: %v %v", p.finished, p.s.Phase())
|
||||
}
|
||||
if a := tt.Announcements(); len(a) == 0 || a[len(a)-1] != "Focus finished." {
|
||||
t.Fatalf("announce %q", a)
|
||||
}
|
||||
_ = tt.Click("Skip")
|
||||
_ = tt.Click("Skip")
|
||||
if p.s.Phase() != PomodoroLongBreak || p.s.Sessions() != 2 {
|
||||
t.Fatalf("second focus should earn a long break: %v", p.s.Phase())
|
||||
}
|
||||
_ = tt.Click("Reset")
|
||||
if p.s.Phase() != PomodoroFocus || p.s.Sessions() != 0 || p.changes != 6 {
|
||||
t.Fatalf("reset %v (%d)", p.s.Phase(), p.changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPomodoroTimerKeyboardDisabledInvalid(t *testing.T) {
|
||||
p := &pomodoroProbe{opts: PomodoroTimerOptions{Disabled: true}}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Start")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if p.s.Running() || p.changes != 0 || !tt.HasText("Focus, 25 min left") {
|
||||
t.Fatal("disabled timer started")
|
||||
}
|
||||
q := &pomodoroProbe{}
|
||||
tt = q.tester()
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !q.s.Running() {
|
||||
t.Fatal("Enter should start")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
bad := &pomodoroProbe{opts: PomodoroTimerOptions{Focus: 1500 * time.Millisecond}}
|
||||
bad.tester()
|
||||
}
|
||||
|
||||
func TestPomodoroTimerSnapshot(t *testing.T) {
|
||||
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
s := PomodoroState{spent: 10 * time.Minute}
|
||||
snapshot(t, "pomodoro_timer", 360, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { PomodoroTimer(c, &s, PomodoroTimerOptions{Now: now}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestPomodoroTimerIdleAndHidden(t *testing.T) {
|
||||
s, shown, builds := PomodoroState{}, true, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
builds++
|
||||
core.Use(c, core.Settings{})
|
||||
if shown {
|
||||
PomodoroTimer(c, &s, PomodoroTimerOptions{})
|
||||
}
|
||||
}, 400, 400)
|
||||
if builds > 2 {
|
||||
t.Fatalf("idle built %d frames", builds)
|
||||
}
|
||||
if err := tt.Click("Start"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shown = false
|
||||
tt.Frame()
|
||||
before := builds
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
if builds != before || !s.Running() {
|
||||
t.Fatalf("hidden timer: builds %d, running %v", builds, s.Running())
|
||||
}
|
||||
shown = true
|
||||
tt.Frame()
|
||||
if !tt.HasText("Pause") {
|
||||
t.Fatal("visible timer did not retain its clock")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// PriorityIndicatorOptions configure a PriorityIndicator.
|
||||
type PriorityIndicatorOptions struct {
|
||||
// IconOnly hides the text; the name stays in the label.
|
||||
IconOnly bool
|
||||
}
|
||||
|
||||
var priorityKeys = [...]string{"priority_indicator.none", "priority_indicator.low", "priority_indicator.medium",
|
||||
"priority_indicator.high", "priority_indicator.urgent"}
|
||||
|
||||
// PriorityName is a priority's localized name.
|
||||
func PriorityName(c *ui.Context, p Priority) string {
|
||||
projectCheckPriority(p)
|
||||
return core.Msg(c, priorityKeys[p])
|
||||
}
|
||||
|
||||
// PriorityIndicator displays urgency with shapes and localized text.
|
||||
func PriorityIndicator(c *ui.Context, p Priority, opts PriorityIndicatorOptions) *ui.Element {
|
||||
name := PriorityName(c, p)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
col := k.TextMuted
|
||||
switch p {
|
||||
case PriorityUrgent:
|
||||
col = k.Danger
|
||||
case PriorityHigh:
|
||||
col = k.Warning
|
||||
}
|
||||
row := ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Shrink(0).Role(ui.RoleImage).Label(name)
|
||||
row.Children(func() {
|
||||
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(pp *ui.Painter, r ui.Rect) { priorityGlyph(pp, r, p, col, k.Surface, k.Border) })
|
||||
if !opts.IconOnly {
|
||||
ui.Text(c, name).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(col).SingleLine()
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// priorityGlyph draws level bars, urgency marks or empty dashes.
|
||||
func priorityGlyph(p *ui.Painter, r ui.Rect, pr Priority, col, bg, faint ui.Color) {
|
||||
switch pr {
|
||||
case PriorityUrgent:
|
||||
p.Fill(r, col, 2)
|
||||
p.Fill(ui.Rect{X: r.X + r.W/2 - 1, Y: r.Y + 3, W: 2, H: r.H - 8}, bg, 0)
|
||||
p.Fill(ui.Rect{X: r.X + r.W/2 - 1, Y: r.Y + r.H - 4, W: 2, H: 2}, bg, 0)
|
||||
case PriorityNone:
|
||||
for i := range 3 {
|
||||
p.Fill(ui.Rect{X: r.X + 1 + float32(i)*4.5, Y: r.Y + r.H/2 - 1, W: 3, H: 2}, col, 0)
|
||||
}
|
||||
default:
|
||||
for i := range 3 {
|
||||
h := r.H * float32(i+1) / 3
|
||||
c := faint
|
||||
if i < int(pr) {
|
||||
c = col
|
||||
}
|
||||
p.Fill(ui.Rect{X: r.X + 1 + float32(i)*4.5, Y: r.Y + r.H - h, W: 3, H: h}, c, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestPriorityIndicatorNamesAndShapes(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light, Locale: core.ZhCN})
|
||||
ui.Column(c).Children(func() {
|
||||
for p := PriorityNone; p <= PriorityUrgent; p++ {
|
||||
PriorityIndicator(c, p, PriorityIndicatorOptions{})
|
||||
}
|
||||
PriorityIndicator(c, PriorityHigh, PriorityIndicatorOptions{IconOnly: true})
|
||||
})
|
||||
}, 200, 200)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
for _, s := range []string{"无优先级", "低", "中", "高", "紧急"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q: %v", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityIndicatorBarsShowLevel(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
ui.Column(c).Children(func() {
|
||||
for p := PriorityLow; p <= PriorityHigh; p++ {
|
||||
ui.Box(c).Height(20).Children(func() { PriorityIndicator(c, p, PriorityIndicatorOptions{IconOnly: true}) })
|
||||
}
|
||||
})
|
||||
}, 100, 100)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if !tt.HasText("Medium") {
|
||||
t.Fatal("icon-only indicator lost its name")
|
||||
}
|
||||
img := tt.Image()
|
||||
k := theme.Light()
|
||||
for i, want := range []struct {
|
||||
n int
|
||||
col ui.Color
|
||||
}{{1, k.TextMuted}, {2, k.TextMuted}, {3, k.Warning}} {
|
||||
runs, on := 0, false
|
||||
for x := 0; x < 16; x++ {
|
||||
hit := datetimeNear(img.RGBAAt(x, i*20+13), want.col)
|
||||
if hit && !on {
|
||||
runs++
|
||||
}
|
||||
on = hit
|
||||
}
|
||||
if runs != want.n {
|
||||
t.Errorf("priority %d: %d filled bars, want %d", i+1, runs, want.n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityIndicatorInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); PriorityIndicator(c, 9, PriorityIndicatorOptions{}) }, 10, 10)
|
||||
}
|
||||
|
||||
func TestPriorityIndicatorSnapshot(t *testing.T) {
|
||||
snapshot(t, "priority_indicator", 520, 60, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(16).Children(func() {
|
||||
for p := PriorityNone; p <= PriorityUrgent; p++ {
|
||||
PriorityIndicator(c, p, PriorityIndicatorOptions{})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
)
|
||||
|
||||
// Priority describes urgency; zero means no priority.
|
||||
type Priority int
|
||||
|
||||
const (
|
||||
PriorityNone Priority = iota
|
||||
PriorityLow
|
||||
PriorityMedium
|
||||
PriorityHigh
|
||||
PriorityUrgent
|
||||
)
|
||||
|
||||
// ProjectMember is a person tasks can be assigned to.
|
||||
type ProjectMember struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// TaskLabel is a colored tag tasks carry.
|
||||
type TaskLabel struct {
|
||||
ID string
|
||||
Name string
|
||||
Color datetime.EventColor
|
||||
}
|
||||
|
||||
// Subtask is one checklist step of a task.
|
||||
type Subtask struct {
|
||||
ID string
|
||||
Title string
|
||||
Done bool
|
||||
}
|
||||
|
||||
// Task is one unit of project work.
|
||||
type Task struct {
|
||||
// ID is the issue key, as ENG-123.
|
||||
ID string
|
||||
Title string
|
||||
Done bool
|
||||
Priority Priority
|
||||
Due core.Date
|
||||
Status string
|
||||
Assignees []string
|
||||
Labels []string
|
||||
Subtasks []Subtask
|
||||
}
|
||||
|
||||
// StatusGroup is the workflow stage a status belongs to.
|
||||
type StatusGroup int
|
||||
|
||||
const (
|
||||
StatusBacklog StatusGroup = iota
|
||||
StatusUnstarted
|
||||
StatusStarted
|
||||
StatusCompleted
|
||||
StatusCanceled
|
||||
)
|
||||
|
||||
// WorkflowStatus is one workflow state.
|
||||
type WorkflowStatus struct {
|
||||
ID string
|
||||
Name string
|
||||
Group StatusGroup
|
||||
}
|
||||
|
||||
// ProjectResult exposes the element and task events.
|
||||
type ProjectResult struct {
|
||||
Element *ui.Element
|
||||
changed string
|
||||
opened string
|
||||
}
|
||||
|
||||
// Changed returns the edited task ID.
|
||||
func (r ProjectResult) Changed() (string, bool) { return r.changed, r.changed != "" }
|
||||
|
||||
// Opened returns the activated task ID.
|
||||
func (r ProjectResult) Opened() (string, bool) { return r.opened, r.opened != "" }
|
||||
|
||||
// projectCheckPriority panics on an unknown priority.
|
||||
func projectCheckPriority(p Priority) {
|
||||
if p < PriorityNone || p > PriorityUrgent {
|
||||
panic(fmt.Sprintf("mujica: Priority %d out of range", p))
|
||||
}
|
||||
}
|
||||
|
||||
// projectMember resolves a member ID or panics.
|
||||
func projectMember(members []ProjectMember, id string) ProjectMember {
|
||||
i := slices.IndexFunc(members, func(m ProjectMember) bool { return m.ID == id })
|
||||
if i < 0 {
|
||||
panic(fmt.Sprintf("mujica: unknown member %q", id))
|
||||
}
|
||||
return members[i]
|
||||
}
|
||||
|
||||
// projectLabel resolves a label ID or panics.
|
||||
func projectLabel(labels []TaskLabel, id string) TaskLabel {
|
||||
i := slices.IndexFunc(labels, func(l TaskLabel) bool { return l.ID == id })
|
||||
if i < 0 {
|
||||
panic(fmt.Sprintf("mujica: unknown label %q", id))
|
||||
}
|
||||
return labels[i]
|
||||
}
|
||||
|
||||
// projectStatus resolves a status ID or panics.
|
||||
func projectStatus(statuses []WorkflowStatus, id string) WorkflowStatus {
|
||||
i := slices.IndexFunc(statuses, func(s WorkflowStatus) bool { return s.ID == id })
|
||||
if i < 0 {
|
||||
panic(fmt.Sprintf("mujica: unknown status %q", id))
|
||||
}
|
||||
return statuses[i]
|
||||
}
|
||||
|
||||
// projectDueText formats a date relative to today.
|
||||
func projectDueText(c *ui.Context, due, today core.Date, done bool) (string, bool) {
|
||||
core.DatetimeCheckDate("project due", due)
|
||||
if due.IsZero() {
|
||||
return "", false
|
||||
}
|
||||
days := int(due.Civil().Sub(today.Civil()).Hours() / 24)
|
||||
switch {
|
||||
case days < 0 && !done:
|
||||
return core.CalendarCount(c, "projects.overdue", -days), true
|
||||
case days == 0:
|
||||
return core.Msg(c, "projects.due_today"), false
|
||||
case days == 1:
|
||||
return core.Msg(c, "projects.due_tomorrow"), false
|
||||
}
|
||||
return fmt.Sprintf(core.Msg(c, "projects.due"), core.DatetimeMonthName(c, due.Month), due.Day), false
|
||||
}
|
||||
|
||||
// projectBar draws a named completion bar.
|
||||
func projectBar(c *ui.Context, frac float64, label string) *ui.Element {
|
||||
k := core.Tokens(c)
|
||||
return ui.Box(c).Height(6).FillWidth().Role(ui.RoleProgress).Label(label).Description(fmt.Sprintf("%d%%", int(frac*100))).
|
||||
Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(r, k.Border, 3)
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(frac), H: r.H}, k.AccentText, 3)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
)
|
||||
|
||||
var projStatuses = []WorkflowStatus{
|
||||
{ID: "backlog", Name: "Backlog", Group: StatusBacklog},
|
||||
{ID: "todo", Name: "Todo", Group: StatusUnstarted},
|
||||
{ID: "doing", Name: "In Progress", Group: StatusStarted},
|
||||
{ID: "review", Name: "In Review", Group: StatusStarted},
|
||||
{ID: "done", Name: "Done", Group: StatusCompleted},
|
||||
{ID: "dropped", Name: "Dropped", Group: StatusCanceled},
|
||||
}
|
||||
|
||||
var projMembers = []ProjectMember{{ID: "saki", Name: "Sakiko Togawa"}, {ID: "mutsu", Name: "Mutsumi Wakaba"},
|
||||
{ID: "uika", Name: "Uika Misumi"}, {ID: "nyamu", Name: "Nyamu Yuutenji"}, {ID: "umiri", Name: "Umiri Yahata"}}
|
||||
|
||||
var projLabels = []TaskLabel{{ID: "bug", Name: "Bug", Color: datetime.EventDanger}, {ID: "ui", Name: "UI", Color: datetime.EventInfo},
|
||||
{ID: "docs", Name: "Docs", Color: datetime.EventSuccess}}
|
||||
|
||||
// projTasks returns fresh tasks for one test.
|
||||
func projTasks() []Task {
|
||||
return []Task{
|
||||
{ID: "AVE-1", Title: "Write the overture", Status: "todo", Priority: PriorityHigh, Due: oct(7), Assignees: []string{"saki"}, Labels: []string{"docs"}},
|
||||
{ID: "AVE-2", Title: "Tune the organ", Status: "doing", Priority: PriorityUrgent, Due: oct(3), Assignees: []string{"mutsu", "uika"}, Labels: []string{"bug"}},
|
||||
{ID: "AVE-3", Title: "Masks for the ball", Status: "doing", Priority: PriorityMedium, Assignees: []string{"nyamu"}},
|
||||
{ID: "AVE-4", Title: "Rehearse act two", Status: "review", Priority: PriorityLow, Due: oct(12)},
|
||||
{ID: "AVE-5", Title: "Book the hall", Status: "done", Done: true, Priority: PriorityNone, Due: oct(1), Labels: []string{"ui"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDueText(t *testing.T) {
|
||||
var got []string
|
||||
var late []bool
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
for _, c2 := range []struct {
|
||||
due core.Date
|
||||
done bool
|
||||
}{{oct(2), false}, {oct(4), false}, {oct(5), false}, {oct(6), false}, {oct(14), false}, {oct(1), true}} {
|
||||
s, l := projectDueText(c, c2.due, calendarToday, c2.done)
|
||||
got, late = append(got, s), append(late, l)
|
||||
}
|
||||
}, 10, 10)
|
||||
want := []string{"Overdue by 3 days", "Overdue by 1 day", "Due today", "Due tomorrow", "Due October 14", "Due October 1"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] || late[i] != (i < 2) {
|
||||
t.Fatalf("%d: %q %v", i, got[i], late[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/feedback"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Sprint is one iteration: its name, goal and inclusive dates.
|
||||
type Sprint struct {
|
||||
Name string
|
||||
Goal string
|
||||
Start, End core.Date
|
||||
}
|
||||
|
||||
// SprintBoardOptions configure a SprintBoard.
|
||||
type SprintBoardOptions struct {
|
||||
Board KanbanBoardOptions
|
||||
// Today counts the days left; zero uses the system clock.
|
||||
Today core.Date
|
||||
}
|
||||
|
||||
// SprintBoard combines iteration information, completion and a task board.
|
||||
func SprintBoard(c *ui.Context, sprint Sprint, tasks *[]Task, opts SprintBoardOptions) KanbanResult {
|
||||
core.DatetimeCheckDate("SprintBoard Start", sprint.Start)
|
||||
core.DatetimeCheckDate("SprintBoard End", sprint.End)
|
||||
if sprint.Start.IsZero() || sprint.End.Compare(sprint.Start) < 0 {
|
||||
panic(fmt.Sprintf("mujica: SprintBoard dates %v..%v are not a span", sprint.Start, sprint.End))
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
today := core.DatetimeToday(c, opts.Today)
|
||||
var r KanbanResult
|
||||
col := ui.Column(c).Gap(d.Unit() * 4).Grow(1).MinHeight(0).Role(ui.RoleGroup).Label(sprint.Name)
|
||||
cache := ui.Local(col, "sprint-counts", func() sprintCache { return sprintCache{} })
|
||||
var first *Task
|
||||
if len(*tasks) > 0 {
|
||||
first = &(*tasks)[0]
|
||||
}
|
||||
if !cache.ready || cache.first != first || cache.n != len(*tasks) || cache.version != opts.Board.Version || !slices.Equal(cache.statuses, opts.Board.Statuses) {
|
||||
*cache = sprintCache{first: first, n: len(*tasks), version: opts.Board.Version, statuses: slices.Clone(opts.Board.Statuses), ready: true}
|
||||
for _, t := range *tasks {
|
||||
if g := projectStatus(opts.Board.Statuses, t.Status).Group; g != StatusCanceled {
|
||||
cache.total++
|
||||
if g == StatusCompleted {
|
||||
cache.done++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
done, total := cache.done, cache.total
|
||||
col.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 6).AlignItems(ui.Center).Wrap().Children(func() {
|
||||
ui.Column(c).Gap(d.Unit()).Grow(1).Children(func() {
|
||||
ui.Text(c, sprint.Name).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H2Size)).TextColor(k.Text)
|
||||
if sprint.Goal != "" {
|
||||
ui.Text(c, sprint.Goal).TextColor(k.TextMuted)
|
||||
}
|
||||
ui.Text(c, datetime.WeekViewTitle(c, sprint.Start, sprint.End)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
})
|
||||
sprintStat(c, sprintDaysLeft(c, sprint, today), core.Msg(c, "sprint_board.time"))
|
||||
frac := 0.0
|
||||
if total > 0 {
|
||||
frac = float64(done) / float64(total)
|
||||
}
|
||||
ui.Column(c).Width(d.ControlHeight() * 5).Gap(d.Unit()).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "sprint_board.done"), done, total)).TextColor(k.Text).FontFeatures("tnum")
|
||||
feedback.Progress(c, frac, feedback.ProgressOptions{Label: core.Msg(c, "sprint_board.progress")})
|
||||
})
|
||||
})
|
||||
ui.Box(c).Height(12).FillWidth().Draw(func(p *ui.Painter, rr ui.Rect) { internal.RuleDiamond(p, rr, 8, 6, k.Ornament) })
|
||||
r = KanbanBoard(c, tasks, opts.Board)
|
||||
})
|
||||
if _, moved := r.Moved(); moved {
|
||||
cache.ready = false
|
||||
c.Invalidate()
|
||||
}
|
||||
r.Element = col
|
||||
return r
|
||||
}
|
||||
|
||||
// sprintDaysLeft formats remaining time relative to the iteration.
|
||||
func sprintDaysLeft(c *ui.Context, s Sprint, today core.Date) string {
|
||||
switch {
|
||||
case today.Compare(s.Start) < 0:
|
||||
return core.CalendarCount(c, "sprint_board.starts_in", int(s.Start.Civil().Sub(today.Civil()).Hours()/24))
|
||||
case today.Compare(s.End) > 0:
|
||||
return core.Msg(c, "sprint_board.ended")
|
||||
}
|
||||
return core.CalendarCount(c, "sprint_board.left", int(s.End.Civil().Sub(today.Civil()).Hours()/24)+1)
|
||||
}
|
||||
|
||||
// sprintStat is a figure above its caption.
|
||||
func sprintStat(c *ui.Context, value, caption string) {
|
||||
k := core.Tokens(c)
|
||||
ui.Column(c).Gap(2).Role(ui.RoleStatus).Label(caption + ": " + value).Children(func() {
|
||||
ui.Text(c, value).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(k.AccentText)
|
||||
ui.Text(c, caption).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
})
|
||||
}
|
||||
|
||||
type sprintCache struct {
|
||||
first *Task
|
||||
n, version, done, total int
|
||||
statuses []WorkflowStatus
|
||||
ready bool
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var projSprint = Sprint{Name: "Sprint 12", Goal: "Ready the masquerade", Start: oct(1), End: oct(14)}
|
||||
|
||||
func sprintTester(sprint Sprint, tasks *[]Task, today core.Date, disabled bool, moves *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Height(640).Padding(8).Children(func() {
|
||||
r := SprintBoard(c, sprint, tasks, SprintBoardOptions{Today: today, Board: KanbanBoardOptions{Columns: kanbanColumns3,
|
||||
Statuses: projStatuses, Members: projMembers, Labels: projLabels, Disabled: disabled}})
|
||||
if _, ok := r.Moved(); ok {
|
||||
*moves++
|
||||
}
|
||||
})
|
||||
}, 1240, 660)
|
||||
}
|
||||
|
||||
func TestSprintBoardHeaderAndMoves(t *testing.T) {
|
||||
tasks, n := projTasks(), 0
|
||||
tt := sprintTester(projSprint, &tasks, calendarToday, false, &n)
|
||||
for _, s := range []string{"Sprint 12", "Ready the masquerade", "October 1 – October 14, 2026", "10 days left", "1 of 5 done"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %v", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("AVE-4 Rehearse act two")
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if n != 1 || !tt.HasText("2 of 5 done") {
|
||||
t.Fatal("moving to Done should count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSprintBoardDaysAndDisabled(t *testing.T) {
|
||||
tasks, n := projTasks(), 0
|
||||
for _, c := range []struct {
|
||||
today core.Date
|
||||
want string
|
||||
}{{core.Date{Year: 2026, Month: 9, Day: 30}, "Starts in 1 day"}, {oct(14), "1 day left"}, {oct(15), "Ended"}} {
|
||||
if tt := sprintTester(projSprint, &tasks, c.today, false, &n); !tt.HasText(c.want) {
|
||||
t.Errorf("%v: want %q", c.today, c.want)
|
||||
}
|
||||
}
|
||||
tt := sprintTester(projSprint, &tasks, calendarToday, true, &n)
|
||||
_ = tt.Click("AVE-4 Rehearse act two")
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if n != 0 {
|
||||
t.Fatal("disabled board moved")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for an inverted sprint")
|
||||
}
|
||||
}()
|
||||
sprintTester(Sprint{Name: "x", Start: oct(5), End: oct(1)}, &tasks, calendarToday, false, &n)
|
||||
}
|
||||
|
||||
func TestSprintBoardSnapshot(t *testing.T) {
|
||||
tasks := projTasks()
|
||||
snapshot(t, "sprint_board", 1240, 560, func(c *ui.Context) {
|
||||
ui.Column(c).Height(560).Padding(16).Children(func() {
|
||||
SprintBoard(c, projSprint, &tasks, SprintBoardOptions{Today: calendarToday, Board: KanbanBoardOptions{Columns: kanbanColumns3,
|
||||
Statuses: projStatuses, Members: projMembers, Labels: projLabels}})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestSprintBoardLargeData(t *testing.T) {
|
||||
tasks := make([]Task, 100000)
|
||||
for i := range tasks {
|
||||
tasks[i] = Task{ID: fmt.Sprintf("BIG-%d", i+1), Title: "Task", Status: "todo"}
|
||||
}
|
||||
moves := 0
|
||||
tt := sprintTester(projSprint, &tasks, calendarToday, false, &moves)
|
||||
if built := len(tt.Texts()); built > 600 {
|
||||
t.Fatalf("built %d labels", built)
|
||||
}
|
||||
if !tt.HasText("0 of 100000 done") {
|
||||
t.Fatal("incorrect sprint summary")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// StatusSelectOptions configure a StatusSelect.
|
||||
type StatusSelectOptions struct {
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
var statusGroupKeys = [...]string{"status_select.backlog", "status_select.unstarted", "status_select.started",
|
||||
"status_select.completed", "status_select.canceled"}
|
||||
|
||||
// StatusSelect chooses a status grouped by workflow stage.
|
||||
func StatusSelect(c *ui.Context, value *string, statuses []WorkflowStatus, opts StatusSelectOptions) core.ChoiceResult {
|
||||
if len(statuses) == 0 {
|
||||
panic("mujica: StatusSelect needs statuses")
|
||||
}
|
||||
for i, s := range statuses {
|
||||
if s.Group < StatusBacklog || s.Group > StatusCanceled || s.ID == "" {
|
||||
panic(fmt.Sprintf("mujica: StatusSelect status %+v is not valid", s))
|
||||
}
|
||||
for _, o := range statuses[:i] {
|
||||
if o.ID == s.ID {
|
||||
panic(fmt.Sprintf("mujica: StatusSelect status %q repeats", s.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
cur := projectStatus(statuses, *value)
|
||||
k := core.Tokens(c)
|
||||
old := *value
|
||||
pick := old
|
||||
var s *ui.SelectParts[string]
|
||||
root := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
|
||||
epoch := ui.Local(root, "status-epoch", func() struct {
|
||||
disabled bool
|
||||
generation int
|
||||
} {
|
||||
return struct {
|
||||
disabled bool
|
||||
generation int
|
||||
}{}
|
||||
})
|
||||
if disabled := root.IsDisabled(); disabled != epoch.disabled {
|
||||
epoch.disabled = disabled
|
||||
epoch.generation++
|
||||
}
|
||||
root.Children(func() {
|
||||
ui.Row(c).Key(epoch.generation).Children(func() { s = ui.SelectBase(c, &pick) })
|
||||
})
|
||||
tr := s.Trigger
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "status_select.label")
|
||||
}
|
||||
tr.Label(label).Description(cur.Name)
|
||||
input.SelectField(c, tr, s.Open(), func() {
|
||||
statusGlyphBox(c, cur.Group)
|
||||
ui.Text(c, cur.Name).SingleLine().Grow(1).Shrink(1).TextColor(k.Text)
|
||||
})
|
||||
if !tr.IsDisabled() {
|
||||
s.Popup(func(panel *ui.Element) {
|
||||
input.SelectPanel(c, panel)
|
||||
_, wh := c.Size()
|
||||
ui.Scroll(c).MaxHeight(min(320, wh/2)).Children(func() {
|
||||
for g := StatusBacklog; g <= StatusCanceled; g++ {
|
||||
first := true
|
||||
for _, st := range statuses {
|
||||
if st.Group != g {
|
||||
continue
|
||||
}
|
||||
if first {
|
||||
ui.Text(c, core.Msg(c, statusGroupKeys[g])).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).
|
||||
Padding(core.Density(c).Unit(), core.Density(c).Unit()*2).Role(ui.RoleColumnHeader)
|
||||
first = false
|
||||
}
|
||||
it := s.Item(st.ID)
|
||||
input.SelectRow(c, it, it.Highlighted(), st.ID == *value, "")
|
||||
it.Label(st.Name).Children(func() {
|
||||
statusGlyphBox(c, st.Group)
|
||||
ui.Text(c, st.Name).SingleLine()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if pick != *value && !tr.IsDisabled() {
|
||||
*value = pick
|
||||
}
|
||||
changed := *value != old
|
||||
if changed {
|
||||
slog.Debug("mujica: status_select change", "from", old, "to", *value)
|
||||
}
|
||||
return core.NewChoiceResult(tr, changed)
|
||||
}
|
||||
|
||||
// statusGlyphBox reserves 14 DIPs for a status glyph.
|
||||
func statusGlyphBox(c *ui.Context, g StatusGroup) {
|
||||
k := core.Tokens(c)
|
||||
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { statusGlyph(p, r, g, k) })
|
||||
}
|
||||
|
||||
// statusGlyph draws a distinct shape for each workflow stage.
|
||||
func statusGlyph(p *ui.Painter, r ui.Rect, g StatusGroup, k theme.Tokens) {
|
||||
cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W/2-1
|
||||
ring := func(col ui.Color) {
|
||||
var path ui.Path
|
||||
internal.Arc(&path, cx, cy, rad, 0, 2*math.Pi, true)
|
||||
p.StrokePath(&path, 1.5, col)
|
||||
}
|
||||
switch g {
|
||||
case StatusBacklog:
|
||||
for i := 0; i < 8; i += 2 {
|
||||
var path ui.Path
|
||||
internal.Arc(&path, cx, cy, rad, float64(i)*math.Pi/4, float64(i+1)*math.Pi/4, true)
|
||||
p.StrokePath(&path, 1.5, k.TextMuted)
|
||||
}
|
||||
case StatusUnstarted:
|
||||
ring(k.TextMuted)
|
||||
case StatusStarted:
|
||||
ring(k.Warning)
|
||||
p.FillPath(internal.Sector(cx, cy, 0, rad-2, 0, math.Pi), k.Warning)
|
||||
case StatusCompleted:
|
||||
p.FillPath(internal.Sector(cx, cy, 0, rad+1, 0, 2*math.Pi), k.Success)
|
||||
internal.Mark(p, ui.Rect{X: r.X + 3, Y: r.Y + 3, W: r.W - 6, H: r.H - 6}, "check", k.Surface)
|
||||
case StatusCanceled:
|
||||
p.FillPath(internal.Sector(cx, cy, 0, rad+1, 0, 2*math.Pi), k.TextMuted)
|
||||
internal.Mark(p, ui.Rect{X: r.X + 3, Y: r.Y + 3, W: r.W - 6, H: r.H - 6}, "x", k.Surface)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func statusTester(v *string, opts *StatusSelectOptions, n *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
if StatusSelect(c, v, projStatuses, *opts).Changed() {
|
||||
*n++
|
||||
}
|
||||
})
|
||||
}, 400, 520)
|
||||
}
|
||||
|
||||
func TestStatusSelectGroupsAndPick(t *testing.T) {
|
||||
v, n := "todo", 0
|
||||
tt := statusTester(&v, &StatusSelectOptions{}, &n)
|
||||
_ = tt.Click("Status")
|
||||
for _, g := range []string{"Backlog", "To do", "In progress", "Done", "Canceled"} {
|
||||
if !tt.HasText(g) {
|
||||
t.Fatalf("group %q missing: %v", g, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("In Review")
|
||||
if v != "review" || n != 1 {
|
||||
t.Fatalf("picked %q", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if v != "done" || n != 2 {
|
||||
t.Fatalf("keyboard picked %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSelectDisabledAndInvalid(t *testing.T) {
|
||||
v, n := "todo", 0
|
||||
opts := &StatusSelectOptions{Disabled: true}
|
||||
tt := statusTester(&v, opts, &n)
|
||||
_ = tt.Click("Status")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if tt.HasText("In Review") || v != "todo" || n != 0 {
|
||||
t.Fatal("disabled select opened")
|
||||
}
|
||||
for _, bad := range []string{"nope", ""} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%q: expected panic", bad)
|
||||
}
|
||||
}()
|
||||
w := bad
|
||||
statusTester(&w, &StatusSelectOptions{}, &n)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSelectSnapshot(t *testing.T) {
|
||||
values := []string{"backlog", "todo", "doing", "done", "dropped"}
|
||||
snapshot(t, "status_select", 440, 176, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(8).Wrap().Children(func() {
|
||||
for i := range values {
|
||||
StatusSelect(c, &values[i], projStatuses, StatusSelectOptions{})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatusSelectAncestorDisableCloses(t *testing.T) {
|
||||
value, disabled := "todo", false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() { StatusSelect(c, &value, projStatuses, StatusSelectOptions{}) })
|
||||
}, 400, 520)
|
||||
if err := tt.Click("Status"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
if tt.HasText("In Review") {
|
||||
t.Fatal("ancestor-disabled popup remains visible")
|
||||
}
|
||||
disabled = false
|
||||
tt.Frame()
|
||||
if tt.HasText("In Review") {
|
||||
t.Fatal("popup reopens after enabling")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func subtaskSample() []Subtask {
|
||||
return []Subtask{{ID: "a", Title: "Sketch", Done: true}, {ID: "b", Title: "Score"}, {ID: "c", Title: "Rehearse"}}
|
||||
}
|
||||
|
||||
func subtaskTester(subs *[]Subtask, opts *SubtaskListOptions, n *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
if SubtaskList(c, subs, *opts).Changed() {
|
||||
*n++
|
||||
}
|
||||
})
|
||||
}, 480, 480)
|
||||
}
|
||||
|
||||
func subtaskTitles(s []Subtask) string {
|
||||
out := ""
|
||||
for _, x := range s {
|
||||
out += x.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSubtaskListProgressCheckAdd(t *testing.T) {
|
||||
subs, n := subtaskSample(), 0
|
||||
tt := subtaskTester(&subs, &SubtaskListOptions{}, &n)
|
||||
if !tt.HasText("1 of 3 done") {
|
||||
t.Fatalf("progress %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Score")
|
||||
if !subs[1].Done || !tt.HasText("2 of 3 done") {
|
||||
t.Fatal("check")
|
||||
}
|
||||
_ = tt.Click("Add a subtask")
|
||||
tt.Type("Bow")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(subs) != 4 || subs[3].Title != "Bow" || subs[3].ID != "sub-4" || !tt.HasText("2 of 4 done") {
|
||||
t.Fatalf("add %+v", subs)
|
||||
}
|
||||
_ = tt.Click("Remove Sketch")
|
||||
if subtaskTitles(subs) != "bcsub-4" || n != 3 {
|
||||
t.Fatalf("remove %v (%d)", subtaskTitles(subs), n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtaskListReorder(t *testing.T) {
|
||||
subs, n := subtaskSample(), 0
|
||||
tt := subtaskTester(&subs, &SubtaskListOptions{}, &n)
|
||||
_ = tt.Click("Rehearse")
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if subtaskTitles(subs) != "acb" {
|
||||
t.Fatalf("Alt+Up %v", subtaskTitles(subs))
|
||||
}
|
||||
g, _ := tt.Find("Drag Sketch")
|
||||
target, _ := tt.Find("Drag Score")
|
||||
calDrag(tt, g.X+g.W/2, g.Y+g.H/2, target.X+40, target.Y+target.H/2)
|
||||
if subtaskTitles(subs) != "cba" {
|
||||
t.Fatalf("drag %v", subtaskTitles(subs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtaskListDisabledAndInvalid(t *testing.T) {
|
||||
subs, n := subtaskSample(), 0
|
||||
tt := subtaskTester(&subs, &SubtaskListOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Score")
|
||||
_ = tt.Click("Remove Sketch")
|
||||
g, _ := tt.Find("Drag Sketch")
|
||||
calDrag(tt, g.X+g.W/2, g.Y+g.H/2, g.X+g.W/2, g.Y+120)
|
||||
if n != 0 || subtaskTitles(subs) != "abc" || subs[1].Done {
|
||||
t.Fatal("disabled list changed")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for repeated IDs")
|
||||
}
|
||||
}()
|
||||
bad := []Subtask{{ID: "x"}, {ID: "x"}}
|
||||
subtaskTester(&bad, &SubtaskListOptions{}, &n)
|
||||
}
|
||||
|
||||
func TestSubtaskListSnapshot(t *testing.T) {
|
||||
subs := subtaskSample()
|
||||
snapshot(t, "subtask_list", 440, 240, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { SubtaskList(c, &subs, SubtaskListOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubtaskListLargeData(t *testing.T) {
|
||||
subs := make([]Subtask, 100000)
|
||||
for i := range subs {
|
||||
subs[i] = Subtask{ID: fmt.Sprint(i), Title: "Step"}
|
||||
}
|
||||
n := 0
|
||||
tt := subtaskTester(&subs, &SubtaskListOptions{}, &n)
|
||||
if built := len(tt.Texts()); built > 200 {
|
||||
t.Fatalf("built %d labels", built)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// TaskDetailPanelOptions configure a TaskDetailPanel.
|
||||
type TaskDetailPanelOptions struct {
|
||||
Statuses []WorkflowStatus
|
||||
Members []ProjectMember
|
||||
Labels []TaskLabel
|
||||
// Activity builds the caller's activity log below the fields.
|
||||
Activity func()
|
||||
// Today anchors the due date; zero uses the system clock.
|
||||
Today core.Date
|
||||
Disabled bool
|
||||
// Version changes when task or related data change in place.
|
||||
Version int
|
||||
}
|
||||
|
||||
// TaskDetailResult exposes the panel, edits and close events.
|
||||
type TaskDetailResult struct {
|
||||
Element *ui.Element
|
||||
changed bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Changed reports a field edit this frame.
|
||||
func (r TaskDetailResult) Changed() bool { return r.changed }
|
||||
|
||||
// Closed reports a click on Close or Escape.
|
||||
func (r TaskDetailResult) Closed() bool { return r.closed }
|
||||
|
||||
// TaskDetailPanel edits task fields and hosts an activity slot.
|
||||
func TaskDetailPanel(c *ui.Context, task *Task, opts TaskDetailPanelOptions) TaskDetailResult {
|
||||
projectStatus(opts.Statuses, task.Status)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
r := TaskDetailResult{}
|
||||
panel := ui.Column(c).Gap(d.Unit()*4).Padding(d.Unit()*4).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.CardRadius).Role(ui.RoleGroup).Label(task.ID + " " + task.Title).Disabled(opts.Disabled)
|
||||
r.Element = panel
|
||||
opts.Disabled = panel.IsDisabled()
|
||||
if opts.Disabled {
|
||||
copied := *task
|
||||
copied.Assignees, copied.Labels, copied.Subtasks = slices.Clone(task.Assignees), slices.Clone(task.Labels), slices.Clone(task.Subtasks)
|
||||
task = &copied
|
||||
}
|
||||
panel.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
IssueIdBadge(c, task.ID, IssueIdBadgeOptions{Copyable: true, Disabled: opts.Disabled})
|
||||
ui.Spacer(c)
|
||||
if b := core.IconAction(c, icons.Must("x"), core.Msg(c, "task_detail_panel.close")); b.Clicked() && !b.IsDisabled() {
|
||||
r.closed = true
|
||||
}
|
||||
})
|
||||
title := input.TextInput(c, &task.Title, input.TextInputOptions{Label: core.Msg(c, "task_detail_panel.title"), Disabled: opts.Disabled})
|
||||
title.Input.Font(theme.SerifFont)
|
||||
r.changed = title.Changed()
|
||||
taskField(c, core.Msg(c, "task_detail_panel.status"), func() {
|
||||
r.changed = StatusSelect(c, &task.Status, opts.Statuses, StatusSelectOptions{Disabled: opts.Disabled}).Changed() || r.changed
|
||||
})
|
||||
taskField(c, core.Msg(c, "task_detail_panel.priority"), func() {
|
||||
prios := make([]input.SelectOption[Priority], 0, 5)
|
||||
for p := PriorityUrgent; p >= PriorityNone; p-- {
|
||||
prios = append(prios, input.SelectOption[Priority]{Value: p, Label: PriorityName(c, p)})
|
||||
}
|
||||
r.changed = input.Select(c, &task.Priority, prios, input.SelectOptions{Label: core.Msg(c, "task_detail_panel.priority"), Disabled: opts.Disabled}).Changed() || r.changed
|
||||
})
|
||||
taskField(c, core.Msg(c, "assignee_picker.label"), func() {
|
||||
r.changed = AssigneePicker(c, &task.Assignees, opts.Members, AssigneePickerOptions{Disabled: opts.Disabled, Version: opts.Version}).Changed() || r.changed
|
||||
})
|
||||
taskField(c, core.Msg(c, "due_date_picker.label"), func() {
|
||||
r.changed = DueDatePicker(c, &task.Due, DueDatePickerOptions{Done: task.Done, Today: opts.Today, Disabled: opts.Disabled}).Changed() || r.changed
|
||||
})
|
||||
taskField(c, core.Msg(c, "label_manager.label"), func() {
|
||||
options := make([]input.SelectOption[string], len(opts.Labels))
|
||||
for i, l := range opts.Labels {
|
||||
options[i] = input.SelectOption[string]{Value: l.ID, Label: l.Name}
|
||||
}
|
||||
r.changed = input.MultiSelect(c, &task.Labels, options, input.MultiSelectOptions{Label: core.Msg(c, "label_manager.label"), Disabled: opts.Disabled}).Changed() || r.changed
|
||||
})
|
||||
r.changed = SubtaskList(c, &task.Subtasks, SubtaskListOptions{Disabled: opts.Disabled, Version: opts.Version}).Changed() || r.changed
|
||||
if opts.Activity != nil {
|
||||
ui.Box(c).Height(16).FillWidth().Draw(func(p *ui.Painter, rr ui.Rect) { internal.RuleDiamond(p, rr, 8, 6, k.Ornament) })
|
||||
ui.Text(c, core.Msg(c, "task_detail_panel.activity")).Font(theme.SerifFont).TextColor(k.Text)
|
||||
opts.Activity()
|
||||
}
|
||||
})
|
||||
if !panel.IsDisabled() && panel.FocusWithin() && c.Shortcut(0, ui.KeyEscape) {
|
||||
r.closed = true
|
||||
}
|
||||
if r.changed {
|
||||
slog.Debug("mujica: task_detail_panel change", "id", task.ID)
|
||||
}
|
||||
if r.closed {
|
||||
slog.Debug("mujica: task_detail_panel close", "id", task.ID)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// taskField puts a caption above a control.
|
||||
func taskField(c *ui.Context, label string, control func()) {
|
||||
ui.Column(c).Gap(core.Density(c).Unit()).Children(func() {
|
||||
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(core.Tokens(c).TextMuted)
|
||||
control()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type detailProbe struct {
|
||||
task Task
|
||||
disabled bool
|
||||
version int
|
||||
changes, closes int
|
||||
activityRendered bool
|
||||
}
|
||||
|
||||
func (p *detailProbe) tester() *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Scroll(c).Fill().Children(func() {
|
||||
ui.Box(c).Width(420).Children(func() {
|
||||
r := TaskDetailPanel(c, &p.task, TaskDetailPanelOptions{Statuses: projStatuses, Members: projMembers, Labels: projLabels,
|
||||
Today: calendarToday, Disabled: p.disabled, Version: p.version, Activity: func() {
|
||||
p.activityRendered = true
|
||||
ui.Text(c, "Sakiko opened this task")
|
||||
}})
|
||||
if r.Changed() {
|
||||
p.changes++
|
||||
}
|
||||
if r.Closed() {
|
||||
p.closes++
|
||||
}
|
||||
})
|
||||
})
|
||||
}, 640, 1200)
|
||||
}
|
||||
|
||||
func TestTaskDetailPanelEditsFields(t *testing.T) {
|
||||
p := &detailProbe{task: projTasks()[0]}
|
||||
tt := p.tester()
|
||||
if !tt.HasText("Sakiko opened this task") || !tt.HasText("Activity") {
|
||||
t.Fatal("activity slot missing")
|
||||
}
|
||||
_ = tt.Click("Todo")
|
||||
_ = tt.Click("In Review")
|
||||
_ = tt.Click("Tomorrow")
|
||||
_ = tt.Click("Add a subtask")
|
||||
tt.Type("Copy parts")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if p.task.Status != "review" || p.task.Due != oct(6) || len(p.task.Subtasks) != 1 || p.changes != 3 {
|
||||
t.Fatalf("edited %+v (%d changes)", p.task, p.changes)
|
||||
}
|
||||
_ = tt.Click("Title")
|
||||
tt.Command("selectAll")
|
||||
tt.Type("Write the requiem")
|
||||
if p.task.Title != "Write the requiem" {
|
||||
t.Fatalf("title %q", p.task.Title)
|
||||
}
|
||||
_ = tt.Click("Close details")
|
||||
if p.closes != 1 {
|
||||
t.Fatal("close not reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailPanelDisabled(t *testing.T) {
|
||||
p := &detailProbe{task: projTasks()[0], disabled: true}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Tomorrow")
|
||||
_ = tt.Click("Title")
|
||||
tt.Type("x")
|
||||
_ = tt.Click("Close details")
|
||||
if p.changes != 0 || p.closes != 0 || p.task.Title != "Write the overture" {
|
||||
t.Fatalf("disabled panel changed %+v", p.task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailPanelSnapshot(t *testing.T) {
|
||||
task := projTasks()[1]
|
||||
task.Subtasks = []Subtask{{ID: "s1", Title: "Replace the pipes", Done: true}, {ID: "s2", Title: "Tune by ear"}}
|
||||
snapshot(t, "task_detail_panel", 460, 900, func(c *ui.Context) {
|
||||
ui.Box(c).Width(420).Margin(16).Children(func() {
|
||||
TaskDetailPanel(c, &task, TaskDetailPanelOptions{Statuses: projStatuses, Members: projMembers, Labels: projLabels,
|
||||
Today: calendarToday, Activity: func() { ui.Text(c, "Mutsumi moved this to In Progress") }})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskDetailPanelAncestorDisableClosesLabels(t *testing.T) {
|
||||
task, disabled := projTasks()[0], false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
TaskDetailPanel(c, &task, TaskDetailPanelOptions{Statuses: projStatuses, Members: projMembers, Labels: projLabels})
|
||||
})
|
||||
}, 600, 1000)
|
||||
if err := tt.Click("Docs"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Select all") {
|
||||
t.Fatal("labels popup did not open")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Select all") {
|
||||
t.Fatal("disabled panel retained its labels popup")
|
||||
}
|
||||
if taskSnapshot(task) != taskSnapshot(projTasks()[0]) {
|
||||
t.Fatal("disabled panel changed task fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailPanelEscapeClosesPopupFirst(t *testing.T) {
|
||||
p := &detailProbe{task: projTasks()[0]}
|
||||
tt := p.tester()
|
||||
if err := tt.Click("Todo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("In Review") {
|
||||
t.Fatal("status popup did not open")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if p.closes != 0 || tt.HasText("In Review") {
|
||||
t.Fatalf("popup escape closes = %d, popup visible = %v", p.closes, tt.HasText("In Review"))
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if p.closes != 1 {
|
||||
t.Fatalf("panel escape closes = %d", p.closes)
|
||||
}
|
||||
}
|
||||
|
||||
// taskSnapshot compares editable task fields.
|
||||
func taskSnapshot(t Task) string {
|
||||
return fmt.Sprintf("%q|%q|%d|%v|%q|%q|%v", t.Title, t.Status, t.Priority, t.Due, t.Assignees, t.Labels, t.Subtasks)
|
||||
}
|
||||
|
||||
func TestTaskDetailPanelVersionUpdatesSubtasks(t *testing.T) {
|
||||
p := &detailProbe{task: projTasks()[0]}
|
||||
p.task.Subtasks = []Subtask{{ID: "one", Title: "One"}}
|
||||
tt := p.tester()
|
||||
p.task.Subtasks[0].Done = true
|
||||
p.version++
|
||||
tt.Frame()
|
||||
if !tt.HasText("1 of 1 done") || p.changes != 0 {
|
||||
t.Fatalf("external update: changes %d, labels %v", p.changes, tt.Texts())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// TaskItemOptions configure a TaskItem.
|
||||
type TaskItemOptions struct {
|
||||
Members []ProjectMember
|
||||
// Today anchors the overdue mark; zero uses the system clock.
|
||||
Today core.Date
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// taskItemEdit is a title being edited inline.
|
||||
type taskItemEdit struct {
|
||||
on bool
|
||||
draft string
|
||||
}
|
||||
|
||||
// TaskItem displays task fields with completion and inline editing.
|
||||
func TaskItem(c *ui.Context, task *Task, opts TaskItemOptions) ProjectResult {
|
||||
projectCheckPriority(task.Priority)
|
||||
for _, id := range task.Assignees {
|
||||
projectMember(opts.Members, id)
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
today := core.DatetimeToday(c, opts.Today)
|
||||
r := ProjectResult{}
|
||||
row := ui.Row(c).MinHeight(d.ControlHeight()).Gap(d.Unit() * 3).AlignItems(ui.Center).PaddingX(d.Unit() * 2).
|
||||
Role(ui.RoleListItem).Label(task.Title).Disabled(opts.Disabled)
|
||||
r.Element = row
|
||||
edit := ui.Local(row, "task-item-edit", func() taskItemEdit { return taskItemEdit{} })
|
||||
if row.Hovered() {
|
||||
row.Background(k.SurfaceHover)
|
||||
}
|
||||
row.Children(func() {
|
||||
done := task.Done
|
||||
if input.Checkbox(c, &done, "", input.CheckboxOptions{Disabled: opts.Disabled}).Label(task.Title).Changed() && !row.IsDisabled() {
|
||||
task.Done, r.changed = done, task.ID
|
||||
slog.Debug("mujica: task_item done", "id", task.ID, "done", done)
|
||||
}
|
||||
if edit.on && !row.IsDisabled() {
|
||||
in := input.TextInput(c, &edit.draft, input.TextInputOptions{Label: core.Msg(c, "task_item.rename")})
|
||||
in.Element.Grow(1)
|
||||
in.Input.AutoFocus()
|
||||
switch {
|
||||
case in.Submitted() && strings.TrimSpace(edit.draft) != "":
|
||||
if t := strings.TrimSpace(edit.draft); t != task.Title {
|
||||
task.Title, r.changed = t, task.ID
|
||||
slog.Debug("mujica: task_item rename", "id", task.ID)
|
||||
}
|
||||
edit.on = false
|
||||
case in.Input.Shortcut(0, ui.KeyEscape):
|
||||
edit.on = false
|
||||
}
|
||||
return
|
||||
}
|
||||
edit.on = false
|
||||
title := ui.ButtonBase(c).Grow(1).Shrink(1).Justify(ui.Start).MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).Label(task.Title)
|
||||
internal.Ring(title, k.Focus, theme.ControlRadius)
|
||||
title.Children(func() {
|
||||
t := ui.Text(c, task.Title).SingleLine().Ellipsis("…").TextColor(k.Text)
|
||||
if task.Done {
|
||||
t.Strikethrough().TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
if !title.IsDisabled() {
|
||||
if title.DoubleClicked() || title.Shortcut(0, ui.KeyF2) {
|
||||
*edit = taskItemEdit{on: true, draft: task.Title}
|
||||
} else if title.Clicked() {
|
||||
r.opened = task.ID
|
||||
}
|
||||
}
|
||||
PriorityIndicator(c, task.Priority, PriorityIndicatorOptions{IconOnly: true})
|
||||
if text, late := projectDueText(c, task.Due, today, task.Done); text != "" {
|
||||
fg := k.TextMuted
|
||||
if late {
|
||||
fg = k.Danger
|
||||
}
|
||||
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(fg).SingleLine().Shrink(0)
|
||||
}
|
||||
if len(task.Assignees) > 0 {
|
||||
items := make([]display.AvatarItem, len(task.Assignees))
|
||||
for i, id := range task.Assignees {
|
||||
items[i] = display.AvatarItem{Name: projectMember(opts.Members, id).Name}
|
||||
}
|
||||
display.AvatarGroup(c, items, display.AvatarGroupOptions{Max: 2, Size: 24})
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type taskItemProbe struct {
|
||||
task Task
|
||||
disabled bool
|
||||
changes, openeds []string
|
||||
}
|
||||
|
||||
func (p *taskItemProbe) tester() *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(8).Children(func() {
|
||||
r := TaskItem(c, &p.task, TaskItemOptions{Members: projMembers, Today: calendarToday, Disabled: p.disabled})
|
||||
if id, ok := r.Changed(); ok {
|
||||
p.changes = append(p.changes, id)
|
||||
}
|
||||
if id, ok := r.Opened(); ok {
|
||||
p.openeds = append(p.openeds, id)
|
||||
}
|
||||
})
|
||||
}, 600, 120)
|
||||
}
|
||||
|
||||
func TestTaskItemFieldsAndCheck(t *testing.T) {
|
||||
p := &taskItemProbe{task: projTasks()[1]}
|
||||
tt := p.tester()
|
||||
for _, s := range []string{"Tune the organ", "Urgent", "Overdue by 2 days", "Mutsumi Wakaba"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %v", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !p.task.Done || len(p.changes) != 1 || tt.HasText("Overdue") {
|
||||
t.Fatalf("check: %+v", p.task)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(p.openeds) != 1 || p.openeds[0] != "AVE-2" {
|
||||
t.Fatalf("opened %v", p.openeds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskItemInlineRename(t *testing.T) {
|
||||
p := &taskItemProbe{task: projTasks()[0]}
|
||||
tt := p.tester()
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyF2)
|
||||
tt.Command("selectAll")
|
||||
tt.Type("Write the finale")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if p.task.Title != "Write the finale" || len(p.changes) != 1 || !tt.HasText("Write the finale") {
|
||||
t.Fatalf("rename %q", p.task.Title)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyF2)
|
||||
tt.Type(" draft")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if p.task.Title != "Write the finale" || !tt.HasText("Write the finale") {
|
||||
t.Fatalf("Escape kept %q", p.task.Title)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyF2)
|
||||
tt.Command("selectAll")
|
||||
tt.Type(" ")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if p.task.Title != "Write the finale" {
|
||||
t.Fatal("an empty title was saved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskItemDisabled(t *testing.T) {
|
||||
p := &taskItemProbe{task: projTasks()[0], disabled: true}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Write the overture")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyF2)
|
||||
if p.task.Done || len(p.changes)+len(p.openeds) != 0 {
|
||||
t.Fatal("disabled item reacted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskItemSnapshot(t *testing.T) {
|
||||
tasks := projTasks()
|
||||
snapshot(t, "task_item", 560, 220, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(8).Children(func() {
|
||||
for i := range tasks {
|
||||
TaskItem(c, &tasks[i], TaskItemOptions{Members: projMembers, Today: calendarToday})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 []ProjectMember
|
||||
// Version must change when tasks change in place.
|
||||
Version int
|
||||
// Today anchors overdue marks; zero uses the system clock.
|
||||
Today core.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 core.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 := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.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 != core.CurrentLocale(c) {
|
||||
*cache = &taskListCache{first: first, n: len(tasks), ver: opts.Version, group: opts.GroupBy, filter: *filter, statuses: slices.Clone(opts.Statuses), locale: core.CurrentLocale(c)}
|
||||
(*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(core.Msg(c, "task_list.all"), counts[0]),
|
||||
fmt.Sprintf(core.Msg(c, "task_list.open"), counts[1]),
|
||||
fmt.Sprintf(core.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, core.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 := core.Tokens(c), core.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(core.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, core.Motion(c, theme.StateDuration), fg)
|
||||
internal.Ring(seg, k.Focus, theme.ControlRadius)
|
||||
seg.Children(func() { ui.Text(c, label).SingleLine() })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
type taskListProbe struct {
|
||||
tasks []Task
|
||||
filter TaskFilter
|
||||
opts TaskListOptions
|
||||
changed, opens []string
|
||||
}
|
||||
|
||||
func (p *taskListProbe) tester() *ui.Tester {
|
||||
p.opts.Members, p.opts.Statuses, p.opts.Today = projMembers, projStatuses, calendarToday
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Height(560).Padding(8).Children(func() {
|
||||
r := TaskList(c, p.tasks, &p.filter, p.opts)
|
||||
if id, ok := r.Changed(); ok {
|
||||
p.changed = append(p.changed, id)
|
||||
}
|
||||
if id, ok := r.Opened(); ok {
|
||||
p.opens = append(p.opens, id)
|
||||
}
|
||||
})
|
||||
}, 640, 580)
|
||||
}
|
||||
|
||||
func textIndex(texts []string, s string) int {
|
||||
for i, x := range texts {
|
||||
if x == s {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestTaskListGroupsAndFilter(t *testing.T) {
|
||||
p := &taskListProbe{tasks: projTasks(), opts: TaskListOptions{GroupBy: TaskGroupStatus}}
|
||||
tt := p.tester()
|
||||
tx := tt.Texts()
|
||||
todo, doing, review, done := textIndex(tx, "Todo"), textIndex(tx, "In Progress"), textIndex(tx, "In Review"), textIndex(tx, "Done")
|
||||
if !(todo >= 0 && todo < doing && doing < review && review < done) || textIndex(tx, "Backlog") >= 0 {
|
||||
t.Fatalf("groups %d %d %d %d in %v", todo, doing, review, done, tx)
|
||||
}
|
||||
_ = tt.Click("Open 4")
|
||||
if p.filter != TaskShowOpen || tt.HasText("Book the hall") || !tt.HasText("Tune the organ") {
|
||||
t.Fatalf("open filter %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Done 1")
|
||||
if tt.HasText("Tune the organ") || !tt.HasText("Book the hall") {
|
||||
t.Fatal("done filter")
|
||||
}
|
||||
_ = tt.Click("Book the hall")
|
||||
if len(p.opens) != 1 || p.opens[0] != "AVE-5" {
|
||||
t.Fatalf("opened %v", p.opens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskListPriorityGroupsAndEdit(t *testing.T) {
|
||||
p := &taskListProbe{tasks: projTasks(), opts: TaskListOptions{GroupBy: TaskGroupPriority}}
|
||||
tt := p.tester()
|
||||
tx := tt.Texts()
|
||||
if u, h := textIndex(tx, "Urgent"), textIndex(tx, "High"); u < 0 || u > h {
|
||||
t.Fatalf("priority order %v", tx)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if len(p.changed) != 1 || !p.tasks[1].Done {
|
||||
t.Fatalf("keyboard check %v %+v", p.changed, p.tasks[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskListDisabledEmptyInvalid(t *testing.T) {
|
||||
p := &taskListProbe{tasks: projTasks(), opts: TaskListOptions{Disabled: true}}
|
||||
tt := p.tester()
|
||||
_ = tt.Click("Done 1")
|
||||
_ = tt.Click("Write the overture")
|
||||
if p.filter != TaskShowAll || len(p.opens) != 0 {
|
||||
t.Fatal("disabled list reacted")
|
||||
}
|
||||
q := &taskListProbe{}
|
||||
if tt := q.tester(); !tt.HasText("No tasks here.") {
|
||||
t.Fatal("no empty state")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
bad := &taskListProbe{filter: 7}
|
||||
bad.tester()
|
||||
}
|
||||
|
||||
func TestTaskListLargeData(t *testing.T) {
|
||||
p := &taskListProbe{opts: TaskListOptions{GroupBy: TaskGroupPriority}}
|
||||
for i := range 100000 {
|
||||
p.tasks = append(p.tasks, Task{ID: fmt.Sprintf("BIG-%d", i+1), Title: "Task", Status: "todo", Priority: Priority(i % 5)})
|
||||
}
|
||||
tt := p.tester()
|
||||
if n := len(tt.Texts()); n > 200 {
|
||||
t.Fatalf("built %d labels", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskListSnapshot(t *testing.T) {
|
||||
tasks := projTasks()
|
||||
filter := TaskShowAll
|
||||
snapshot(t, "task_list", 600, 420, func(c *ui.Context) {
|
||||
ui.Column(c).Height(420).Padding(8).Children(func() {
|
||||
TaskList(c, tasks, &filter, TaskListOptions{GroupBy: TaskGroupStatus, Statuses: projStatuses, Members: projMembers, Today: calendarToday})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskListCompletionRefreshesCounts(t *testing.T) {
|
||||
p := &taskListProbe{tasks: projTasks()}
|
||||
tt := p.tester()
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !tt.HasText("Open 3") || !tt.HasText("Done 2") {
|
||||
t.Fatal("completion left cached filter counts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskListFilterHitAreas(t *testing.T) {
|
||||
for _, density := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} {
|
||||
tasks, filter := projTasks(), TaskShowAll
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Density: density})
|
||||
ui.Box(c).Height(300).Children(func() { TaskList(c, tasks, &filter, TaskListOptions{Members: projMembers}) })
|
||||
}, 600, 320)
|
||||
for _, label := range []string{"All 5", "Open 4", "Done 1"} {
|
||||
r, ok := tt.Find(label)
|
||||
if !ok || r.W < 28 || r.H < 28 {
|
||||
t.Fatalf("density %v, %s: bounds %v", density, label, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// TimeEntry records a span; zero End means running.
|
||||
type TimeEntry struct {
|
||||
Start, End time.Time
|
||||
}
|
||||
|
||||
// TimeTrackerState holds the caller's tracked spans and manual correction.
|
||||
type TimeTrackerState struct {
|
||||
Entries []TimeEntry
|
||||
// Adjust is added to the tracked spans by manual correction.
|
||||
Adjust time.Duration
|
||||
}
|
||||
|
||||
// Running reports whether the last entry is still open.
|
||||
func (s *TimeTrackerState) Running() bool {
|
||||
return len(s.Entries) > 0 && s.Entries[len(s.Entries)-1].End.IsZero()
|
||||
}
|
||||
|
||||
// Total returns the tracked time at now, including the correction.
|
||||
func (s *TimeTrackerState) Total(now time.Time) time.Duration {
|
||||
t := s.Adjust
|
||||
for _, e := range s.Entries {
|
||||
end := e.End
|
||||
if end.IsZero() {
|
||||
end = now
|
||||
}
|
||||
t += end.Sub(e.Start)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// TimeTrackerOptions configure a TimeTracker.
|
||||
type TimeTrackerOptions struct {
|
||||
// Now fixes the clock; zero uses the system clock.
|
||||
Now time.Time
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// TimeTracker records spans and supports manual total corrections.
|
||||
func TimeTracker(c *ui.Context, s *TimeTrackerState, opts TimeTrackerOptions) core.ChoiceResult {
|
||||
now := opts.Now
|
||||
live := now.IsZero()
|
||||
if live {
|
||||
now = c.Now()
|
||||
}
|
||||
for i, e := range s.Entries {
|
||||
if e.Start.IsZero() || e.Start.After(now) || !e.End.IsZero() && (e.End.Before(e.Start) || e.End.After(now)) || e.End.IsZero() && i != len(s.Entries)-1 || i > 0 && e.Start.Before(s.Entries[i-1].End) {
|
||||
panic("mujica: TimeTracker entries must be ordered spans with only the last open")
|
||||
}
|
||||
}
|
||||
if s.Total(now) < 0 {
|
||||
panic("mujica: TimeTracker total must be non-negative")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "time_tracker.label")
|
||||
}
|
||||
changed := false
|
||||
row := ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
editing := ui.Local(row, "time-tracker-edit", func() bool { return false })
|
||||
draft := ui.Local(row, "time-tracker-draft", func() time.Duration { return 0 })
|
||||
act := func(what string, fn func()) {
|
||||
if row.IsDisabled() {
|
||||
return
|
||||
}
|
||||
fn()
|
||||
changed = true
|
||||
slog.Debug("mujica: time_tracker "+what, "total", s.Total(now))
|
||||
}
|
||||
row.Children(func() {
|
||||
total := s.Total(now)
|
||||
running := s.Running()
|
||||
icon, key, variant := "play", "time_tracker.start", input.Secondary
|
||||
if running {
|
||||
icon, key, variant = "pause", "time_tracker.stop", input.Primary
|
||||
}
|
||||
if input.Button(c, core.Msg(c, key), input.ButtonOptions{Variant: variant, Icon: icons.Must(icon)}).Clicked() {
|
||||
act(key[len("time_tracker."):], func() {
|
||||
if running {
|
||||
s.Entries[len(s.Entries)-1].End = now
|
||||
} else {
|
||||
s.Entries = append(s.Entries, TimeEntry{Start: now})
|
||||
}
|
||||
})
|
||||
}
|
||||
if *editing && !row.IsDisabled() {
|
||||
datetime.DurationPicker(c, draft, datetime.DurationPickerOptions{Seconds: true, Label: core.Msg(c, "time_tracker.correct")})
|
||||
if input.Button(c, core.Msg(c, "time_tracker.apply"), input.ButtonOptions{}).Clicked() {
|
||||
act("correct", func() { s.Adjust += *draft - total })
|
||||
*editing = false
|
||||
}
|
||||
if input.Button(c, core.Msg(c, "calendar.cancel"), input.ButtonOptions{Variant: input.Ghost}).Clicked() {
|
||||
*editing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
*editing = false
|
||||
fg := k.Text
|
||||
if running {
|
||||
fg = k.AccentText
|
||||
}
|
||||
ui.Text(c, datetime.StopwatchText(total)).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(fg).FontFeatures("tnum").
|
||||
Label(label + ", " + datetime.DurationText(c, total.Truncate(time.Second)))
|
||||
if running {
|
||||
ui.Text(c, core.Msg(c, "time_tracker.running")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Box(c).Disabled(running).Children(func() {
|
||||
if b := core.IconAction(c, icons.Must("pencil"), core.Msg(c, "time_tracker.correct")); b.Clicked() && !b.IsDisabled() {
|
||||
*editing, *draft = true, total.Truncate(time.Second)
|
||||
}
|
||||
})
|
||||
})
|
||||
if s.Running() && live {
|
||||
c.After(100*time.Millisecond - s.Total(now)%(100*time.Millisecond))
|
||||
}
|
||||
return core.NewChoiceResult(row, changed)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestTimeTrackerStartStopCorrect(t *testing.T) {
|
||||
var s TimeTrackerState
|
||||
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
n, disabled := 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if TimeTracker(c, &s, TimeTrackerOptions{Now: now, Disabled: disabled}).Changed() {
|
||||
n++
|
||||
}
|
||||
}, 900, 160)
|
||||
_ = tt.Click("Start")
|
||||
now = now.Add(25*time.Minute + 30*time.Second)
|
||||
tt.Frame()
|
||||
if !s.Running() || !tt.HasText("Time tracked, 25 min 30 s") || !tt.HasText("Tracking…") {
|
||||
t.Fatalf("running %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Correct the total")
|
||||
if tt.HasText("Apply") {
|
||||
t.Fatal("correction must wait until tracking stops")
|
||||
}
|
||||
_ = tt.Click("Stop")
|
||||
now = now.Add(time.Hour)
|
||||
_ = tt.Click("Start")
|
||||
now = now.Add(4*time.Minute + 30*time.Second)
|
||||
_ = tt.Click("Stop")
|
||||
if s.Total(now) != 30*time.Minute || len(s.Entries) != 2 || n != 4 {
|
||||
t.Fatalf("total %v entries %d changes %d", s.Total(now), len(s.Entries), n)
|
||||
}
|
||||
_ = tt.Click("Correct the total")
|
||||
_ = tt.Click("Minutes")
|
||||
tt.Command("selectAll")
|
||||
tt.Type("45")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
_ = tt.Click("Apply")
|
||||
if s.Total(now) != 45*time.Minute || s.Adjust != 15*time.Minute || !tt.HasText("Time tracked, 45 min") {
|
||||
t.Fatalf("corrected to %v (adjust %v)", s.Total(now), s.Adjust)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Start")
|
||||
if s.Running() {
|
||||
t.Fatal("disabled tracker started")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeTrackerInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for two open entries")
|
||||
}
|
||||
}()
|
||||
now := time.Now()
|
||||
s := TimeTrackerState{Entries: []TimeEntry{{Start: now}, {Start: now}}}
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); TimeTracker(c, &s, TimeTrackerOptions{Now: now}) }, 10, 10)
|
||||
}
|
||||
|
||||
func TestTimeTrackerSnapshot(t *testing.T) {
|
||||
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
s := TimeTrackerState{Entries: []TimeEntry{{Start: now.Add(-time.Hour), End: now.Add(-30 * time.Minute)}, {Start: now.Add(-12 * time.Minute)}}}
|
||||
snapshot(t, "time_tracker", 460, 80, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { TimeTracker(c, &s, TimeTrackerOptions{Now: now}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimeTrackerRejectsInvalidSpans(t *testing.T) {
|
||||
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
for _, state := range []TimeTrackerState{
|
||||
{Entries: []TimeEntry{{Start: now, End: now.Add(time.Hour)}, {Start: now.Add(time.Minute), End: now.Add(2 * time.Hour)}}},
|
||||
{Entries: []TimeEntry{{Start: now.Add(time.Hour)}}},
|
||||
{Adjust: -time.Second},
|
||||
} {
|
||||
t.Run("span", func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected invalid span panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
TimeTracker(c, &state, TimeTrackerOptions{Now: now})
|
||||
}, 600, 200)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeTrackerCorrectionRemovesFractionalRemainder(t *testing.T) {
|
||||
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
|
||||
s := TimeTrackerState{Entries: []TimeEntry{{Start: now.Add(-30500 * time.Millisecond), End: now}}}
|
||||
tt := ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); TimeTracker(c, &s, TimeTrackerOptions{Now: now}) }, 900, 180)
|
||||
if err := tt.Click("Correct the total"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("Apply"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Total(now) != 30*time.Second {
|
||||
t.Fatalf("total = %v", s.Total(now))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Workload is one member's assigned and available hours.
|
||||
type Workload struct {
|
||||
Member ProjectMember
|
||||
Assigned float64
|
||||
Capacity float64
|
||||
}
|
||||
|
||||
// WorkloadViewOptions configure a WorkloadView.
|
||||
type WorkloadViewOptions struct {
|
||||
// Unit labels the amounts; empty uses hours.
|
||||
Unit string
|
||||
Label string
|
||||
// Version changes when loads change in place.
|
||||
Version int
|
||||
}
|
||||
|
||||
// WorkloadView virtualizes member loads with capacity and overload marks.
|
||||
func WorkloadView(c *ui.Context, loads []Workload, opts WorkloadViewOptions) *ui.Element {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
unit := opts.Unit
|
||||
if unit == "" {
|
||||
unit = core.Msg(c, "workload_view.hours")
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "workload_view.label")
|
||||
}
|
||||
root := ui.Column(c).Grow(1).MinHeight(0).Role(ui.RoleList).Label(label)
|
||||
cache := ui.Local(root, "workload-summary", func() workloadCache { return workloadCache{} })
|
||||
var first *Workload
|
||||
if len(loads) > 0 {
|
||||
first = &loads[0]
|
||||
}
|
||||
if !cache.ready || cache.first != first || cache.n != len(loads) || cache.version != opts.Version {
|
||||
*cache = workloadSummary(loads)
|
||||
cache.first, cache.n, cache.version, cache.ready = first, len(loads), opts.Version, true
|
||||
}
|
||||
top := cache.top
|
||||
state := ui.Local(root, "workload-list", func() *ui.ListState { return &ui.ListState{} })
|
||||
st := *state
|
||||
st.Key = func(i int) any { return loads[i].Member.ID }
|
||||
num := func(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
|
||||
root.Children(func() {
|
||||
over := cache.over
|
||||
if over > 0 {
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(k.Danger).Role(ui.RoleStatus).Padding(d.Unit()).Children(func() {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14)
|
||||
ui.Text(c, core.CalendarCount(c, "workload_view.overloaded", over)).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
}
|
||||
ui.List(c, st, len(loads), func(i int) {
|
||||
l := loads[i]
|
||||
hot := l.Assigned > l.Capacity
|
||||
text := fmt.Sprintf("%s / %s %s", num(l.Assigned), num(l.Capacity), unit)
|
||||
note := ""
|
||||
if hot {
|
||||
note = fmt.Sprintf(core.Msg(c, "workload_view.over"), num(l.Assigned-l.Capacity), unit)
|
||||
}
|
||||
ui.Row(c).MinHeight(d.ControlHeight()).Gap(d.Unit() * 3).AlignItems(ui.Center).PaddingX(d.Unit()).Role(ui.RoleListItem).
|
||||
Label(l.Member.Name + ", " + text + " " + note).Children(func() {
|
||||
display.Avatar(c, l.Member.Name, display.AvatarOptions{Size: 24})
|
||||
ui.Text(c, l.Member.Name).Width(d.ControlHeight() * 4).Shrink(0).SingleLine().Ellipsis("…").TextColor(k.Text)
|
||||
ui.Box(c).Grow(1).Height(10).Draw(func(p *ui.Painter, r ui.Rect) { workloadBar(p, r, l, top, k.Border, k.AccentText, k.Danger, k.Text) })
|
||||
fg := k.TextMuted
|
||||
if hot {
|
||||
fg = k.Danger
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14).TextColor(fg)
|
||||
}
|
||||
ui.Text(c, text).Width(d.ControlHeight() * 3).Shrink(0).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(fg).FontFeatures("tnum").TextAlign(ui.End)
|
||||
})
|
||||
}).Grow(1).MinHeight(d.ControlHeight() * 3)
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
// workloadBar draws assigned work, overload hatching and capacity marks.
|
||||
func workloadBar(p *ui.Painter, r ui.Rect, l Workload, top float64, track, fill, hot, tick ui.Color) {
|
||||
x := func(v float64) float32 { return r.X + r.W*float32(v/top) }
|
||||
p.Fill(r, track, 3)
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: x(min(l.Assigned, l.Capacity)) - r.X, H: r.H}, fill, 3)
|
||||
if l.Assigned > l.Capacity {
|
||||
p.Fill(ui.Rect{X: x(l.Capacity), Y: r.Y, W: x(l.Assigned) - x(l.Capacity), H: r.H}, hot, 0)
|
||||
for sx := x(l.Capacity) + 2; sx < x(l.Assigned); sx += 5 {
|
||||
p.Line(sx, r.Y+r.H, sx+3, r.Y, 1, track)
|
||||
}
|
||||
}
|
||||
cx := x(l.Capacity)
|
||||
p.Line(cx, r.Y-3, cx, r.Y+r.H+3, 2, tick)
|
||||
}
|
||||
|
||||
type workloadCache struct {
|
||||
first *Workload
|
||||
n, version, over int
|
||||
top float64
|
||||
ready bool
|
||||
}
|
||||
|
||||
func workloadSummary(loads []Workload) workloadCache {
|
||||
out := workloadCache{}
|
||||
seen := map[string]bool{}
|
||||
for _, l := range loads {
|
||||
if l.Member.ID == "" || seen[l.Member.ID] || l.Assigned < 0 || l.Capacity <= 0 || math.IsNaN(l.Assigned) || math.IsNaN(l.Capacity) || math.IsInf(l.Assigned, 0) || math.IsInf(l.Capacity, 0) {
|
||||
panic("mujica: WorkloadView needs unique member IDs and finite non-negative loads with positive capacity")
|
||||
}
|
||||
seen[l.Member.ID] = true
|
||||
out.top = max(out.top, l.Assigned, l.Capacity)
|
||||
if l.Assigned > l.Capacity {
|
||||
out.over++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func workloadSample() []Workload {
|
||||
return []Workload{{Member: projMembers[0], Assigned: 32, Capacity: 40}, {Member: projMembers[1], Assigned: 46.5, Capacity: 40},
|
||||
{Member: projMembers[2], Assigned: 12, Capacity: 20}}
|
||||
}
|
||||
|
||||
func workloadTester(loads []Workload, opts WorkloadViewOptions) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
ui.Column(c).Height(300).Width(600).Children(func() { WorkloadView(c, loads, opts) })
|
||||
}, 600, 300)
|
||||
}
|
||||
|
||||
func TestWorkloadViewOverload(t *testing.T) {
|
||||
tt := workloadTester(workloadSample(), WorkloadViewOptions{})
|
||||
for _, s := range []string{"1 person is over capacity", "46.5 / 40 h", "Mutsumi Wakaba, 46.5 / 40 h over by 6.5 h", "Sakiko Togawa, 32 / 40 h "} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %v", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
r, _ := tt.Find("Mutsumi Wakaba, 46.5 / 40 h over by 6.5 h")
|
||||
img := tt.Image()
|
||||
found := false
|
||||
for x := int(r.X); x < int(r.X+r.W); x++ {
|
||||
if datetimeNear(img.RGBAAt(x, int(r.Y+r.H/2)), theme.Light().Danger) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("overload is not drawn in Danger")
|
||||
}
|
||||
if tt := workloadTester(workloadSample()[:1], WorkloadViewOptions{Unit: "pts"}); !tt.HasText("32 / 40 pts") || tt.HasText("over capacity") {
|
||||
t.Fatal("custom unit or false alarm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkloadViewLargeAndInvalid(t *testing.T) {
|
||||
var loads []Workload
|
||||
for i := range 100000 {
|
||||
loads = append(loads, Workload{Member: ProjectMember{ID: fmt.Sprint(i), Name: "M"}, Assigned: float64(i % 50), Capacity: 40})
|
||||
}
|
||||
if n := len(workloadTester(loads, WorkloadViewOptions{}).Texts()); n > 200 {
|
||||
t.Fatalf("built %d labels", n)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic for zero capacity")
|
||||
}
|
||||
}()
|
||||
workloadTester([]Workload{{Member: projMembers[0], Capacity: 0}}, WorkloadViewOptions{})
|
||||
}
|
||||
|
||||
func TestWorkloadViewSnapshot(t *testing.T) {
|
||||
loads := workloadSample()
|
||||
snapshot(t, "workload_view", 600, 200, func(c *ui.Context) {
|
||||
ui.Column(c).Height(200).Padding(16).Children(func() { WorkloadView(c, loads, WorkloadViewOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkloadViewRejectsInfinite(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected infinite load panic")
|
||||
}
|
||||
}()
|
||||
workloadTester([]Workload{{Member: projMembers[0], Assigned: math.Inf(1), Capacity: 40}}, WorkloadViewOptions{})
|
||||
}
|
||||
Reference in new issue
Block a user