diff --git a/assignee_picker.go b/assignee_picker.go new file mode 100644 index 0000000..0d9cc06 --- /dev/null +++ b/assignee_picker.go @@ -0,0 +1,232 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + "mujicaui/internal" + "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 *Member + n, version int + ready, open bool + query, filtered string + options, shown []SelectOption[string] + members map[string]Member + 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 []Member, opts AssigneePickerOptions) ChoiceResult { + if opts.Max < 0 { + panic("mujica: AssigneePicker Max must be non-negative") + } + d, k := Density(c), Tokens(c) + label := opts.Label + if label == "" { + label = 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 *Member + 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]Member, len(members)) + st.options = make([]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] = 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([]AvatarItem, min(3, len(*ids))) + for i := range items { + items[i] = AvatarItem{Name: st.members[(*ids)[i]].Name} + } + AvatarGroup(c, items, 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]) + } + selectField(c, trigger, st.open, func() { + options := make([]SelectOption[string], min(2, len(*ids))) + for i := range options { + options[i] = SelectOption[string]{Value: (*ids)[i], Label: st.members[(*ids)[i]].Name} + } + multiSelectTags(c, ids, options, MultiSelectOptions{MaxTags: 2, Disabled: trigger.IsDisabled(), Placeholder: msg(c, "assignee_picker.placeholder")}) + }) + ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) { + 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 := selectSearch(c, &st.query, msg(c, "multi_select.search")) + q := strings.ToLower(strings.TrimSpace(st.query)) + if st.shown == nil || st.filtered != q { + st.shown = make([]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 { + selectNote(c, 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 = multiSelectCheck(c, k, &on, mark, msg(c, "multi_select.all"), full && st.count == 0) }) + if search.Shortcut(0, ui.KeyDown) { + all.Focus() + } + if all.Changed() && !all.IsDisabled() { + *ids = 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 = 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 = 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(msg(c, "multi_select.limit"), len(*ids), opts.Max)).FontSize(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 ChoiceResult{Element: row, changed: 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 +} diff --git a/assignee_picker_test.go b/assignee_picker_test.go new file mode 100644 index 0000000..6e7b8fc --- /dev/null +++ b/assignee_picker_test.go @@ -0,0 +1,154 @@ +package mujica + +import ( + "fmt" + "slices" + "testing" + + "github.com/egoist/mygo/ui" + "mujicaui/theme" +) + +func assigneeTester(ids *[]string, opts *AssigneePickerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, 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) { + Use(c, 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([]Member, 100000) + for i := range members { + members[i] = Member{ID: fmt.Sprint(i), Name: fmt.Sprintf("Member %d", i)} + } + var ids []string + tt := ui.NewTester(func(c *ui.Context) { Use(c, 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) { + Use(c, Settings{Density: theme.Compact}) + ui.Column(c).Padding(16).Children(func() { AssigneePicker(c, &ids, []Member{{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) + } + } +} diff --git a/burndown_chart.go b/burndown_chart.go new file mode 100644 index 0000000..75a81e0 --- /dev/null +++ b/burndown_chart.go @@ -0,0 +1,216 @@ +package mujica + +import ( + "fmt" + "math" + "slices" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// BurndownChartOptions configure a BurndownChart. +type BurndownChartOptions struct { + Frame ChartFrame + // Start and End are the sprint's first and last days. + Start, End 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) ChartResult { + datetimeCheckDate("BurndownChart Start", opts.Start) + 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 := newChart(c, opts.Frame) + summary := chartMemo(ch, "burndown-domain", struct { + data string + total float64 + version int + }{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 := Tokens(c) + pal := internal.Palette(k) + size := 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 := []chartKey{{Name: msg(c, "burndown_chart.actual"), Color: pal[0], Shape: 0}, {Name: msg(c, "burndown_chart.ideal"), Color: pal[5], Shape: 1}} + geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) { + plot := 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 := chartSpec{n: n, keys: keys, toggle: 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 := 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 := 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 +} diff --git a/burndown_chart_test.go b/burndown_chart_test.go new file mode 100644 index 0000000..f9c00b8 --- /dev/null +++ b/burndown_chart_test.go @@ -0,0 +1,145 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +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) { + Use(c, 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: ChartFrame{Status: 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: 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: ChartFrame{Status: DataFailed, Error: "Unavailable"}}, &points) + if !tt.HasText("Unavailable") { + t.Fatal("missing error state") + } + tt = ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { + r := BurndownChart(c, burndownRemaining, BurndownChartOptions{Frame: 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: ChartFrame{Label: "Large burndown"}, Start: Date{Year: 1900, Month: 1, Day: 1}, End: Date{Year: 1900, Month: 1, Day: 1}.AddDays(99999), Total: 100000} + ui.NewTester(func(c *ui.Context) { Use(c, 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") + } +} diff --git a/docs/components/361-kanban-board.md b/docs/components/361-kanban-board.md new file mode 100644 index 0000000..ab3fd4e --- /dev/null +++ b/docs/components/361-kanban-board.md @@ -0,0 +1,43 @@ +# 361 KanbanBoard · 任务看板 + +## 用途 + +任务看板用于项目管理界面。 + +## 最小示例 + +```go +statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.KanbanBoard(c, &tasks, mujica.KanbanBoardOptions{Columns: []mujica.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) +``` + +## 参数 + +`tasks *[]Task` 为受控数据。`Columns` 至少一列;`Statuses` 提供其状态;`Members`、`Labels` 解析关联 ID。`Version` 标识原地数据更改;`Disabled` 禁用移动与打开;`Label` 为无障碍名称。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Moved()` 返回已应用的 `KanbanMove{ID, From, To, Index}`;`Opened()` 返回卡片 ID。 + +## 键盘操作 + +Tab 聚焦卡片;Enter/Space 打开;Alt+左右跨列;Alt+上下排序;可拖放到卡片之前或列末。 + +## 限制 + +修改原切片任务时递增 `Version`。列按 `Columns` 顺序排列;`WIP=0` 表示自由容量。网络与持久化由调用方处理。 diff --git a/docs/components/362-task-list.md b/docs/components/362-task-list.md new file mode 100644 index 0000000..b707d4f --- /dev/null +++ b/docs/components/362-task-list.md @@ -0,0 +1,44 @@ +# 362 TaskList · 任务列表 + +## 用途 + +任务列表用于项目管理界面。 + +## 最小示例 + +```go +statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}} +filter := mujica.TaskShowAll +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TaskList(c, tasks, &filter, mujica.TaskListOptions{GroupBy: mujica.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) +``` + +## 参数 + +`tasks []Task`、`filter *TaskFilter`;`GroupBy` 为无分组、状态或优先级。按状态分组必传 `Statuses`。`Members` 解析负责人;`Today` 为逾期基准;`Version`、`Disabled`、`Label` 控制数据刷新与交互。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 返回编辑任务 ID;`Opened()` 返回打开任务 ID。筛选值直接写入 `filter`。 + +## 键盘操作 + +Tab 在筛选器和任务控件间移动;空格切换完成;Enter 打开;F2 编辑标题。 + +## 限制 + +可见行虚拟化;外部原地数据修改递增 `Version`。列表需父容器提供有限高度。 diff --git a/docs/components/363-task-item.md b/docs/components/363-task-item.md new file mode 100644 index 0000000..3c7b7ba --- /dev/null +++ b/docs/components/363-task-item.md @@ -0,0 +1,42 @@ +# 363 TaskItem · 任务行 + +## 用途 + +任务行用于项目管理界面。 + +## 最小示例 + +```go +task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TaskItem(c, &task, mujica.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) +``` + +## 参数 + +`task *Task` 保存标题、完成、优先级、日期、负责人;`Members` 解析负责人;`Today` 默认当前日期;`Disabled` 禁用输入。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 返回完成或标题编辑的任务 ID;`Opened()` 返回打开任务 ID。 + +## 键盘操作 + +Tab 在复选框和标题间移动;空格切换完成;Enter 打开;F2 或双击开始编辑;Enter 保存;Escape 取消。 + +## 限制 + +任务标题保存时去除首尾空白;空标题保留编辑态。业务字段来自调用方。 diff --git a/docs/components/364-task-detail-panel.md b/docs/components/364-task-detail-panel.md new file mode 100644 index 0000000..0736810 --- /dev/null +++ b/docs/components/364-task-detail-panel.md @@ -0,0 +1,43 @@ +# 364 TaskDetailPanel · 任务详情侧栏 + +## 用途 + +任务详情侧栏用于项目管理界面。 + +## 最小示例 + +```go +statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TaskDetailPanel(c, &task, mujica.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) +``` + +## 参数 + +`task *Task` 为受控任务;`Statuses`、`Members`、`Labels` 为关联数据;`Activity func()` 构建活动记录;`Today`、`Disabled` 控制日期与交互;`Version` 用于外部原地修改任务、成员或子任务。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 表示字段更改;`Closed()` 表示关闭按钮或 Escape。由调用方控制侧栏显隐。 + +## 键盘操作 + +Tab 在字段间移动;选择框使用方向键和 Enter;Escape 返回关闭事件;子任务 Enter 新增。 + +## 限制 + +组件是可嵌入的侧栏内容;容器负责布局与滚动。活动记录通过 `Activity` 插槽构建;外部原地更改后递增 `Version`。 diff --git a/docs/components/365-subtask-list.md b/docs/components/365-subtask-list.md new file mode 100644 index 0000000..c6e656c --- /dev/null +++ b/docs/components/365-subtask-list.md @@ -0,0 +1,38 @@ +# 365 SubtaskList · 子任务列表 + +## 用途 + +子任务列表用于项目管理界面。 + +## 最小示例 + +```go +subtasks := []mujica.Subtask{{ID: "one", Title: "Review", Done: true}, {ID: "two", Title: "Ship"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SubtaskList(c, &subtasks, mujica.SubtaskListOptions{}).Changed() { + log.Println("subtasks changed", len(subtasks)) + } +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`subs *[]Subtask` 保存子任务;`Disabled` 禁用所有操作;`Label` 为进度与列表名称;`Version` 用于外部原地修改。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 表示勾选、新增、删除或排序;变更已写入切片。 + +## 键盘操作 + +Tab 移动;Space 切换完成;新增输入框 Enter 提交;Alt+上下排序;拖动握柄排序。 + +## 限制 + +新增 ID 按 `sub-N` 生成;原有 ID 必须唯一且非空。空白新增内容保留输入。列表虚拟化,最多显示八行高度。 diff --git a/docs/components/368-sprint-board.md b/docs/components/368-sprint-board.md new file mode 100644 index 0000000..b3ca3aa --- /dev/null +++ b/docs/components/368-sprint-board.md @@ -0,0 +1,44 @@ +# 368 SprintBoard · 迭代看板 + +## 用途 + +迭代看板用于项目管理界面。 + +## 最小示例 + +```go +statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}} +sprint := mujica.Sprint{Name: "Release sprint", Goal: "Ship", Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SprintBoard(c, sprint, &tasks, mujica.SprintBoardOptions{Board: mujica.KanbanBoardOptions{Columns: []mujica.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) +``` + +## 参数 + +`Sprint{Name, Goal, Start, End}`;日期必填且顺序有效。`Board` 为看板参数;`Today` 默认当前日期。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +与 `KanbanBoard` 相同;移动更新受控任务并刷新完成数。 + +## 键盘操作 + +看板卡片支持 Tab、Enter/Space、Alt+方向键与拖放。 + +## 限制 + +完成度按工作流状态统计,取消状态排除。日期为包含首尾的区间;外部原地修改递增 `Board.Version`。 diff --git a/docs/components/369-burndown-chart.md b/docs/components/369-burndown-chart.md new file mode 100644 index 0000000..241a4dd --- /dev/null +++ b/docs/components/369-burndown-chart.md @@ -0,0 +1,46 @@ +# 369 BurndownChart · 燃尽图 + +## 用途 + +燃尽图用于项目管理界面。 + +## 最小示例 + +```go +remaining := []float64{40, 32, 25, 18} +opts := mujica.BurndownChartOptions{Frame: mujica.ChartFrame{Label: "Release burndown"}, Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}, Total: 40} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.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) +``` + +## 参数 + +`Frame` 提供必填 `Label`、默认 240 DIP `Height`、`Status/Error/Empty`;`Start < End`,`Total > 0`;`Format` 自定义读数;`Version` 刷新原地修改。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 与 `Point` 返回活跃日期索引;`Toggled()` 返回图例名称;`Retried()` 请求重试。 + +## 键盘操作 + +Tab 聚焦图例与图表;Space 切换图例;方向键逐日读取;Home/End 首尾;Escape 清除读数。 + +## 限制 + +实际值以 `NaN` 表示缺失;数组从 Start 连续对应日期。数据加载与失败由调用方提供;数值须有限且非负。 diff --git a/docs/components/370-priority-indicator.md b/docs/components/370-priority-indicator.md new file mode 100644 index 0000000..76fe81e --- /dev/null +++ b/docs/components/370-priority-indicator.md @@ -0,0 +1,36 @@ +# 370 PriorityIndicator · 优先级指示 + +## 用途 + +优先级指示用于项目管理界面。 + +## 最小示例 + +```go +priority := mujica.PriorityHigh +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PriorityIndicator(c, priority, mujica.PriorityIndicatorOptions{}) +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`p Priority` 决定等级;`IconOnly` 默认 false,开启后保留无障碍名称。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +静态展示。 + +## 键盘操作 + +静态展示,通过无障碍名称读取完整等级。 + +## 限制 + +等级仅接受 None、Low、Medium、High、Urgent。 diff --git a/docs/components/371-status-select.md b/docs/components/371-status-select.md new file mode 100644 index 0000000..e8b5603 --- /dev/null +++ b/docs/components/371-status-select.md @@ -0,0 +1,39 @@ +# 371 StatusSelect · 状态选择 + +## 用途 + +状态选择用于项目管理界面。 + +## 最小示例 + +```go +statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +value := "todo" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.StatusSelect(c, &value, statuses, mujica.StatusSelectOptions{}).Changed() { + log.Println("status", value) + } +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`value *string`、`statuses []WorkflowStatus`;状态 `Group` 为 Backlog、Unstarted、Started、Completed、Canceled;`Disabled` 和 `Label` 控制交互与名称。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 表示受控状态 ID 更改。 + +## 键盘操作 + +Enter/Space 或上下键打开;上下、Home/End 定位;Enter 确认;Escape 关闭。 + +## 限制 + +状态 ID 唯一且非空;当前值须属于状态列表。状态按五个工作流阶段分组。 diff --git a/docs/components/372-assignee-picker.md b/docs/components/372-assignee-picker.md new file mode 100644 index 0000000..4fa99db --- /dev/null +++ b/docs/components/372-assignee-picker.md @@ -0,0 +1,39 @@ +# 372 AssigneePicker · 负责人选择 + +## 用途 + +负责人选择用于项目管理界面。 + +## 最小示例 + +```go +members := []mujica.Member{{ID: "ann", Name: "Ann"}, {ID: "ben", Name: "Ben"}} +ids := []string{"ann"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.AssigneePicker(c, &ids, members, mujica.AssigneePickerOptions{Max: 2}).Changed() { + log.Println("assignees", ids) + } +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`ids *[]string`、`members []Member`;`Max=0` 表示自由数量;`Disabled`、`Label` 控制交互与名称;`Version` 用于外部原地修改。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 表示受控成员 ID 列表更改。 + +## 键盘操作 + +Enter/Space 打开;输入搜索;下键进入选项;Space/Enter 勾选;Backspace 移除最后一项;Escape 关闭。 + +## 限制 + +成员 ID 唯一且非空;所有已选 ID 必须存在。头像使用姓名回退;网络成员查询由调用方提供。搜索结果按可见行虚拟化。 diff --git a/docs/components/373-due-date-picker.md b/docs/components/373-due-date-picker.md new file mode 100644 index 0000000..10e8c4b --- /dev/null +++ b/docs/components/373-due-date-picker.md @@ -0,0 +1,38 @@ +# 373 DueDatePicker · 截止日期选择 + +## 用途 + +截止日期选择用于项目管理界面。 + +## 最小示例 + +```go +var due mujica.Date +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.DueDatePicker(c, &due, mujica.DueDatePickerOptions{}).Changed() { + log.Println("due", due) + } +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`value *Date`;`Today` 默认当前日期;`WeekStart` 默认本地周起始;`Done`、`Disabled`、`Label` 控制展示与交互。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 表示日期更改;返回 `Input` 为日期编辑器。 + +## 键盘操作 + +Tab 到快捷项、日期输入与日历按钮;Enter 选择;日历方向键移动;Escape 关闭。 + +## 限制 + +零日期表示清空;Next week 选择下一个周起始日。`Done` 让已完成任务显示普通日期。 diff --git a/docs/components/374-label-manager.md b/docs/components/374-label-manager.md new file mode 100644 index 0000000..5a188f0 --- /dev/null +++ b/docs/components/374-label-manager.md @@ -0,0 +1,39 @@ +# 374 LabelManager · 标签管理 + +## 用途 + +标签管理用于项目管理界面。 + +## 最小示例 + +```go +labels := []mujica.TaskLabel{{ID: "ui", Name: "UI", Color: mujica.EventInfo}} +selected := []string{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LabelManager(c, &labels, &selected, mujica.LabelManagerOptions{}).Changed() { + log.Println("labels changed", len(labels), selected) + } +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`labels *[]TaskLabel`、`selected *[]string`;ID 非空唯一;`Color` 为六种 EventColor;`Disabled`、`Label` 控制交互与名称;`Version` 用于外部原地修改。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 表示选择、创建、重命名、颜色或删除变更。删除同步移除已选 ID。 + +## 键盘操作 + +Tab 移动;Space 勾选;新增输入 Enter 创建;编辑颜色按钮打开编辑;Enter 保存名称;Escape 结束编辑。 + +## 限制 + +名称去除首尾空白并按大小写折叠检测重复;颜色选中立即提交。列表虚拟化,最多显示八行高度。 diff --git a/docs/components/375-issue-card.md b/docs/components/375-issue-card.md new file mode 100644 index 0000000..ffa2eed --- /dev/null +++ b/docs/components/375-issue-card.md @@ -0,0 +1,39 @@ +# 375 IssueCard · 任务卡片 + +## 用途 + +任务卡片用于项目管理界面。 + +## 最小示例 + +```go +statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.IssueCard(c, task, mujica.IssueCardOptions{Statuses: statuses}).Clicked() { + log.Println("open", task.ID) + } +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`task Task`;`Statuses` 解析状态;`Members`、`Labels` 解析关联数据;`Selected` 显示选中底色与边标;`Disabled` 禁用激活。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +返回 `Element.Clicked()` 为卡片激活。 + +## 键盘操作 + +Tab 聚焦;Enter/Space 激活。 + +## 限制 + +长标题最多两行;头像组折叠到三个;字段与关联数据由调用方提供。 diff --git a/docs/components/376-issue-id-badge.md b/docs/components/376-issue-id-badge.md new file mode 100644 index 0000000..b966df7 --- /dev/null +++ b/docs/components/376-issue-id-badge.md @@ -0,0 +1,38 @@ +# 376 IssueIdBadge · 任务编号 + +## 用途 + +任务编号用于项目管理界面。 + +## 最小示例 + +```go +id := "ENG-123" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.IssueIdBadge(c, id, mujica.IssueIdBadgeOptions{Copyable: true}).Changed() { + log.Println("copied", id) + } +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`id string`;`Copyable` 默认 false;`Disabled` 禁用复制。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 表示复制,剪贴板由组件写入;同时朗读复制结果。 + +## 键盘操作 + +Copyable 开启时 Tab 聚焦,Enter/Space 复制。 + +## 限制 + +编号匹配大写项目名、连字符、正整数,例如 ENG-123。复制提示保持 1500 ms。 diff --git a/docs/components/377-milestone-progress.md b/docs/components/377-milestone-progress.md new file mode 100644 index 0000000..1f454c9 --- /dev/null +++ b/docs/components/377-milestone-progress.md @@ -0,0 +1,36 @@ +# 377 MilestoneProgress · 里程碑进度 + +## 用途 + +里程碑进度用于项目管理界面。 + +## 最小示例 + +```go +milestone := mujica.Milestone{Title: "Release", Due: mujica.Date{Year: 2026, Month: 10, Day: 14}, Done: 3, Total: 8} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MilestoneProgress(c, milestone, mujica.MilestoneProgressOptions{}) +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`Milestone{Title, Due, Done, Total}`;`Today` 默认当前日期;零 Due 表示无日期。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +静态展示。 + +## 键盘操作 + +静态展示,进度条通过无障碍名称与百分比读取。 + +## 限制 + +要求 0 ≤ Done ≤ Total;零总量显示 0%;完成后显示达成标记。 diff --git a/docs/components/378-workload-view.md b/docs/components/378-workload-view.md new file mode 100644 index 0000000..0ae8335 --- /dev/null +++ b/docs/components/378-workload-view.md @@ -0,0 +1,36 @@ +# 378 WorkloadView · 成员负载 + +## 用途 + +成员负载用于项目管理界面。 + +## 最小示例 + +```go +loads := []mujica.Workload{{Member: mujica.Member{ID: "ann", Name: "Ann"}, Assigned: 46, Capacity: 40}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.WorkloadView(c, loads, mujica.WorkloadViewOptions{}) +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`loads []Workload`;`Unit` 默认小时;`Label` 为列表名称;`Version` 刷新原地数据更改。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +静态展示。 + +## 键盘操作 + +静态行通过无障碍名称读取负载与超出量;滚动查看更多成员。 + +## 限制 + +成员 ID 唯一;Assigned 有限非负,Capacity 有限正数。可见行虚拟化;父容器需提供有限高度;原地修改递增 Version。 diff --git a/docs/components/379-time-tracker.md b/docs/components/379-time-tracker.md new file mode 100644 index 0000000..ccc53b9 --- /dev/null +++ b/docs/components/379-time-tracker.md @@ -0,0 +1,38 @@ +# 379 TimeTracker · 工时计时 + +## 用途 + +工时计时用于项目管理界面。 + +## 最小示例 + +```go +var state mujica.TimeTrackerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TimeTracker(c, &state, mujica.TimeTrackerOptions{}).Changed() { + log.Println("tracked", state.Total(c.Now())) + } +} +ui.Render(view, 1000, 700, 1) +``` + +## 参数 + +`state *TimeTrackerState`;`Now` 固定时钟供测试;`Disabled` 禁用操作;`Label` 为读数名称。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 表示开始、停止或手动修正;`Entries` 和 `Adjust` 由调用方持有。 + +## 键盘操作 + +Tab 移动;Enter/Space 开始停止;停止后打开修正,编辑时长,Apply 提交。 + +## 限制 + +只有最后一项可处于运行态;时段按顺序且互不重叠;累计时长非负。隐藏时保留计时状态。 diff --git a/docs/components/380-pomodoro-timer.md b/docs/components/380-pomodoro-timer.md new file mode 100644 index 0000000..5ab2793 --- /dev/null +++ b/docs/components/380-pomodoro-timer.md @@ -0,0 +1,42 @@ +# 380 PomodoroTimer · 番茄钟 + +## 用途 + +番茄钟用于项目管理界面。 + +## 最小示例 + +```go +var state mujica.PomodoroState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.PomodoroTimer(c, &state, mujica.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) +``` + +## 参数 + +`state *PomodoroState`;`Focus/Break/LongBreak` 默认 25/5/15 分钟;`LongEvery` 默认 4;`Now` 固定时钟;`Disabled`、`Label` 控制交互与名称。 + +## 状态 + +业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。 + +## 事件 + +`Changed()` 返回操作事件;`Finished()` 返回自然完成的阶段,调用方可播放提示。 + +## 键盘操作 + +Tab 移动;Enter/Space 开始、暂停、跳过与重置。 + +## 限制 + +时长为整秒;自然完成后等待用户启动下一阶段。隐藏组件停止请求帧,恢复显示时读取经过时间。 diff --git a/docs_test.go b/docs_test.go index f7f1d22..c7e169a 100644 --- a/docs_test.go +++ b/docs_test.go @@ -83,6 +83,9 @@ func TestDocSnippetsAreCompiledExamples(t *testing.T) { // TestDocsMatchGalleryPages wants exactly one doc and one gallery page per component ID. func TestDocsMatchGalleryPages(t *testing.T) { + if n := len(readGlob(t, "docs/components/*.md")); n != 318 { + t.Fatalf("component docs = %d, want 318", n) + } pages := map[string]int{} for _, src := range readGlob(t, "examples/gallery/pages_*.go") { for _, m := range docsPageID.FindAllStringSubmatch(src, -1) { diff --git a/due_date_picker.go b/due_date_picker.go new file mode 100644 index 0000000..ed75472 --- /dev/null +++ b/due_date_picker.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// DueDatePickerOptions configure a DueDatePicker. +type DueDatePickerOptions struct { + // Done stops a past date from showing as overdue. + Done bool + WeekStart WeekStart + // Today anchors shortcuts and overdue marks; zero uses today. + Today Date + Disabled bool + Label string +} + +// DueDatePicker combines date shortcuts, a calendar and overdue marks. +func DueDatePicker(c *ui.Context, value *Date, opts DueDatePickerOptions) DateTimeResult { + datetimeCheckDate("DueDatePicker value", *value) + k := Tokens(c) + d := Density(c) + today := datetimeToday(c, opts.Today) + first := calendarFirstWeekday(c, opts.WeekStart) + nextWeek := today.AddDays(7 - (int(today.Weekday())-int(first)+7)%7) + label := opts.Label + if label == "" { + label = 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 DateTimeResult + col.Children(func() { + ui.Row(c).Gap(d.Unit()).Wrap().Children(func() { + for _, q := range []struct { + key string + day Date + }{{"due_date_picker.today", today}, {"due_date_picker.tomorrow", today.AddDays(1)}, {"due_date_picker.next_week", nextWeek}} { + variant := Secondary + text := msg(c, q.key) + if *value == q.day { + variant = Outline + text = "✓ " + text + } + b := Button(c, text, ButtonOptions{Variant: variant}).Label(msg(c, q.key)) + if *value == q.day { + b.Background(k.Selection).TextColor(k.Text) + } + if b.Clicked() && !b.IsDisabled() { + *value = q.day + } + } + }) + r = DatePicker(c, value, 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(FontSize(c, theme.CaptionSize)) + }) + } + }) + changed := *value != old + if changed { + slog.Debug("mujica: due_date_picker change", "from", old, "to", *value) + } + return DateTimeResult{Element: col, Input: r.Input, changed: changed} +} diff --git a/due_date_picker_test.go b/due_date_picker_test.go new file mode 100644 index 0000000..d2eb2a2 --- /dev/null +++ b/due_date_picker_test.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func dueTester(v *Date, opts *DueDatePickerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, 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 := 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) + } +} diff --git a/example_projects_test.go b/example_projects_test.go new file mode 100644 index 0000000..93d95d8 --- /dev/null +++ b/example_projects_test.go @@ -0,0 +1,242 @@ +package mujica_test + +import ( + "github.com/egoist/mygo/ui" + "log" + mujica "mujicaui" + "time" +) + +func ExampleKanbanBoard() { + statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} + tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.KanbanBoard(c, &tasks, mujica.KanbanBoardOptions{Columns: []mujica.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 := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} + tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}} + filter := mujica.TaskShowAll + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TaskList(c, tasks, &filter, mujica.TaskListOptions{GroupBy: mujica.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 := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TaskItem(c, &task, mujica.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 := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} + task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TaskDetailPanel(c, &task, mujica.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 := []mujica.Subtask{{ID: "one", Title: "Review", Done: true}, {ID: "two", Title: "Ship"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SubtaskList(c, &subtasks, mujica.SubtaskListOptions{}).Changed() { + log.Println("subtasks changed", len(subtasks)) + } + } + ui.Render(view, 1000, 700, 1) +} + +func ExampleSprintBoard() { + statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} + tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}} + sprint := mujica.Sprint{Name: "Release sprint", Goal: "Ship", Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SprintBoard(c, sprint, &tasks, mujica.SprintBoardOptions{Board: mujica.KanbanBoardOptions{Columns: []mujica.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 := mujica.BurndownChartOptions{Frame: mujica.ChartFrame{Label: "Release burndown"}, Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}, Total: 40} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.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 := mujica.PriorityHigh + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PriorityIndicator(c, priority, mujica.PriorityIndicatorOptions{}) + } + ui.Render(view, 1000, 700, 1) +} + +func ExampleStatusSelect() { + statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} + value := "todo" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.StatusSelect(c, &value, statuses, mujica.StatusSelectOptions{}).Changed() { + log.Println("status", value) + } + } + ui.Render(view, 1000, 700, 1) +} + +func ExampleAssigneePicker() { + members := []mujica.Member{{ID: "ann", Name: "Ann"}, {ID: "ben", Name: "Ben"}} + ids := []string{"ann"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.AssigneePicker(c, &ids, members, mujica.AssigneePickerOptions{Max: 2}).Changed() { + log.Println("assignees", ids) + } + } + ui.Render(view, 1000, 700, 1) +} + +func ExampleDueDatePicker() { + var due mujica.Date + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.DueDatePicker(c, &due, mujica.DueDatePickerOptions{}).Changed() { + log.Println("due", due) + } + } + ui.Render(view, 1000, 700, 1) +} + +func ExampleLabelManager() { + labels := []mujica.TaskLabel{{ID: "ui", Name: "UI", Color: mujica.EventInfo}} + selected := []string{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LabelManager(c, &labels, &selected, mujica.LabelManagerOptions{}).Changed() { + log.Println("labels changed", len(labels), selected) + } + } + ui.Render(view, 1000, 700, 1) +} + +func ExampleIssueCard() { + statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} + task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.IssueCard(c, task, mujica.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) { + mujica.Use(c, mujica.Settings{}) + if mujica.IssueIdBadge(c, id, mujica.IssueIdBadgeOptions{Copyable: true}).Changed() { + log.Println("copied", id) + } + } + ui.Render(view, 1000, 700, 1) +} + +func ExampleMilestoneProgress() { + milestone := mujica.Milestone{Title: "Release", Due: mujica.Date{Year: 2026, Month: 10, Day: 14}, Done: 3, Total: 8} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MilestoneProgress(c, milestone, mujica.MilestoneProgressOptions{}) + } + ui.Render(view, 1000, 700, 1) +} + +func ExampleWorkloadView() { + loads := []mujica.Workload{{Member: mujica.Member{ID: "ann", Name: "Ann"}, Assigned: 46, Capacity: 40}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.WorkloadView(c, loads, mujica.WorkloadViewOptions{}) + } + ui.Render(view, 1000, 700, 1) +} + +func ExampleTimeTracker() { + var state mujica.TimeTrackerState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TimeTracker(c, &state, mujica.TimeTrackerOptions{}).Changed() { + log.Println("tracked", state.Total(c.Now())) + } + } + ui.Render(view, 1000, 700, 1) +} + +func ExamplePomodoroTimer() { + var state mujica.PomodoroState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.PomodoroTimer(c, &state, mujica.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) +} diff --git a/examples/gallery/pages_projects.go b/examples/gallery/pages_projects.go index 386b9c2..bdbae6a 100644 --- a/examples/gallery/pages_projects.go +++ b/examples/gallery/pages_projects.go @@ -1,3 +1,82 @@ package main -var projectsPages = []page{} +const projectsCategory = "Projects" + +var projectsPages = append(append(projectsTaskPages, projectsFieldPages...), projectsTimePages...) + +var projectsTaskPages = []page{ + {ID: "361", Name: "KanbanBoard", ZhName: "任务看板", Category: projectsCategory, Demo: projectsDemoKanbanBoard, Keys: "Tab 聚焦卡片;Enter/Space 打开;Alt+左右跨列;Alt+上下排序;可拖放到卡片之前或列末。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.KanbanBoard(c, &tasks, mujica.KanbanBoardOptions{Columns: []mujica.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)`}, + {ID: "362", Name: "TaskList", ZhName: "任务列表", Category: projectsCategory, Demo: projectsDemoTaskList, Keys: "Tab 在筛选器和任务控件间移动;空格切换完成;Enter 打开;F2 编辑标题。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}} +filter := mujica.TaskShowAll +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TaskList(c, tasks, &filter, mujica.TaskListOptions{GroupBy: mujica.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)`}, + {ID: "363", Name: "TaskItem", ZhName: "任务行", Category: projectsCategory, Demo: projectsDemoTaskItem, Keys: "Tab 在复选框和标题间移动;空格切换完成;Enter 打开;F2 或双击开始编辑;Enter 保存;Escape 取消。", Code: `task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TaskItem(c, &task, mujica.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)`}, + {ID: "364", Name: "TaskDetailPanel", ZhName: "任务详情侧栏", Category: projectsCategory, Demo: projectsDemoTaskDetailPanel, Keys: "Tab 在字段间移动;选择框使用方向键和 Enter;Escape 返回关闭事件;子任务 Enter 新增。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TaskDetailPanel(c, &task, mujica.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)`}, + {ID: "365", Name: "SubtaskList", ZhName: "子任务列表", Category: projectsCategory, Demo: projectsDemoSubtaskList, Keys: "Tab 移动;Space 切换完成;新增输入框 Enter 提交;Alt+上下排序;拖动握柄排序。", Code: `subtasks := []mujica.Subtask{{ID: "one", Title: "Review", Done: true}, {ID: "two", Title: "Ship"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SubtaskList(c, &subtasks, mujica.SubtaskListOptions{}).Changed() { + log.Println("subtasks changed", len(subtasks)) + } +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "368", Name: "SprintBoard", ZhName: "迭代看板", Category: projectsCategory, Demo: projectsDemoSprintBoard, Keys: "看板卡片支持 Tab、Enter/Space、Alt+方向键与拖放。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}} +sprint := mujica.Sprint{Name: "Release sprint", Goal: "Ship", Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SprintBoard(c, sprint, &tasks, mujica.SprintBoardOptions{Board: mujica.KanbanBoardOptions{Columns: []mujica.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)`}, +} diff --git a/examples/gallery/pages_projects_demos.go b/examples/gallery/pages_projects_demos.go new file mode 100644 index 0000000..357e967 --- /dev/null +++ b/examples/gallery/pages_projects_demos.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + "slices" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +var projectsStatuses = []mujica.WorkflowStatus{ + {ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, + {ID: "doing", Name: "In progress", Group: mujica.StatusStarted}, + {ID: "done", Name: "Done", Group: mujica.StatusCompleted}, +} +var projectsMembers = []mujica.Member{{ID: "ann", Name: "Ann"}, {ID: "ben", Name: "Ben"}, {ID: "cy", Name: "Cy"}} +var projectsLabels = []mujica.TaskLabel{{ID: "ui", Name: "UI", Color: mujica.EventInfo}, {ID: "release", Name: "Release", Color: mujica.EventSuccess}} + +func projectsCheck(c *ui.Context, log func(string), value *bool, label string) { + if mujica.Checkbox(c, value, label, mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("%s = %v", label, *value)) + } +} + +func projectsChoice[T comparable](c *ui.Context, log func(string), value *T, options []mujica.SelectOption[T], label string) { + if mujica.Select(c, value, options, mujica.SelectOptions{Label: label}).Changed() { + log(fmt.Sprintf("%s = %v", label, *value)) + } +} + +type projectsTaskState struct { + tasks []mujica.Task + disabled bool + version int + group mujica.TaskGrouping + filter mujica.TaskFilter + wip float64 + closed bool +} + +func projectsTasks(c *ui.Context, key string) *projectsTaskState { + return *ui.Local(c.Root(), key, func() *projectsTaskState { + return &projectsTaskState{wip: 2, tasks: []mujica.Task{ + {ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh, Due: mujica.Date{Year: 2026, Month: 10, Day: 3}, Assignees: []string{"ann"}, Labels: []string{"ui"}, Subtasks: []mujica.Subtask{{ID: "one", Title: "Review", Done: true}, {ID: "two", Title: "Ship"}}}, + {ID: "ENG-2", Title: "Check keyboard paths", Status: "doing", Priority: mujica.PriorityUrgent, Assignees: []string{"ben"}, Labels: []string{"release"}}, + {ID: "ENG-3", Title: "Publish docs", Status: "done", Done: true, Priority: mujica.PriorityLow}, + }} + }) +} + +func projectsBoardOptions(s *projectsTaskState) mujica.KanbanBoardOptions { + return mujica.KanbanBoardOptions{Columns: []mujica.KanbanColumn{{Status: "todo"}, {Status: "doing", WIP: int(s.wip)}, {Status: "done"}}, Statuses: projectsStatuses, Members: projectsMembers, Labels: projectsLabels, Version: s.version, Disabled: s.disabled} +} + +func projectsLogBoard(s *projectsTaskState, r mujica.KanbanResult, log func(string)) { + if move, ok := r.Moved(); ok { + s.version++ + log(fmt.Sprintf("move %s: %s -> %s at %d", move.ID, move.From, move.To, move.Index)) + } + if id, ok := r.Opened(); ok { + log("open " + id) + } +} + +func projectsDemoKanbanBoard(c *ui.Context, log func(string)) { + s := projectsTasks(c, "361") + projectsCheck(c, log, &s.disabled, "Disabled") + if mujica.NumberInput(c, &s.wip, mujica.NumberInputOptions{Min: 0, Max: 10, Step: 1, Label: "WIP limit"}).Changed() { + log(fmt.Sprintf("WIP = %g", s.wip)) + } + ui.ScrollHorizontal(c).Height(420).FillWidth().Children(func() { + ui.Box(c).Width(880).Height(420).Children(func() { projectsLogBoard(s, mujica.KanbanBoard(c, &s.tasks, projectsBoardOptions(s)), log) }) + }) +} + +func projectsDemoTaskList(c *ui.Context, log func(string)) { + s := projectsTasks(c, "362") + projectsCheck(c, log, &s.disabled, "Disabled") + projectsChoice(c, log, &s.group, []mujica.SelectOption[mujica.TaskGrouping]{{Value: mujica.TaskGroupNone, Label: "Ungrouped"}, {Value: mujica.TaskGroupStatus, Label: "By status"}, {Value: mujica.TaskGroupPriority, Label: "By priority"}}, "Grouping") + ui.Box(c).Height(360).FillWidth().Children(func() { + before := s.filter + r := mujica.TaskList(c, s.tasks, &s.filter, mujica.TaskListOptions{Statuses: projectsStatuses, Members: projectsMembers, GroupBy: s.group, Version: s.version, Disabled: s.disabled}) + if s.filter != before { + log(fmt.Sprintf("filter = %d", s.filter)) + } + if id, ok := r.Changed(); ok { + s.version++ + log("edit " + id) + } + if id, ok := r.Opened(); ok { + log("open " + id) + } + }) +} + +func projectsDemoTaskItem(c *ui.Context, log func(string)) { + s := projectsTasks(c, "363") + projectsCheck(c, log, &s.disabled, "Disabled") + projectsChoice(c, log, &s.tasks[0].Priority, []mujica.SelectOption[mujica.Priority]{{Value: mujica.PriorityLow, Label: "Low"}, {Value: mujica.PriorityHigh, Label: "High"}, {Value: mujica.PriorityUrgent, Label: "Urgent"}}, "Priority") + r := mujica.TaskItem(c, &s.tasks[0], mujica.TaskItemOptions{Members: projectsMembers, Disabled: s.disabled}) + if id, ok := r.Changed(); ok { + log("edit " + id) + } + if id, ok := r.Opened(); ok { + log("open " + id) + } +} + +func projectsDemoTaskDetailPanel(c *ui.Context, log func(string)) { + s := projectsTasks(c, "364") + projectsCheck(c, log, &s.disabled, "Disabled") + if s.closed { + if mujica.Button(c, "Open details", mujica.ButtonOptions{}).Clicked() { + s.closed = false + log("open details") + } + return + } + ui.Scroll(c).Height(620).FillWidth().Children(func() { + ui.Box(c).MaxWidth(500).FillWidth().Children(func() { + r := mujica.TaskDetailPanel(c, &s.tasks[0], mujica.TaskDetailPanelOptions{Statuses: projectsStatuses, Members: projectsMembers, Labels: projectsLabels, Disabled: s.disabled, Activity: func() { ui.Text(c, "Ann created this task") }}) + if r.Changed() { + log("task fields changed") + } + if r.Closed() { + s.closed = true + log("close details") + } + }) + }) +} + +func projectsDemoSubtaskList(c *ui.Context, log func(string)) { + s := projectsTasks(c, "365") + projectsCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Clear subtasks", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() { + s.tasks[0].Subtasks = nil + log("clear subtasks") + } + if mujica.SubtaskList(c, &s.tasks[0].Subtasks, mujica.SubtaskListOptions{Disabled: s.disabled}).Changed() { + log("subtasks changed") + } +} + +func projectsDemoSprintBoard(c *ui.Context, log func(string)) { + s := projectsTasks(c, "368") + projectsCheck(c, log, &s.disabled, "Disabled") + sprint := mujica.Sprint{Name: "Release sprint", Goal: "Ship the first release", Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}} + ui.ScrollHorizontal(c).Height(540).FillWidth().Children(func() { + ui.Box(c).Width(880).Height(540).Children(func() { + projectsLogBoard(s, mujica.SprintBoard(c, sprint, &s.tasks, mujica.SprintBoardOptions{Board: projectsBoardOptions(s)}), log) + }) + }) +} + +func projectsDemoLabelManager(c *ui.Context, log func(string)) { + s := *ui.Local(c.Root(), "374", func() *struct { + labels []mujica.TaskLabel + selected []string + disabled bool + } { + return &struct { + labels []mujica.TaskLabel + selected []string + disabled bool + }{labels: slices.Clone(projectsLabels)} + }) + projectsCheck(c, log, &s.disabled, "Disabled") + if mujica.LabelManager(c, &s.labels, &s.selected, mujica.LabelManagerOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("labels changed: %d, selected %v", len(s.labels), s.selected)) + } +} diff --git a/examples/gallery/pages_projects_fields.go b/examples/gallery/pages_projects_fields.go new file mode 100644 index 0000000..8c2e889 --- /dev/null +++ b/examples/gallery/pages_projects_fields.go @@ -0,0 +1,61 @@ +package main + +var projectsFieldPages = []page{ + {ID: "369", Name: "BurndownChart", ZhName: "燃尽图", Category: projectsCategory, Demo: projectsDemoBurndownChart, Keys: "Tab 聚焦图例与图表;Space 切换图例;方向键逐日读取;Home/End 首尾;Escape 清除读数。", Code: `remaining := []float64{40, 32, 25, 18} +opts := mujica.BurndownChartOptions{Frame: mujica.ChartFrame{Label: "Release burndown"}, Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}, Total: 40} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.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)`}, + {ID: "370", Name: "PriorityIndicator", ZhName: "优先级指示", Category: projectsCategory, Demo: projectsDemoPriorityIndicator, Keys: "静态展示,通过无障碍名称读取完整等级。", Code: `priority := mujica.PriorityHigh +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PriorityIndicator(c, priority, mujica.PriorityIndicatorOptions{}) +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "371", Name: "StatusSelect", ZhName: "状态选择", Category: projectsCategory, Demo: projectsDemoStatusSelect, Keys: "Enter/Space 或上下键打开;上下、Home/End 定位;Enter 确认;Escape 关闭。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +value := "todo" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.StatusSelect(c, &value, statuses, mujica.StatusSelectOptions{}).Changed() { + log.Println("status", value) + } +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "372", Name: "AssigneePicker", ZhName: "负责人选择", Category: projectsCategory, Demo: projectsDemoAssigneePicker, Keys: "Enter/Space 打开;输入搜索;下键进入选项;Space/Enter 勾选;Backspace 移除最后一项;Escape 关闭。", Code: `members := []mujica.Member{{ID: "ann", Name: "Ann"}, {ID: "ben", Name: "Ben"}} +ids := []string{"ann"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.AssigneePicker(c, &ids, members, mujica.AssigneePickerOptions{Max: 2}).Changed() { + log.Println("assignees", ids) + } +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "373", Name: "DueDatePicker", ZhName: "截止日期选择", Category: projectsCategory, Demo: projectsDemoDueDatePicker, Keys: "Tab 到快捷项、日期输入与日历按钮;Enter 选择;日历方向键移动;Escape 关闭。", Code: `var due mujica.Date +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.DueDatePicker(c, &due, mujica.DueDatePickerOptions{}).Changed() { + log.Println("due", due) + } +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "374", Name: "LabelManager", ZhName: "标签管理", Category: projectsCategory, Demo: projectsDemoLabelManager, Keys: "Tab 移动;Space 勾选;新增输入 Enter 创建;编辑颜色按钮打开编辑;Enter 保存名称;Escape 结束编辑。", Code: `labels := []mujica.TaskLabel{{ID: "ui", Name: "UI", Color: mujica.EventInfo}} +selected := []string{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LabelManager(c, &labels, &selected, mujica.LabelManagerOptions{}).Changed() { + log.Println("labels changed", len(labels), selected) + } +} +ui.Render(view, 1000, 700, 1)`}, +} diff --git a/examples/gallery/pages_projects_fields_demos.go b/examples/gallery/pages_projects_fields_demos.go new file mode 100644 index 0000000..0351b5b --- /dev/null +++ b/examples/gallery/pages_projects_fields_demos.go @@ -0,0 +1,142 @@ +package main + +import ( + "fmt" + "github.com/egoist/mygo/ui" + "math" + mujica "mujicaui" +) + +func projectsDemoBurndownChart(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "369", func() struct { + disabled bool + status mujica.DataStatus + total float64 + values []float64 + } { + return struct { + disabled bool + status mujica.DataStatus + total float64 + values []float64 + }{total: 40, values: []float64{40, 38, 32, 34, 26, math.NaN(), 20, 18}} + }) + projectsCheck(c, log, &s.disabled, "Disabled") + projectsChoice(c, log, &s.status, []mujica.SelectOption[mujica.DataStatus]{{Value: mujica.DataReady, Label: "Ready"}, {Value: mujica.DataLoading, Label: "Loading"}, {Value: mujica.DataFailed, Label: "Error"}}, "Data state") + if mujica.NumberInput(c, &s.total, mujica.NumberInputOptions{Min: 1, Max: 100, Step: 1, Label: "Total work"}).Changed() { + log(fmt.Sprintf("total = %g", s.total)) + } + ui.Box(c).Disabled(s.disabled).FillWidth().Children(func() { + r := mujica.BurndownChart(c, s.values, mujica.BurndownChartOptions{Frame: mujica.ChartFrame{Label: "Release burndown", Status: s.status, Error: "Data unavailable"}, Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}, Total: s.total}) + if r.Changed() { + log(fmt.Sprintf("point = %d", r.Point)) + } + if name, ok := r.Toggled(); ok { + log("toggle " + name) + } + if r.Retried() { + s.status = mujica.DataReady + log("retry") + } + }) +} + +func projectsDemoPriorityIndicator(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "370", func() struct { + priority mujica.Priority + iconOnly bool + } { + return struct { + priority mujica.Priority + iconOnly bool + }{priority: mujica.PriorityHigh} + }) + projectsCheck(c, log, &s.iconOnly, "Icon only") + projectsChoice(c, log, &s.priority, []mujica.SelectOption[mujica.Priority]{{Value: mujica.PriorityNone, Label: "None"}, {Value: mujica.PriorityLow, Label: "Low"}, {Value: mujica.PriorityMedium, Label: "Medium"}, {Value: mujica.PriorityHigh, Label: "High"}, {Value: mujica.PriorityUrgent, Label: "Urgent"}}, "Priority") + mujica.PriorityIndicator(c, s.priority, mujica.PriorityIndicatorOptions{IconOnly: s.iconOnly}) +} + +func projectsDemoStatusSelect(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "371", func() struct { + value string + disabled bool + } { + return struct { + value string + disabled bool + }{value: "todo"} + }) + projectsCheck(c, log, &s.disabled, "Disabled") + if mujica.StatusSelect(c, &s.value, projectsStatuses, mujica.StatusSelectOptions{Disabled: s.disabled}).Changed() { + log("status " + s.value) + } +} + +func projectsDemoAssigneePicker(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "372", func() struct { + ids []string + disabled bool + max float64 + } { + return struct { + ids []string + disabled bool + max float64 + }{ids: []string{"ann"}, max: 2} + }) + projectsCheck(c, log, &s.disabled, "Disabled") + if mujica.NumberInput(c, &s.max, mujica.NumberInputOptions{Min: 0, Max: 3, Step: 1, Label: "Selection limit"}).Changed() { + log(fmt.Sprintf("limit = %g", s.max)) + } + if mujica.AssigneePicker(c, &s.ids, projectsMembers, mujica.AssigneePickerOptions{Max: int(s.max), Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("assignees %v", s.ids)) + } +} + +func projectsDemoDueDatePicker(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "373", func() struct { + due mujica.Date + disabled, done bool + } { + return struct { + due mujica.Date + disabled, done bool + }{due: mujica.Date{Year: 2026, Month: 10, Day: 3}} + }) + projectsCheck(c, log, &s.disabled, "Disabled") + projectsCheck(c, log, &s.done, "Completed") + if mujica.DueDatePicker(c, &s.due, mujica.DueDatePickerOptions{Done: s.done, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("due %v", s.due)) + } +} + +func projectsDemoIssueCard(c *ui.Context, log func(string)) { + s := projectsTasks(c, "375") + selected := ui.Local(c.Root(), "375-selected", func() bool { return false }) + projectsCheck(c, log, &s.disabled, "Disabled") + projectsCheck(c, log, selected, "Selected") + ui.Box(c).Width(300).Children(func() { + if mujica.IssueCard(c, s.tasks[0], mujica.IssueCardOptions{Statuses: projectsStatuses, Members: projectsMembers, Labels: projectsLabels, Selected: *selected, Disabled: s.disabled}).Clicked() { + *selected = !*selected + log("open " + s.tasks[0].ID) + } + }) +} + +func projectsDemoIssueIdBadge(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "376", func() struct { + disabled, copyable bool + id string + } { + return struct { + disabled, copyable bool + id string + }{copyable: true, id: "ENG-123"} + }) + projectsCheck(c, log, &s.disabled, "Disabled") + projectsCheck(c, log, &s.copyable, "Copyable") + projectsChoice(c, log, &s.id, []mujica.SelectOption[string]{{Value: "ENG-123", Label: "ENG-123"}, {Value: "OPS-7", Label: "OPS-7"}}, "Issue key") + if mujica.IssueIdBadge(c, s.id, mujica.IssueIdBadgeOptions{Copyable: s.copyable, Disabled: s.disabled}).Changed() { + log("copied " + s.id) + } +} diff --git a/examples/gallery/pages_projects_test.go b/examples/gallery/pages_projects_test.go new file mode 100644 index 0000000..8f022b5 --- /dev/null +++ b/examples/gallery/pages_projects_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +func projectsGalleryTester(t *testing.T, id string) (*ui.Tester, *[]string) { + t.Helper() + var events []string + for _, p := range projectsPages { + if p.ID == id { + tt := ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Fill().Gap(12).Padding(16).Children(func() { p.Demo(c, func(s string) { events = append(events, s) }) }) + }, 1200, 1000) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + return tt, &events + } + } + t.Fatal("unknown project page " + id) + return nil, nil +} + +func TestProjectsGalleryCatalog(t *testing.T) { + if len(projectsPages) != 18 { + t.Fatalf("pages = %d", len(projectsPages)) + } + seen := map[string]bool{} + for _, p := range projectsPages { + if p.Category != "Projects" || p.Keys == "" || p.Code == "" || seen[p.ID] { + t.Fatalf("invalid page %s", p.ID) + } + seen[p.ID] = true + projectsGalleryTester(t, p.ID) + } + if seen["366"] || seen["367"] { + t.Fatal("dropped components have gallery pages") + } +} + +func TestProjectsGalleryEvents(t *testing.T) { + for _, tc := range []struct{ id, action, event string }{ + {"361", "ENG-1 Plan the release", "open ENG-1"}, + {"362", "Done 1", "filter = 2"}, + {"363", "Plan the release", "open ENG-1"}, + {"364", "Close details", "close details"}, + {"365", "Clear subtasks", "clear subtasks"}, + {"368", "ENG-1 Plan the release", "open ENG-1"}, + {"369", "Ideal", "toggle Ideal"}, + {"370", "Icon only", "Icon only = true"}, + {"371", "Status", ""}, + {"372", "Disabled", "Disabled = true"}, + {"373", "Today", "due "}, + {"374", "UI", "labels changed:"}, + {"375", "ENG-1 Plan the release", "open ENG-1"}, + {"376", "Copy ENG-123", "copied ENG-123"}, + {"377", "Completed tasks", ""}, + {"378", "Ben assigned hours", ""}, + {"379", "Start", "tracked "}, + {"380", "Start", "timer phase = 0, running = true"}, + } { + t.Run(tc.id, func(t *testing.T) { + tt, events := projectsGalleryTester(t, tc.id) + if err := tt.Click(tc.action); err != nil { + t.Fatal(err) + } + switch tc.id { + case "371": + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyEnter) + tc.event = "status done" + case "377", "378": + tt.Command("selectAll") + tt.Type("5") + tt.Key(0, ui.KeyEnter) + if tc.id == "377" { + tc.event = "completed = 5" + } else { + tc.event = "assigned = 5" + } + } + found := false + for _, e := range *events { + if strings.HasPrefix(e, tc.event) { + found = true + } + } + if !found { + t.Fatalf("event %q missing in %v", tc.event, *events) + } + }) + } +} diff --git a/examples/gallery/pages_projects_time.go b/examples/gallery/pages_projects_time.go new file mode 100644 index 0000000..447455b --- /dev/null +++ b/examples/gallery/pages_projects_time.go @@ -0,0 +1,53 @@ +package main + +var projectsTimePages = []page{ + {ID: "375", Name: "IssueCard", ZhName: "任务卡片", Category: projectsCategory, Demo: projectsDemoIssueCard, Keys: "Tab 聚焦;Enter/Space 激活。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}} +task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.IssueCard(c, task, mujica.IssueCardOptions{Statuses: statuses}).Clicked() { + log.Println("open", task.ID) + } +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "376", Name: "IssueIdBadge", ZhName: "任务编号", Category: projectsCategory, Demo: projectsDemoIssueIdBadge, Keys: "Copyable 开启时 Tab 聚焦,Enter/Space 复制。", Code: `id := "ENG-123" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.IssueIdBadge(c, id, mujica.IssueIdBadgeOptions{Copyable: true}).Changed() { + log.Println("copied", id) + } +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "377", Name: "MilestoneProgress", ZhName: "里程碑进度", Category: projectsCategory, Demo: projectsDemoMilestoneProgress, Keys: "静态展示,进度条通过无障碍名称与百分比读取。", Code: `milestone := mujica.Milestone{Title: "Release", Due: mujica.Date{Year: 2026, Month: 10, Day: 14}, Done: 3, Total: 8} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MilestoneProgress(c, milestone, mujica.MilestoneProgressOptions{}) +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "378", Name: "WorkloadView", ZhName: "成员负载", Category: projectsCategory, Demo: projectsDemoWorkloadView, Keys: "静态行通过无障碍名称读取负载与超出量;滚动查看更多成员。", Code: `loads := []mujica.Workload{{Member: mujica.Member{ID: "ann", Name: "Ann"}, Assigned: 46, Capacity: 40}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.WorkloadView(c, loads, mujica.WorkloadViewOptions{}) +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "379", Name: "TimeTracker", ZhName: "工时计时", Category: projectsCategory, Demo: projectsDemoTimeTracker, Keys: "Tab 移动;Enter/Space 开始停止;停止后打开修正,编辑时长,Apply 提交。", Code: `var state mujica.TimeTrackerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TimeTracker(c, &state, mujica.TimeTrackerOptions{}).Changed() { + log.Println("tracked", state.Total(c.Now())) + } +} +ui.Render(view, 1000, 700, 1)`}, + {ID: "380", Name: "PomodoroTimer", ZhName: "番茄钟", Category: projectsCategory, Demo: projectsDemoPomodoroTimer, Keys: "Tab 移动;Enter/Space 开始、暂停、跳过与重置。", Code: `var state mujica.PomodoroState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.PomodoroTimer(c, &state, mujica.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)`}, +} diff --git a/examples/gallery/pages_projects_time_demos.go b/examples/gallery/pages_projects_time_demos.go new file mode 100644 index 0000000..803394a --- /dev/null +++ b/examples/gallery/pages_projects_time_demos.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "github.com/egoist/mygo/ui" + mujica "mujicaui" + "time" +) + +func projectsDemoMilestoneProgress(c *ui.Context, log func(string)) { + done := ui.Local(c.Root(), "377", func() float64 { return 3 }) + if mujica.NumberInput(c, done, mujica.NumberInputOptions{Min: 0, Max: 8, Step: 1, Label: "Completed tasks"}).Changed() { + log(fmt.Sprintf("completed = %g", *done)) + } + mujica.MilestoneProgress(c, mujica.Milestone{Title: "Release", Due: mujica.Date{Year: 2026, Month: 10, Day: 14}, Done: int(*done), Total: 8}, mujica.MilestoneProgressOptions{}) +} + +func projectsDemoWorkloadView(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "378", func() struct { + assigned float64 + loads []mujica.Workload + version int + } { + return struct { + assigned float64 + loads []mujica.Workload + version int + }{assigned: 46, loads: []mujica.Workload{{Member: projectsMembers[0], Assigned: 32, Capacity: 40}, {Member: projectsMembers[1], Assigned: 46, Capacity: 40}, {Member: projectsMembers[2], Assigned: 12, Capacity: 20}}} + }) + if mujica.NumberInput(c, &s.assigned, mujica.NumberInputOptions{Min: 0, Max: 80, Step: 1, Label: "Ben assigned hours"}).Changed() { + s.loads[1].Assigned = s.assigned + s.version++ + log(fmt.Sprintf("assigned = %g", s.assigned)) + } + ui.Box(c).Height(240).FillWidth().Children(func() { mujica.WorkloadView(c, s.loads, mujica.WorkloadViewOptions{Version: s.version}) }) +} + +func projectsDemoTimeTracker(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "379", func() struct { + timer mujica.TimeTrackerState + disabled bool + } { + return struct { + timer mujica.TimeTrackerState + disabled bool + }{} + }) + projectsCheck(c, log, &s.disabled, "Disabled") + ui.ScrollHorizontal(c).Height(120).FillWidth().Children(func() { + if mujica.TimeTracker(c, &s.timer, mujica.TimeTrackerOptions{Disabled: s.disabled}).Changed() { + log("tracked " + s.timer.Total(c.Now()).String()) + } + }) +} + +func projectsDemoPomodoroTimer(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "380", func() struct { + timer mujica.PomodoroState + disabled, hidden bool + seconds float64 + } { + return struct { + timer mujica.PomodoroState + disabled, hidden bool + seconds float64 + }{seconds: 10} + }) + projectsCheck(c, log, &s.disabled, "Disabled") + projectsCheck(c, log, &s.hidden, "Hidden") + if mujica.NumberInput(c, &s.seconds, mujica.NumberInputOptions{Min: 1, Max: 1500, Step: 1, Label: "Focus seconds", Disabled: s.timer.Running()}).Changed() { + log(fmt.Sprintf("focus = %g seconds", s.seconds)) + } + if s.hidden { + return + } + r := mujica.PomodoroTimer(c, &s.timer, mujica.PomodoroTimerOptions{Focus: time.Duration(s.seconds) * time.Second, Break: 5 * time.Second, LongBreak: 10 * time.Second, LongEvery: 2, Disabled: s.disabled}) + if r.Changed() { + log(fmt.Sprintf("timer phase = %d, running = %v", s.timer.Phase(), s.timer.Running())) + } + if phase, ok := r.Finished(); ok { + log(fmt.Sprintf("finished phase %d", phase)) + } +} diff --git a/issue_card.go b/issue_card.go new file mode 100644 index 0000000..26a9d1f --- /dev/null +++ b/issue_card.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "fmt" + "github.com/egoist/mygo/ui" + "log/slog" + + "mujicaui/internal" + "mujicaui/theme" +) + +// IssueCardOptions configure an IssueCard. +type IssueCardOptions struct { + Statuses []WorkflowStatus + Members []Member + 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 := Tokens(c) + d := Density(c) + small := 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(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(colorFade).Draw(func(p *ui.Painter, r ui.Rect) { + if selected { + 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([]AvatarItem, len(task.Assignees)) + for i, id := range task.Assignees { + items[i] = AvatarItem{Name: projectMember(opts.Members, id).Name} + } + AvatarGroup(c, items, 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 := Tokens(c) + d := Density(c) + col := eventColor(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(FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine() + }) +} diff --git a/issue_card_test.go b/issue_card_test.go new file mode 100644 index 0000000..91e91ca --- /dev/null +++ b/issue_card_test.go @@ -0,0 +1,88 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + "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) { + Use(c, 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") + } +} diff --git a/issue_id_badge.go b/issue_id_badge.go new file mode 100644 index 0000000..9261c26 --- /dev/null +++ b/issue_id_badge.go @@ -0,0 +1,73 @@ +package mujica + +import ( + "fmt" + "log/slog" + "regexp" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "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) ChoiceResult { + if !issueIDPattern.MatchString(id) { + panic(fmt.Sprintf("mujica: IssueIdBadge %q is not a key like ENG-123", id)) + } + k := Tokens(c) + d := Density(c) + small := 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 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(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(msg(c, "issue_id_badge.copy"), id)) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Transition(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, msg(c, "issue_id_badge.copied")).FontSize(small).TextColor(k.Success) + } + }) + return ChoiceResult{Element: b, changed: changed} +} diff --git a/issue_id_badge_test.go b/issue_id_badge_test.go new file mode 100644 index 0000000..b63cf08 --- /dev/null +++ b/issue_id_badge_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestIssueIdBadgeCopy(t *testing.T) { + n, disabled := 0, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, 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) { + Use(c, 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) { Use(c, 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{}) + }) + }) +} diff --git a/kanban_board.go b/kanban_board.go new file mode 100644 index 0000000..86ed718 --- /dev/null +++ b/kanban_board.go @@ -0,0 +1,263 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "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 []Member + 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 := Density(c) + label := opts.Label + if label == "" { + label = 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(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 := Tokens(c) + d := 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(FontSize(c, theme.CaptionSize)).TextColor(fg).FontFeatures("tnum") + if col.WIP > 0 { + wip.Label(fmt.Sprintf(msg(c, "kanban_board.wip"), len(idx), col.WIP)) + } + }) + if over { + ui.Text(c, fmt.Sprintf(msg(c, "kanban_board.over"), len(idx)-col.WIP)).FontSize(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(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, msg(c, "kanban_board.empty")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit() * 2) + } + }) + }) +} diff --git a/kanban_board_test.go b/kanban_board_test.go new file mode 100644 index 0000000..f662109 --- /dev/null +++ b/kanban_board_test.go @@ -0,0 +1,177 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +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) { + Use(c, 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) { + Use(c, 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") + } +} diff --git a/label_manager.go b/label_manager.go new file mode 100644 index 0000000..455a570 --- /dev/null +++ b/label_manager.go @@ -0,0 +1,208 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "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) ChoiceResult { + k := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = 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 { + eventColor(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, eventColor(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 := iconAction(c, icons.Must("palette"), fmt.Sprintf(msg(c, "label_manager.edit"), l.Name)); b.Clicked() && live() { + s.editing, s.name, s.problem = l.ID, l.Name, "" + } + if b := iconAction(c, icons.Must("trash-2"), fmt.Sprintf(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, msg(c, "label_manager.empty")).TextColor(k.TextMuted) + } + in := TextInput(c, &s.draft, TextInputOptions{Placeholder: msg(c, "label_manager.new"), Label: 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(msg(c, "label_manager.taken"), name) + default: + l := TaskLabel{ID: labelNewID(*labels), Name: name, Color: 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 ChoiceResult{Element: col, changed: 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 := Tokens(c) + d := Density(c) + ui.Column(c).Grow(1).Gap(d.Unit()).Children(func() { + in := TextInput(c, &s.name, TextInputOptions{Label: msg(c, "label_manager.name"), Error: s.problem}) + in.Input.AutoFocus() + ui.Row(c).Gap(d.Unit()).Role(ui.RoleRadioGroup).Label(msg(c, "label_manager.color")).Children(func() { + for i := range 6 { + col := EventColor(i) + sel := l.Color + b := ui.RadioBase(c, &sel, col).Size(theme.MinHitSize, theme.MinHitSize).Label(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, eventColor(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(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 +} diff --git a/label_manager_test.go b/label_manager_test.go new file mode 100644 index 0000000..8b1ff3c --- /dev/null +++ b/label_manager_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "fmt" + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func labelTester(labels *[]TaskLabel, sel *[]string, opts *LabelManagerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, 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 != 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) + } +} diff --git a/messages_projects.go b/messages_projects.go new file mode 100644 index 0000000..11adf92 --- /dev/null +++ b/messages_projects.go @@ -0,0 +1,120 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "projects.overdue": {"Overdue by %d days", "逾期 %d 天"}, + "projects.overdue_one": {"Overdue by %d day", "逾期 %d 天"}, + "projects.due_today": {"Due today", "今天到期"}, + "projects.due_tomorrow": {"Due tomorrow", "明天到期"}, + "projects.due": {"Due %s %d", "%s%d日到期"}, + + "priority_indicator.none": {"No priority", "无优先级"}, + "priority_indicator.low": {"Low", "低"}, + "priority_indicator.medium": {"Medium", "中"}, + "priority_indicator.high": {"High", "高"}, + "priority_indicator.urgent": {"Urgent", "紧急"}, + + "issue_card.description": {"%s, %s", "%s,%s"}, + + "issue_id_badge.copy": {"Copy %s", "复制 %s"}, + "issue_id_badge.copied": {"Copied", "已复制"}, + "issue_id_badge.copied_label": {"%s copied", "已复制 %s"}, + + "status_select.label": {"Status", "状态"}, + "status_select.backlog": {"Backlog", "待规划"}, + "status_select.unstarted": {"To do", "未开始"}, + "status_select.started": {"In progress", "进行中"}, + "status_select.completed": {"Done", "已完成"}, + "status_select.canceled": {"Canceled", "已取消"}, + + "assignee_picker.label": {"Assignees", "负责人"}, + "assignee_picker.placeholder": {"Unassigned", "未分配"}, + + "due_date_picker.label": {"Due date", "截止日期"}, + "due_date_picker.today": {"Today", "今天"}, + "due_date_picker.tomorrow": {"Tomorrow", "明天"}, + "due_date_picker.next_week": {"Next week", "下周"}, + + "task_item.rename": {"Task title", "任务标题"}, + + "subtask_list.label": {"Subtasks", "子任务"}, + "subtask_list.progress": {"%d of %d done", "已完成 %d / %d"}, + "subtask_list.drag": {"Drag %s", "拖动 %s"}, + "subtask_list.remove": {"Remove %s", "删除 %s"}, + "subtask_list.add": {"Add a subtask", "添加子任务"}, + + "label_manager.label": {"Labels", "标签"}, + "label_manager.edit": {"Edit %s", "编辑 %s"}, + "label_manager.delete": {"Delete %s", "删除 %s"}, + "label_manager.empty": {"No labels yet.", "暂无标签。"}, + "label_manager.new": {"New label", "新建标签"}, + "label_manager.name": {"Label name", "标签名称"}, + "label_manager.color": {"Color", "颜色"}, + "label_manager.taken": {"A label named %q exists.", "已有名为 %q 的标签。"}, + "label_manager.accent": {"Crimson", "酒红"}, + "label_manager.info": {"Blue", "蓝"}, + "label_manager.success": {"Green", "绿"}, + "label_manager.warning": {"Amber", "琥珀"}, + "label_manager.danger": {"Red", "红"}, + "label_manager.neutral": {"Gray", "灰"}, + + "task_list.label": {"Tasks", "任务"}, + "task_list.filter": {"Show", "显示"}, + "task_list.all": {"All %d", "全部 %d"}, + "task_list.open": {"Open %d", "未完成 %d"}, + "task_list.done": {"Done %d", "已完成 %d"}, + "task_list.empty": {"No tasks here.", "这里没有任务。"}, + + "task_detail_panel.close": {"Close details", "关闭详情"}, + "task_detail_panel.title": {"Title", "标题"}, + "task_detail_panel.status": {"Status", "状态"}, + "task_detail_panel.priority": {"Priority", "优先级"}, + "task_detail_panel.activity": {"Activity", "活动记录"}, + + "kanban_board.label": {"Board", "看板"}, + "kanban_board.full": {"%s is at its limit of %d cards.", "%s 已达 %d 张卡片的上限。"}, + "kanban_board.wip": {"%d of %d cards", "%d / %d 张卡片"}, + "kanban_board.over": {"%d over the limit", "超出上限 %d 张"}, + "kanban_board.empty": {"No cards", "暂无卡片"}, + + "sprint_board.time": {"Time", "时间"}, + "sprint_board.done": {"%d of %d done", "已完成 %d / %d"}, + "sprint_board.progress": {"Sprint progress", "迭代进度"}, + "sprint_board.starts_in": {"Starts in %d days", "%d 天后开始"}, + "sprint_board.starts_in_one": {"Starts in %d day", "%d 天后开始"}, + "sprint_board.ended": {"Ended", "已结束"}, + "sprint_board.left": {"%d days left", "剩余 %d 天"}, + "sprint_board.left_one": {"%d day left", "剩余 %d 天"}, + + "milestone_progress.count": {"%d of %d tasks done", "已完成 %d / %d 个任务"}, + "milestone_progress.reached": {"Reached", "已达成"}, + + "workload_view.label": {"Workload", "工作负载"}, + "workload_view.hours": {"h", "小时"}, + "workload_view.over": {"over by %s %s", "超出 %s %s"}, + "workload_view.overloaded": {"%d people are over capacity", "%d 人超出负荷"}, + "workload_view.overloaded_one": {"%d person is over capacity", "%d 人超出负荷"}, + + "time_tracker.label": {"Time tracked", "已记录时间"}, + "time_tracker.start": {"Start", "开始"}, + "time_tracker.stop": {"Stop", "停止"}, + "time_tracker.running": {"Tracking…", "计时中…"}, + "time_tracker.correct": {"Correct the total", "修正总时长"}, + "time_tracker.apply": {"Apply", "应用"}, + + "pomodoro_timer.label": {"Pomodoro timer", "番茄钟"}, + "pomodoro_timer.focus": {"Focus", "专注"}, + "pomodoro_timer.break": {"Short break", "短休息"}, + "pomodoro_timer.long_break": {"Long break", "长休息"}, + "pomodoro_timer.done": {"%s finished.", "%s结束。"}, + "pomodoro_timer.left": {"%s, %s left", "%s,剩余 %s"}, + "pomodoro_timer.sessions": {"%d of %d sessions before a long break", "距长休息:%d / %d 轮"}, + "pomodoro_timer.start": {"Start", "开始"}, + "pomodoro_timer.pause": {"Pause", "暂停"}, + "pomodoro_timer.skip": {"Skip", "跳过"}, + "pomodoro_timer.reset": {"Reset", "重置"}, + + "burndown_chart.actual": {"Remaining", "剩余"}, + "burndown_chart.ideal": {"Ideal", "理想"}, + }) +} diff --git a/milestone_progress.go b/milestone_progress.go new file mode 100644 index 0000000..42bc9f1 --- /dev/null +++ b/milestone_progress.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// Milestone holds a goal, due date and completion counts. +type Milestone struct { + Title string + Due Date + Done, Total int +} + +// MilestoneProgressOptions configure a MilestoneProgress. +type MilestoneProgressOptions struct { + // Today anchors the date note; zero uses the system clock. + Today Date +} + +// MilestoneProgress shows completion counts, progress and due dates. +func MilestoneProgress(c *ui.Context, m Milestone, opts MilestoneProgressOptions) *ui.Element { + 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 := Tokens(c) + d := Density(c) + today := 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(msg(c, "milestone_progress.count"), m.Done, m.Total)).FontSize(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 = 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(FontSize(c, theme.CaptionSize)) + }) + } + }) + }) + return col +} diff --git a/milestone_progress_test.go b/milestone_progress_test.go new file mode 100644 index 0000000..70466bd --- /dev/null +++ b/milestone_progress_test.go @@ -0,0 +1,55 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func milestoneTester(m Milestone) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, 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}) }) + } + }) + }) +} diff --git a/pomodoro_timer.go b/pomodoro_timer.go new file mode 100644 index 0000000..21b1731 --- /dev/null +++ b/pomodoro_timer.go @@ -0,0 +1,212 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "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 := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = 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(msg(c, "pomodoro_timer.done"), 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 := 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(msg(c, "pomodoro_timer.left"), phase, CountdownText(c, time.Duration(secs)*time.Second, 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(FontSize(c, theme.H1Size)).TextColor(k.Text).FontFeatures("tnum") + ui.Text(c, phase).FontSize(FontSize(c, theme.CaptionSize)).TextColor(tone) + }) + }) + ui.Text(c, fmt.Sprintf(msg(c, "pomodoro_timer.sessions"), s.focused%every, every)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + ui.Row(c).Gap(d.Unit() * 2).Children(func() { + key, icon, variant := "pomodoro_timer.start", "play", Primary + if s.running { + key, icon, variant = "pomodoro_timer.pause", "pause", Secondary + } + if Button(c, msg(c, key), 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 Button(c, msg(c, "pomodoro_timer.skip"), ButtonOptions{Variant: Secondary}).Clicked() { + act("skip", next) + } + if Button(c, msg(c, "pomodoro_timer.reset"), ButtonOptions{Variant: 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 +} diff --git a/pomodoro_timer_test.go b/pomodoro_timer_test.go new file mode 100644 index 0000000..2c95216 --- /dev/null +++ b/pomodoro_timer_test.go @@ -0,0 +1,132 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +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) { + Use(c, 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++ + Use(c, 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") + } +} diff --git a/priority_indicator.go b/priority_indicator.go new file mode 100644 index 0000000..e0e3623 --- /dev/null +++ b/priority_indicator.go @@ -0,0 +1,67 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "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 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 := Tokens(c) + d := 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(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) + } + } +} diff --git a/priority_indicator_test.go b/priority_indicator_test.go new file mode 100644 index 0000000..ea7360a --- /dev/null +++ b/priority_indicator_test.go @@ -0,0 +1,79 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestPriorityIndicatorNamesAndShapes(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light, Locale: 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) { + Use(c, Settings{Mode: 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) { Use(c, 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{}) + } + }) + }) +} diff --git a/projects.go b/projects.go new file mode 100644 index 0000000..44cc3ea --- /dev/null +++ b/projects.go @@ -0,0 +1,146 @@ +package mujica + +import ( + "fmt" + "slices" + + "github.com/egoist/mygo/ui" +) + +// Priority describes urgency; zero means no priority. +type Priority int + +const ( + PriorityNone Priority = iota + PriorityLow + PriorityMedium + PriorityHigh + PriorityUrgent +) + +// Member is a person tasks can be assigned to. +type Member struct { + ID string + Name string +} + +// TaskLabel is a colored tag tasks carry. +type TaskLabel struct { + ID string + Name string + Color 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 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 []Member, id string) Member { + i := slices.IndexFunc(members, func(m Member) 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 Date, done bool) (string, bool) { + datetimeCheckDate("project due", due) + if due.IsZero() { + return "", false + } + days := int(due.civil().Sub(today.civil()).Hours() / 24) + switch { + case days < 0 && !done: + return calendarCount(c, "projects.overdue", -days), true + case days == 0: + return msg(c, "projects.due_today"), false + case days == 1: + return msg(c, "projects.due_tomorrow"), false + } + return fmt.Sprintf(msg(c, "projects.due"), 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 := 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) + }) +} diff --git a/projects_test.go b/projects_test.go new file mode 100644 index 0000000..6b6cd6a --- /dev/null +++ b/projects_test.go @@ -0,0 +1,54 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +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 = []Member{{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: EventDanger}, {ID: "ui", Name: "UI", Color: EventInfo}, + {ID: "docs", Name: "Docs", Color: 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) { + Use(c, Settings{}) + for _, c2 := range []struct { + due 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]) + } + } +} diff --git a/sprint_board.go b/sprint_board.go new file mode 100644 index 0000000..5bb76ed --- /dev/null +++ b/sprint_board.go @@ -0,0 +1,111 @@ +package mujica + +import ( + "fmt" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// Sprint is one iteration: its name, goal and inclusive dates. +type Sprint struct { + Name string + Goal string + Start, End Date +} + +// SprintBoardOptions configure a SprintBoard. +type SprintBoardOptions struct { + Board KanbanBoardOptions + // Today counts the days left; zero uses the system clock. + Today Date +} + +// SprintBoard combines iteration information, completion and a task board. +func SprintBoard(c *ui.Context, sprint Sprint, tasks *[]Task, opts SprintBoardOptions) KanbanResult { + datetimeCheckDate("SprintBoard Start", sprint.Start) + 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 := Tokens(c) + d := Density(c) + today := 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(FontSize(c, theme.H2Size)).TextColor(k.Text) + if sprint.Goal != "" { + ui.Text(c, sprint.Goal).TextColor(k.TextMuted) + } + ui.Text(c, weekViewTitle(c, sprint.Start, sprint.End)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + sprintStat(c, sprintDaysLeft(c, sprint, today), 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(msg(c, "sprint_board.done"), done, total)).TextColor(k.Text).FontFeatures("tnum") + Progress(c, frac, ProgressOptions{Label: 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 Date) string { + switch { + case today.Compare(s.Start) < 0: + return calendarCount(c, "sprint_board.starts_in", int(s.Start.civil().Sub(today.civil()).Hours()/24)) + case today.Compare(s.End) > 0: + return msg(c, "sprint_board.ended") + } + return 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 := Tokens(c) + ui.Column(c).Gap(2).Role(ui.RoleStatus).Label(caption + ": " + value).Children(func() { + ui.Text(c, value).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.AccentText) + ui.Text(c, caption).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) +} + +type sprintCache struct { + first *Task + n, version, done, total int + statuses []WorkflowStatus + ready bool +} diff --git a/sprint_board_test.go b/sprint_board_test.go new file mode 100644 index 0000000..e9c3c49 --- /dev/null +++ b/sprint_board_test.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +var projSprint = Sprint{Name: "Sprint 12", Goal: "Ready the masquerade", Start: oct(1), End: oct(14)} + +func sprintTester(sprint Sprint, tasks *[]Task, today Date, disabled bool, moves *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, 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 Date + want string + }{{Date{2026, 9, 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") + } +} diff --git a/status_select.go b/status_select.go new file mode 100644 index 0000000..d4aa7fc --- /dev/null +++ b/status_select.go @@ -0,0 +1,140 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "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) 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 := 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 = msg(c, "status_select.label") + } + tr.Label(label).Description(cur.Name) + 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) { + 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, msg(c, statusGroupKeys[g])).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted). + Padding(Density(c).Unit(), Density(c).Unit()*2).Role(ui.RoleColumnHeader) + first = false + } + it := s.Item(st.ID) + 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 ChoiceResult{Element: tr, changed: changed} +} + +// statusGlyphBox reserves 14 DIPs for a status glyph. +func statusGlyphBox(c *ui.Context, g StatusGroup) { + k := 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) + } +} diff --git a/status_select_test.go b/status_select_test.go new file mode 100644 index 0000000..90e273f --- /dev/null +++ b/status_select_test.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func statusTester(v *string, opts *StatusSelectOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, 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) { + Use(c, 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") + } +} diff --git a/subtask_list.go b/subtask_list.go new file mode 100644 index 0000000..1095476 --- /dev/null +++ b/subtask_list.go @@ -0,0 +1,158 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "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) ChoiceResult { + k := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = 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(msg(c, "subtask_list.progress"), done, len(*subs))).FontSize(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(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 := Checkbox(c, &v, s.Title, 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 := iconAction(c, icons.Must("x"), fmt.Sprintf(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 := TextInput(c, draft, TextInputOptions{Placeholder: msg(c, "subtask_list.add"), Label: 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 ChoiceResult{Element: col, changed: 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 +} diff --git a/subtask_list_test.go b/subtask_list_test.go new file mode 100644 index 0000000..8cf92ea --- /dev/null +++ b/subtask_list_test.go @@ -0,0 +1,107 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +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) { + Use(c, 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) + } +} diff --git a/task_detail_panel.go b/task_detail_panel.go new file mode 100644 index 0000000..60935fb --- /dev/null +++ b/task_detail_panel.go @@ -0,0 +1,115 @@ +package mujica + +import ( + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// TaskDetailPanelOptions configure a TaskDetailPanel. +type TaskDetailPanelOptions struct { + Statuses []WorkflowStatus + Members []Member + 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 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 := Tokens(c) + d := 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 := iconAction(c, icons.Must("x"), msg(c, "task_detail_panel.close")); b.Clicked() && !b.IsDisabled() { + r.closed = true + } + }) + title := TextInput(c, &task.Title, TextInputOptions{Label: msg(c, "task_detail_panel.title"), Disabled: opts.Disabled}) + title.Input.Font(theme.SerifFont) + r.changed = title.Changed() + taskField(c, msg(c, "task_detail_panel.status"), func() { + r.changed = StatusSelect(c, &task.Status, opts.Statuses, StatusSelectOptions{Disabled: opts.Disabled}).Changed() || r.changed + }) + taskField(c, msg(c, "task_detail_panel.priority"), func() { + prios := make([]SelectOption[Priority], 0, 5) + for p := PriorityUrgent; p >= PriorityNone; p-- { + prios = append(prios, SelectOption[Priority]{Value: p, Label: PriorityName(c, p)}) + } + r.changed = Select(c, &task.Priority, prios, SelectOptions{Label: msg(c, "task_detail_panel.priority"), Disabled: opts.Disabled}).Changed() || r.changed + }) + taskField(c, 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, 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, msg(c, "label_manager.label"), func() { + options := make([]SelectOption[string], len(opts.Labels)) + for i, l := range opts.Labels { + options[i] = SelectOption[string]{Value: l.ID, Label: l.Name} + } + r.changed = MultiSelect(c, &task.Labels, options, MultiSelectOptions{Label: 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, 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(Density(c).Unit()).Children(func() { + ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted) + control() + }) +} diff --git a/task_detail_panel_test.go b/task_detail_panel_test.go new file mode 100644 index 0000000..9ac7d55 --- /dev/null +++ b/task_detail_panel_test.go @@ -0,0 +1,147 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +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) { + Use(c, 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) { + Use(c, 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()) + } +} diff --git a/task_item.go b/task_item.go new file mode 100644 index 0000000..bd54a54 --- /dev/null +++ b/task_item.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TaskItemOptions configure a TaskItem. +type TaskItemOptions struct { + Members []Member + // Today anchors the overdue mark; zero uses the system clock. + Today 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 := Tokens(c) + d := Density(c) + today := 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 Checkbox(c, &done, "", 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 := TextInput(c, &edit.draft, TextInputOptions{Label: 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(FontSize(c, theme.CaptionSize)).TextColor(fg).SingleLine().Shrink(0) + } + if len(task.Assignees) > 0 { + items := make([]AvatarItem, len(task.Assignees)) + for i, id := range task.Assignees { + items[i] = AvatarItem{Name: projectMember(opts.Members, id).Name} + } + AvatarGroup(c, items, AvatarGroupOptions{Max: 2, Size: 24}) + } + }) + return r +} diff --git a/task_item_test.go b/task_item_test.go new file mode 100644 index 0000000..3ac3266 --- /dev/null +++ b/task_item_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type taskItemProbe struct { + task Task + disabled bool + changes, openeds []string +} + +func (p *taskItemProbe) tester() *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, 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}) + } + }) + }) +} diff --git a/task_list.go b/task_list.go new file mode 100644 index 0000000..ea533c2 --- /dev/null +++ b/task_list.go @@ -0,0 +1,215 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TaskGrouping chooses the sections of a TaskList. +type TaskGrouping int + +const ( + TaskGroupNone TaskGrouping = iota + TaskGroupStatus + TaskGroupPriority +) + +// TaskFilter shows all, open or done tasks. +type TaskFilter int + +const ( + TaskShowAll TaskFilter = iota + TaskShowOpen + TaskShowDone +) + +// TaskListOptions configure a TaskList. +type TaskListOptions struct { + GroupBy TaskGrouping + // Statuses order the status groups; required with TaskGroupStatus. + Statuses []WorkflowStatus + Members []Member + // Version must change when tasks change in place. + Version int + // Today anchors overdue marks; zero uses the system clock. + Today Date + Disabled bool + Label string +} + +// taskRow identifies a heading or task. +type taskRow struct { + head string + task int +} + +// taskListCache keeps rows until the data, grouping or filter change. +type taskListCache struct { + first *Task + n, ver int + group TaskGrouping + filter TaskFilter + rows []taskRow + counted [3]int + statuses []WorkflowStatus + locale Locale +} + +// TaskList groups and filters tasks, virtualizing visible rows. +func TaskList(c *ui.Context, tasks []Task, filter *TaskFilter, opts TaskListOptions) ProjectResult { + if opts.GroupBy < TaskGroupNone || opts.GroupBy > TaskGroupPriority || *filter < TaskShowAll || *filter > TaskShowDone { + panic(fmt.Sprintf("mujica: TaskList grouping %d or filter %d out of range", opts.GroupBy, *filter)) + } + if opts.GroupBy == TaskGroupStatus && len(opts.Statuses) == 0 { + panic("mujica: TaskList grouping by status needs Statuses") + } + k := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "task_list.label") + } + r := ProjectResult{} + root := ui.Column(c).Grow(1).MinHeight(0).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + r.Element = root + cache := ui.Local(root, "task-list", func() *taskListCache { return &taskListCache{} }) + var first *Task + if len(tasks) > 0 { + first = &tasks[0] + } + if x := *cache; x.rows == nil || x.first != first || x.n != len(tasks) || x.ver != opts.Version || x.group != opts.GroupBy || x.filter != *filter || !slices.Equal(x.statuses, opts.Statuses) || x.locale != current(c).locale { + *cache = &taskListCache{first: first, n: len(tasks), ver: opts.Version, group: opts.GroupBy, filter: *filter, statuses: slices.Clone(opts.Statuses), locale: current(c).locale} + (*cache).rows, (*cache).counted = taskListRows(c, tasks, opts, *filter) + } + rows := (*cache).rows + state := ui.Local(root, "task-list-state", func() *ui.ListState { return &ui.ListState{} }) + st := *state + st.Key = func(i int) any { + if rows[i].task < 0 { + return "head:" + rows[i].head + } + return tasks[rows[i].task].ID + } + st.Header = func(i int) bool { return rows[i].task < 0 } + root.Children(func() { + counts := (*cache).counted + sel := int(*filter) + taskListFilter(c, &sel, []string{ + fmt.Sprintf(msg(c, "task_list.all"), counts[0]), + fmt.Sprintf(msg(c, "task_list.open"), counts[1]), + fmt.Sprintf(msg(c, "task_list.done"), counts[2]), + }) + if sel != int(*filter) && !root.IsDisabled() { + *filter = TaskFilter(sel) + slog.Debug("mujica: task_list filter", "filter", *filter) + } + list := ui.List(c, st, len(rows), func(i int) { + row := rows[i] + if row.task < 0 { + h := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).PaddingX(d.Unit() * 2).Background(k.Background).Role(ui.RoleColumnHeader) + h.Children(func() { ui.Text(c, row.head).Font(theme.SerifFont).TextColor(k.Text) }) + h.DrawOver(func(p *ui.Painter, rr ui.Rect) { internal.Rule(p, rr.X, rr.X+rr.W, rr.Y+rr.H-1, k.Ornament) }) + return + } + ir := TaskItem(c, &tasks[row.task], TaskItemOptions{Members: opts.Members, Today: opts.Today, Disabled: opts.Disabled}) + if id, ok := ir.Changed(); ok { + r.changed = id + (*cache).rows = nil + c.Invalidate() + } + if id, ok := ir.Opened(); ok { + r.opened = id + } + }).Grow(1).MinHeight(d.ControlHeight() * 4) + list.Children(func() { + if len(rows) == 0 { + ui.Text(c, msg(c, "task_list.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 3) + } + }) + }) + return r +} + +// taskListRows computes groups and completion counts. +func taskListRows(c *ui.Context, tasks []Task, opts TaskListOptions, filter TaskFilter) ([]taskRow, [3]int) { + var counts [3]int + keep := make([]int, 0, len(tasks)) + for i, t := range tasks { + counts[0]++ + if t.Done { + counts[2]++ + } else { + counts[1]++ + } + if filter == TaskShowAll || (filter == TaskShowDone) == t.Done { + keep = append(keep, i) + } + } + rows := make([]taskRow, 0, len(keep)+8) + switch opts.GroupBy { + case TaskGroupNone: + for _, i := range keep { + rows = append(rows, taskRow{task: i}) + } + case TaskGroupStatus: + for _, s := range opts.Statuses { + rows = taskListSection(rows, s.Name, keep, func(i int) bool { return tasks[i].Status == s.ID }) + } + for _, i := range keep { + projectStatus(opts.Statuses, tasks[i].Status) + } + case TaskGroupPriority: + for p := PriorityUrgent; p >= PriorityNone; p-- { + rows = taskListSection(rows, PriorityName(c, p), keep, func(i int) bool { return tasks[i].Priority == p }) + } + } + return slices.Clip(rows), counts +} + +// taskListSection appends a heading and its tasks when any match. +func taskListSection(rows []taskRow, head string, keep []int, in func(int) bool) []taskRow { + start := len(rows) + for _, i := range keep { + if in(i) { + if len(rows) == start { + rows = append(rows, taskRow{head: head, task: -1}) + } + rows = append(rows, taskRow{task: i}) + } + } + return rows +} + +// taskListFilter marks the selected completion filter with an inset. +func taskListFilter(c *ui.Context, value *int, labels []string) { + k, d := Tokens(c), Density(c) + parts := ui.SegmentedBase(c, value, len(labels)) + parts.Track.MinHeight(max(d.ControlHeight(), theme.MinHitSize+4)).AlignItems(ui.Stretch).Padding(2).Gap(2).Radius(theme.ControlRadius+2). + Background(k.Background).Border(theme.BorderWidth, k.ControlBorder).Label(msg(c, "task_list.filter")) + parts.Track.Children(func() { + for i, label := range labels { + selected := i == *value + seg := parts.Segment(i).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize).PaddingX(d.Unit() * 3).Radius(theme.ControlRadius).Label(label) + fg := k.TextMuted + if selected { + fg = k.AccentText + seg.Background(k.Selection).FontWeight(600) + seg.Draw(func(p *ui.Painter, r ui.Rect) { + p.Stroke(ui.Rect{X: r.X + 2, Y: r.Y + 2, W: r.W - 4, H: r.H - 4}, k.AccentText, theme.ControlRadius, 1) + }) + } else if seg.Hovered() { + fg = k.Text + seg.Background(k.SurfaceHover) + } + internal.Ease(seg, Motion(c, theme.StateDuration), fg) + internal.Ring(seg, k.Focus, theme.ControlRadius) + seg.Children(func() { ui.Text(c, label).SingleLine() }) + } + }) +} diff --git a/task_list_test.go b/task_list_test.go new file mode 100644 index 0000000..fa72dd4 --- /dev/null +++ b/task_list_test.go @@ -0,0 +1,147 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" + "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) { + Use(c, 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) { + Use(c, 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) + } + } + } +} diff --git a/testdata/snapshots/darwin/assignee_picker-dark.png b/testdata/snapshots/darwin/assignee_picker-dark.png new file mode 100644 index 0000000..5bbb7dd Binary files /dev/null and b/testdata/snapshots/darwin/assignee_picker-dark.png differ diff --git a/testdata/snapshots/darwin/assignee_picker-light.png b/testdata/snapshots/darwin/assignee_picker-light.png new file mode 100644 index 0000000..8c19a1d Binary files /dev/null and b/testdata/snapshots/darwin/assignee_picker-light.png differ diff --git a/testdata/snapshots/darwin/burndown_chart-dark.png b/testdata/snapshots/darwin/burndown_chart-dark.png new file mode 100644 index 0000000..ee856f8 Binary files /dev/null and b/testdata/snapshots/darwin/burndown_chart-dark.png differ diff --git a/testdata/snapshots/darwin/burndown_chart-light.png b/testdata/snapshots/darwin/burndown_chart-light.png new file mode 100644 index 0000000..7a5d584 Binary files /dev/null and b/testdata/snapshots/darwin/burndown_chart-light.png differ diff --git a/testdata/snapshots/darwin/due_date_picker-dark.png b/testdata/snapshots/darwin/due_date_picker-dark.png new file mode 100644 index 0000000..c3fad13 Binary files /dev/null and b/testdata/snapshots/darwin/due_date_picker-dark.png differ diff --git a/testdata/snapshots/darwin/due_date_picker-light.png b/testdata/snapshots/darwin/due_date_picker-light.png new file mode 100644 index 0000000..12ee9e0 Binary files /dev/null and b/testdata/snapshots/darwin/due_date_picker-light.png differ diff --git a/testdata/snapshots/darwin/issue_card-dark.png b/testdata/snapshots/darwin/issue_card-dark.png new file mode 100644 index 0000000..f741efc Binary files /dev/null and b/testdata/snapshots/darwin/issue_card-dark.png differ diff --git a/testdata/snapshots/darwin/issue_card-light.png b/testdata/snapshots/darwin/issue_card-light.png new file mode 100644 index 0000000..aaf9d4e Binary files /dev/null and b/testdata/snapshots/darwin/issue_card-light.png differ diff --git a/testdata/snapshots/darwin/issue_id_badge-dark.png b/testdata/snapshots/darwin/issue_id_badge-dark.png new file mode 100644 index 0000000..df21eda Binary files /dev/null and b/testdata/snapshots/darwin/issue_id_badge-dark.png differ diff --git a/testdata/snapshots/darwin/issue_id_badge-light.png b/testdata/snapshots/darwin/issue_id_badge-light.png new file mode 100644 index 0000000..00a3420 Binary files /dev/null and b/testdata/snapshots/darwin/issue_id_badge-light.png differ diff --git a/testdata/snapshots/darwin/kanban_board-dark.png b/testdata/snapshots/darwin/kanban_board-dark.png new file mode 100644 index 0000000..a9126e7 Binary files /dev/null and b/testdata/snapshots/darwin/kanban_board-dark.png differ diff --git a/testdata/snapshots/darwin/kanban_board-light.png b/testdata/snapshots/darwin/kanban_board-light.png new file mode 100644 index 0000000..04bfb4d Binary files /dev/null and b/testdata/snapshots/darwin/kanban_board-light.png differ diff --git a/testdata/snapshots/darwin/label_manager-dark.png b/testdata/snapshots/darwin/label_manager-dark.png new file mode 100644 index 0000000..20e9a29 Binary files /dev/null and b/testdata/snapshots/darwin/label_manager-dark.png differ diff --git a/testdata/snapshots/darwin/label_manager-light.png b/testdata/snapshots/darwin/label_manager-light.png new file mode 100644 index 0000000..288e437 Binary files /dev/null and b/testdata/snapshots/darwin/label_manager-light.png differ diff --git a/testdata/snapshots/darwin/milestone_progress-dark.png b/testdata/snapshots/darwin/milestone_progress-dark.png new file mode 100644 index 0000000..8a8056a Binary files /dev/null and b/testdata/snapshots/darwin/milestone_progress-dark.png differ diff --git a/testdata/snapshots/darwin/milestone_progress-light.png b/testdata/snapshots/darwin/milestone_progress-light.png new file mode 100644 index 0000000..748bfc8 Binary files /dev/null and b/testdata/snapshots/darwin/milestone_progress-light.png differ diff --git a/testdata/snapshots/darwin/pomodoro_timer-dark.png b/testdata/snapshots/darwin/pomodoro_timer-dark.png new file mode 100644 index 0000000..7143060 Binary files /dev/null and b/testdata/snapshots/darwin/pomodoro_timer-dark.png differ diff --git a/testdata/snapshots/darwin/pomodoro_timer-light.png b/testdata/snapshots/darwin/pomodoro_timer-light.png new file mode 100644 index 0000000..936a720 Binary files /dev/null and b/testdata/snapshots/darwin/pomodoro_timer-light.png differ diff --git a/testdata/snapshots/darwin/priority_indicator-dark.png b/testdata/snapshots/darwin/priority_indicator-dark.png new file mode 100644 index 0000000..3e373d6 Binary files /dev/null and b/testdata/snapshots/darwin/priority_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/priority_indicator-light.png b/testdata/snapshots/darwin/priority_indicator-light.png new file mode 100644 index 0000000..bd50c73 Binary files /dev/null and b/testdata/snapshots/darwin/priority_indicator-light.png differ diff --git a/testdata/snapshots/darwin/sprint_board-dark.png b/testdata/snapshots/darwin/sprint_board-dark.png new file mode 100644 index 0000000..6f2eb6d Binary files /dev/null and b/testdata/snapshots/darwin/sprint_board-dark.png differ diff --git a/testdata/snapshots/darwin/sprint_board-light.png b/testdata/snapshots/darwin/sprint_board-light.png new file mode 100644 index 0000000..d99315e Binary files /dev/null and b/testdata/snapshots/darwin/sprint_board-light.png differ diff --git a/testdata/snapshots/darwin/status_select-dark.png b/testdata/snapshots/darwin/status_select-dark.png new file mode 100644 index 0000000..ab531db Binary files /dev/null and b/testdata/snapshots/darwin/status_select-dark.png differ diff --git a/testdata/snapshots/darwin/status_select-light.png b/testdata/snapshots/darwin/status_select-light.png new file mode 100644 index 0000000..2ecbe66 Binary files /dev/null and b/testdata/snapshots/darwin/status_select-light.png differ diff --git a/testdata/snapshots/darwin/subtask_list-dark.png b/testdata/snapshots/darwin/subtask_list-dark.png new file mode 100644 index 0000000..a4b3629 Binary files /dev/null and b/testdata/snapshots/darwin/subtask_list-dark.png differ diff --git a/testdata/snapshots/darwin/subtask_list-light.png b/testdata/snapshots/darwin/subtask_list-light.png new file mode 100644 index 0000000..965661a Binary files /dev/null and b/testdata/snapshots/darwin/subtask_list-light.png differ diff --git a/testdata/snapshots/darwin/task_detail_panel-dark.png b/testdata/snapshots/darwin/task_detail_panel-dark.png new file mode 100644 index 0000000..6a1f39d Binary files /dev/null and b/testdata/snapshots/darwin/task_detail_panel-dark.png differ diff --git a/testdata/snapshots/darwin/task_detail_panel-light.png b/testdata/snapshots/darwin/task_detail_panel-light.png new file mode 100644 index 0000000..0f692bd Binary files /dev/null and b/testdata/snapshots/darwin/task_detail_panel-light.png differ diff --git a/testdata/snapshots/darwin/task_item-dark.png b/testdata/snapshots/darwin/task_item-dark.png new file mode 100644 index 0000000..af2dd0f Binary files /dev/null and b/testdata/snapshots/darwin/task_item-dark.png differ diff --git a/testdata/snapshots/darwin/task_item-light.png b/testdata/snapshots/darwin/task_item-light.png new file mode 100644 index 0000000..a7f6c8f Binary files /dev/null and b/testdata/snapshots/darwin/task_item-light.png differ diff --git a/testdata/snapshots/darwin/task_list-dark.png b/testdata/snapshots/darwin/task_list-dark.png new file mode 100644 index 0000000..396cacd Binary files /dev/null and b/testdata/snapshots/darwin/task_list-dark.png differ diff --git a/testdata/snapshots/darwin/task_list-light.png b/testdata/snapshots/darwin/task_list-light.png new file mode 100644 index 0000000..7d4ab51 Binary files /dev/null and b/testdata/snapshots/darwin/task_list-light.png differ diff --git a/testdata/snapshots/darwin/time_tracker-dark.png b/testdata/snapshots/darwin/time_tracker-dark.png new file mode 100644 index 0000000..7f881f9 Binary files /dev/null and b/testdata/snapshots/darwin/time_tracker-dark.png differ diff --git a/testdata/snapshots/darwin/time_tracker-light.png b/testdata/snapshots/darwin/time_tracker-light.png new file mode 100644 index 0000000..92ce7b3 Binary files /dev/null and b/testdata/snapshots/darwin/time_tracker-light.png differ diff --git a/testdata/snapshots/darwin/workload_view-dark.png b/testdata/snapshots/darwin/workload_view-dark.png new file mode 100644 index 0000000..0ee4103 Binary files /dev/null and b/testdata/snapshots/darwin/workload_view-dark.png differ diff --git a/testdata/snapshots/darwin/workload_view-light.png b/testdata/snapshots/darwin/workload_view-light.png new file mode 100644 index 0000000..1ac3ffb Binary files /dev/null and b/testdata/snapshots/darwin/workload_view-light.png differ diff --git a/time_tracker.go b/time_tracker.go new file mode 100644 index 0000000..b3baa35 --- /dev/null +++ b/time_tracker.go @@ -0,0 +1,131 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "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) 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 := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = 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", Secondary + if running { + icon, key, variant = "pause", "time_tracker.stop", Primary + } + if Button(c, msg(c, key), 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() { + DurationPicker(c, draft, DurationPickerOptions{Seconds: true, Label: msg(c, "time_tracker.correct")}) + if Button(c, msg(c, "time_tracker.apply"), ButtonOptions{}).Clicked() { + act("correct", func() { s.Adjust += *draft - total }) + *editing = false + } + if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Ghost}).Clicked() { + *editing = false + } + return + } + *editing = false + fg := k.Text + if running { + fg = k.AccentText + } + ui.Text(c, StopwatchText(total)).Font(theme.MonoFont).FontSize(FontSize(c, theme.H3Size)).TextColor(fg).FontFeatures("tnum"). + Label(label + ", " + DurationText(c, total.Truncate(time.Second))) + if running { + ui.Text(c, msg(c, "time_tracker.running")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText) + } + ui.Box(c).Disabled(running).Children(func() { + if b := iconAction(c, icons.Must("pencil"), 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 ChoiceResult{Element: row, changed: changed} +} diff --git a/time_tracker_test.go b/time_tracker_test.go new file mode 100644 index 0000000..8da2e5d --- /dev/null +++ b/time_tracker_test.go @@ -0,0 +1,105 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +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) { + Use(c, 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) { Use(c, 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) { Use(c, 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) { Use(c, 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)) + } +} diff --git a/workload_view.go b/workload_view.go new file mode 100644 index 0000000..ba44ac6 --- /dev/null +++ b/workload_view.go @@ -0,0 +1,126 @@ +package mujica + +import ( + "fmt" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Workload is one member's assigned and available hours. +type Workload struct { + Member Member + 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 := Tokens(c) + d := Density(c) + unit := opts.Unit + if unit == "" { + unit = msg(c, "workload_view.hours") + } + label := opts.Label + if label == "" { + label = 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, calendarCount(c, "workload_view.overloaded", over)).FontSize(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(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() { + Avatar(c, l.Member.Name, 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(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 +} diff --git a/workload_view_test.go b/workload_view_test.go new file mode 100644 index 0000000..7b22374 --- /dev/null +++ b/workload_view_test.go @@ -0,0 +1,79 @@ +package mujica + +import ( + "fmt" + "math" + "testing" + + "github.com/egoist/mygo/ui" + + "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) { + Use(c, Settings{Mode: 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: Member{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{}) +}