Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

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