WIP components (as built)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:51:57 -04:00
1 parent c804f2ab94
commit e9d5dbe6ad
102 files changed
+6538 -1

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// AssigneePickerOptions configure an AssigneePicker.
type AssigneePickerOptions struct {
// Max limits selections; zero allows any number.
Max int
Disabled bool
Label string
// Version changes when members or selection change in place.
Version int
}
type assigneeState struct {
first *Member
n, version int
ready, open bool
query, filtered string
options, shown []SelectOption[string]
members map[string]Member
selected map[string]bool
selectedFirst *string
selectedN, count int
list ui.ListState
focus string
}
// AssigneePicker searches and selects members, showing their avatars.
func AssigneePicker(c *ui.Context, ids *[]string, members []Member, opts AssigneePickerOptions) ChoiceResult {
if opts.Max < 0 {
panic("mujica: AssigneePicker Max must be non-negative")
}
d, k := Density(c), Tokens(c)
label := opts.Label
if label == "" {
label = msg(c, "assignee_picker.label")
}
row := ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Disabled(opts.Disabled)
st := ui.Local(row, "assignees", func() assigneeState { return assigneeState{} })
var first *Member
var selectedFirst *string
if len(members) > 0 {
first = &members[0]
}
if len(*ids) > 0 {
selectedFirst = &(*ids)[0]
}
dataChanged := !st.ready || st.first != first || st.n != len(members) || st.version != opts.Version
if dataChanged {
st.first, st.n, st.version, st.ready = first, len(members), opts.Version, true
st.members = make(map[string]Member, len(members))
st.options = make([]SelectOption[string], len(members))
for i, m := range members {
if _, exists := st.members[m.ID]; exists || m.ID == "" {
panic("mujica: AssigneePicker needs unique non-empty IDs")
}
st.members[m.ID] = m
st.options[i] = SelectOption[string]{Value: m.ID, Label: m.Name}
}
st.shown = nil
}
before := *ids
var trigger *ui.Element
row.Children(func() {
if len(*ids) > 0 {
items := make([]AvatarItem, min(3, len(*ids)))
for i := range items {
items[i] = AvatarItem{Name: st.members[(*ids)[i]].Name}
}
AvatarGroup(c, items, AvatarGroupOptions{Max: 3, Size: 28})
}
trigger = ui.ButtonBase(c).Disabled(row.IsDisabled()).Role(ui.RolePopUpButton).Label(label)
if trigger.IsDisabled() {
st.open = false
}
if !trigger.IsDisabled() && (trigger.Clicked() || !st.open && trigger.Shortcut(0, ui.KeyDown)) {
st.open, st.query = !st.open, ""
}
if len(*ids) > 0 && !trigger.IsDisabled() && trigger.Shortcut(0, ui.KeyBackspace) {
*ids = slices.Clone((*ids)[:len(*ids)-1])
}
selectField(c, trigger, st.open, func() {
options := make([]SelectOption[string], min(2, len(*ids)))
for i := range options {
options[i] = SelectOption[string]{Value: (*ids)[i], Label: st.members[(*ids)[i]].Name}
}
multiSelectTags(c, ids, options, MultiSelectOptions{MaxTags: 2, Disabled: trigger.IsDisabled(), Placeholder: msg(c, "assignee_picker.placeholder")})
})
ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) {
selectPanel(c, panel)
ww, wh := c.Size()
panel.MinWidth(min(ww-8, trigger.Bounds().W)).MaxWidth(max(1, ww-8)).MaxHeight(max(1, wh-8))
ui.Scroll(c).MaxHeight(max(1, wh-24)).Children(func() {
search := selectSearch(c, &st.query, msg(c, "multi_select.search"))
q := strings.ToLower(strings.TrimSpace(st.query))
if st.shown == nil || st.filtered != q {
st.shown = make([]SelectOption[string], 0)
for _, o := range st.options {
if strings.Contains(strings.ToLower(o.Label), q) {
st.shown = append(st.shown, o)
}
}
st.filtered = q
st.list.ScrollTo(0, ui.Start)
dataChanged = true
}
if dataChanged || st.selectedFirst != selectedFirst || st.selectedN != len(*ids) || st.selected == nil {
st.selected = make(map[string]bool, len(*ids))
for _, id := range *ids {
if _, ok := st.members[id]; !ok {
panic("mujica: AssigneePicker selection contains an unknown ID")
}
if st.selected[id] {
panic("mujica: AssigneePicker selection contains duplicate IDs")
}
st.selected[id] = true
}
st.selectedFirst, st.selectedN, st.count = selectedFirst, len(*ids), 0
for _, o := range st.shown {
if st.selected[o.Value] {
st.count++
}
}
}
if len(st.shown) == 0 {
selectNote(c, msg(c, "multi_select.no_results"))
return
}
on, full := st.count == len(st.shown), opts.Max > 0 && len(*ids) >= opts.Max
mark := internal.Unchecked
if on {
mark = internal.Checked
} else if st.count > 0 {
mark = internal.Mixed
}
var all *ui.Element
ui.Row(c).MinHeight(d.ControlHeight()).Children(func() { all = multiSelectCheck(c, k, &on, mark, msg(c, "multi_select.all"), full && st.count == 0) })
if search.Shortcut(0, ui.KeyDown) {
all.Focus()
}
if all.Changed() && !all.IsDisabled() {
*ids = multiSelectAll(*ids, st.shown, on, opts.Max)
}
if all.Shortcut(0, ui.KeyDown) && !all.IsDisabled() {
for i, o := range st.shown {
if full && !st.selected[o.Value] {
continue
}
st.focus = o.Value
st.list.ScrollIntoView(i)
c.Invalidate()
break
}
}
st.list.Key = func(i int) any { return st.shown[i].Value }
ui.List(c, &st.list, len(st.shown), func(i int) {
o := st.shown[i]
selected := st.selected[o.Value]
state := internal.Unchecked
if selected {
state = internal.Checked
}
var cb *ui.Element
ui.Row(c).MinHeight(d.ControlHeight()).Children(func() { cb = multiSelectCheck(c, k, &selected, state, o.Label, full && !selected) })
if st.focus == o.Value && !cb.IsDisabled() {
cb.Focus()
st.focus = ""
}
if cb.Changed() && !cb.IsDisabled() {
*ids = multiSelectSet(*ids, o.Value, selected)
}
if cb.IsDisabled() {
return
}
to := i
switch {
case cb.Shortcut(0, ui.KeyDown):
to = assigneeNext(st, i, 1, full)
case cb.Shortcut(0, ui.KeyUp):
to = assigneeNext(st, i, -1, full)
case cb.Shortcut(0, ui.KeyHome):
to = assigneeNext(st, -1, 1, full)
case cb.Shortcut(0, ui.KeyEnd):
to = assigneeNext(st, len(st.shown), -1, full)
}
if to != i {
st.focus = st.shown[to].Value
st.list.ScrollIntoView(to)
c.Invalidate()
}
}).Height(min(240, min(float32(len(st.shown))*d.ControlHeight(), max(d.ControlHeight(), wh-140)))).Padding(4)
if opts.Max > 0 {
ui.Text(c, fmt.Sprintf(msg(c, "multi_select.limit"), len(*ids), opts.Max)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
})
})
})
changed := !slices.Equal(before, *ids)
if dataChanged && !st.open {
for _, id := range *ids {
if _, ok := st.members[id]; !ok {
panic("mujica: AssigneePicker selection contains an unknown ID")
}
}
}
if changed {
st.selected = nil
slog.Debug("mujica: assignee_picker change", "count", len(*ids))
c.Invalidate()
}
return ChoiceResult{Element: row, changed: changed}
}
// assigneeNext skips choices disabled by the selection limit.
func assigneeNext(st *assigneeState, from, direction int, full bool) int {
for i := from + direction; i >= 0 && i < len(st.shown); i += direction {
if !full || st.selected[st.shown[i].Value] {
return i
}
}
return from
}
+154
View File
@@ -0,0 +1,154 @@
package mujica
import (
"fmt"
"slices"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func assigneeTester(ids *[]string, opts *AssigneePickerOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Children(func() {
if AssigneePicker(c, ids, projMembers, *opts).Changed() {
*n++
}
})
}, 520, 520)
}
func TestAssigneePickerSearchAndAvatars(t *testing.T) {
ids, n := []string{"saki"}, 0
tt := assigneeTester(&ids, &AssigneePickerOptions{}, &n)
if !tt.HasText("Sakiko Togawa") {
t.Fatalf("avatar label missing: %v", tt.Texts())
}
_ = tt.Click("Assignees")
tt.Type("mutsu")
if tt.HasText("Uika Misumi") {
t.Fatalf("search should filter: %v", tt.Texts())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if !slices.Contains(ids, "mutsu") || n == 0 {
t.Fatalf("ids %v", ids)
}
}
func TestAssigneePickerMaxDisabledInvalid(t *testing.T) {
ids, n := []string{"saki"}, 0
tt := assigneeTester(&ids, &AssigneePickerOptions{Disabled: true}, &n)
_ = tt.Click("Assignees")
tt.Type("mutsu")
tt.Key(0, ui.KeyEnter)
if len(ids) != 1 || n != 0 {
t.Fatal("disabled picker changed")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for an unknown member")
}
}()
bad := []string{"ghost"}
assigneeTester(&bad, &AssigneePickerOptions{}, &n)
}
func TestAssigneePickerSnapshot(t *testing.T) {
ids := []string{"saki", "mutsu", "uika", "nyamu"}
snapshot(t, "assignee_picker", 560, 80, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { AssigneePicker(c, &ids, projMembers, AssigneePickerOptions{}) })
})
}
func TestAssigneePickerAncestorDisableCloses(t *testing.T) {
ids, disabled := []string{}, false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(disabled).Children(func() { AssigneePicker(c, &ids, projMembers, AssigneePickerOptions{}) })
}, 500, 520)
if err := tt.Click("Assignees"); err != nil {
t.Fatal(err)
}
disabled = true
tt.Frame()
if tt.HasText("Select all") {
t.Fatal("ancestor-disabled popup remains visible")
}
if err := tt.Click("Sakiko Togawa"); err == nil && len(ids) != 0 {
t.Fatal("disabled popup selected a member")
}
}
func TestAssigneePickerLargeData(t *testing.T) {
members := make([]Member, 100000)
for i := range members {
members[i] = Member{ID: fmt.Sprint(i), Name: fmt.Sprintf("Member %d", i)}
}
var ids []string
tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); AssigneePicker(c, &ids, members, AssigneePickerOptions{}) }, 500, 520)
if err := tt.Click("Assignees"); err != nil {
t.Fatal(err)
}
if built := len(tt.Texts()); built > 200 {
t.Fatalf("built %d labels", built)
}
tt.Type("Member 99999")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if !slices.Equal(ids, []string{"99999"}) {
t.Fatalf("selected %v", ids)
}
}
func TestAssigneePickerFullKeyboardSkipsDisabled(t *testing.T) {
ids, changes := []string{"mutsu"}, 0
tt := assigneeTester(&ids, &AssigneePickerOptions{Max: 1}, &changes)
if err := tt.Click("Assignees"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if len(ids) != 0 || changes != 1 {
t.Fatalf("selected = %v, changes = %d", ids, changes)
}
}
func TestAssigneePickerVersionRefreshAndBulkClear(t *testing.T) {
ids, changes := []string{"saki"}, 0
opts := &AssigneePickerOptions{}
tt := assigneeTester(&ids, opts, &changes)
if err := tt.Click("Assignees"); err != nil {
t.Fatal(err)
}
ids[0], opts.Version = "mutsu", 1
tt.Frame()
tt.Type("Mutsumi")
if err := tt.Click("Select all"); err != nil {
t.Fatal(err)
}
if len(ids) != 0 || changes != 1 {
t.Fatalf("selected = %v, changes = %d", ids, changes)
}
}
func TestAssigneePickerCompactHitAreas(t *testing.T) {
var ids []string
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Density: theme.Compact})
ui.Column(c).Padding(16).Children(func() { AssigneePicker(c, &ids, []Member{{ID: "only", Name: "Only Member"}}, AssigneePickerOptions{}) })
}, 500, 520)
if err := tt.Click("Assignees"); err != nil {
t.Fatal(err)
}
for _, label := range []string{"Select all", "Only Member"} {
r, ok := tt.Find(label)
if !ok || r.H < 28 {
t.Fatalf("%s hit height = %g", label, r.H)
}
}
}
+216
View File
@@ -0,0 +1,216 @@
package mujica
import (
"fmt"
"math"
"slices"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BurndownChartOptions configure a BurndownChart.
type BurndownChartOptions struct {
Frame ChartFrame
// Start and End are the sprint's first and last days.
Start, End Date
// Total starts the ideal line, which reaches zero.
Total float64
// Format writes amounts; nil uses up to two decimals.
Format func(v float64) string
// Version changes when remaining work changes in place.
Version int
}
// BurndownChart compares remaining work with the ideal line.
func BurndownChart(c *ui.Context, remaining []float64, opts BurndownChartOptions) ChartResult {
datetimeCheckDate("BurndownChart Start", opts.Start)
datetimeCheckDate("BurndownChart End", opts.End)
if opts.Start.IsZero() || opts.End.Compare(opts.Start) <= 0 || !(opts.Total > 0) || math.IsInf(opts.Total, 0) {
panic(fmt.Sprintf("mujica: BurndownChart needs Start < End and a positive Total, got %v..%v %v", opts.Start, opts.End, opts.Total))
}
days := int((opts.End.civil().Unix()-opts.Start.civil().Unix())/86400) + 1
if len(remaining) > days {
panic(fmt.Sprintf("mujica: BurndownChart has %d values for %d days", len(remaining), days))
}
format := opts.Format
if format == nil {
format = func(v float64) string {
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(v, 'f', 2, 64), "0"), ".")
}
}
ch := newChart(c, opts.Frame)
summary := chartMemo(ch, "burndown-domain", struct {
data string
total float64
version int
}{chartID(remaining), opts.Total, opts.Version}, func() float64 {
hi := opts.Total
for _, v := range remaining {
if v < 0 || math.IsInf(v, 0) {
panic("mujica: BurndownChart amounts must be non-negative and finite or NaN")
}
if !math.IsNaN(v) {
hi = max(hi, v)
}
}
return hi
})
cache := ui.Local(ch.res.Element, "burndown-samples", func() burndownCache { return burndownCache{} })
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
ideal := func(i int) float64 { return opts.Total * (1 - float64(i)/float64(days-1)) }
actual := func(i int) float64 {
if i < len(remaining) {
return remaining[i]
}
return math.NaN()
}
lo, top, step := internal.Nice(0, summary, 4)
ticks := internal.Ticks(lo, top, step)
keys := []chartKey{{Name: msg(c, "burndown_chart.actual"), Color: pal[0], Shape: 0}, {Name: msg(c, "burndown_chart.ideal"), Color: pal[5], Shape: 1}}
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
plot := chartInset(r, 48, 26)
return plot, internal.Linear{D0: 0, D1: float64(days - 1), R0: float64(plot.X), R1: float64(plot.X + plot.W)},
internal.Linear{D0: lo, D1: top, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
n := days
if len(remaining) == 0 {
n = 0
}
spec := chartSpec{n: n, keys: keys, toggle: true}
spec.pick = func(r ui.Rect, x, y float32) int {
plot, xs, _ := geom(r)
if x < plot.X-8 || x > plot.X+plot.W+8 {
return -1
}
return max(0, min(days-1, int(math.Round(xs.Invert(x)))))
}
spec.readout = func(i int) string {
day := opts.Start.AddDays(i)
parts := datetimeMonthName(c, day.Month) + " " + strconv.Itoa(day.Day)
if v := actual(i); !math.IsNaN(v) && !ch.hidden(keys[0].Name) {
parts += " · " + keys[0].Name + " " + format(v)
}
if !ch.hidden(keys[1].Name) {
parts += " · " + keys[1].Name + " " + format(math.Round(ideal(i)*100)/100)
}
return parts
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, xs, ys := geom(r)
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k)
var tx []float32
var tl []string
stride := max(1, int(math.Ceil(float64(days)/max(float64(plot.W)/70, 1))))
for i := 0; i < days; i += stride {
tx = append(tx, xs.Map(float64(i)))
tl = append(tl, strconv.Itoa(opts.Start.AddDays(i).Day))
}
internal.AxisX(p, plot, tx, tl, size, k)
pt := func(i int, v float64) (float32, float32) {
return internal.Snap(p, xs.Map(float64(i))), internal.Snap(p, ys.Map(v))
}
if !ch.hidden(keys[1].Name) {
x0, y0 := pt(0, ideal(0))
x1, y1 := pt(days-1, 0)
burndownDashed(p, x0, y0, x1, y1, keys[1].Color)
}
if !ch.hidden(keys[0].Name) {
var line ui.Path
open := false
width := max(1, int(plot.W*p.Scale()))
data := chartID(remaining)
if cache.data != data || cache.version != opts.Version || cache.days != days || cache.width != width {
cache.data, cache.version, cache.days, cache.width = data, opts.Version, days, width
cache.points = burndownSamples(remaining, days, width)
}
lastRun := -1
for _, point := range cache.points {
x, y := pt(point.index, remaining[point.index])
if open && lastRun == point.run {
line.LineTo(x, y)
} else {
line.MoveTo(x, y)
open = true
}
lastRun = point.run
internal.Marker(p, 0, x, y, 7, keys[0].Color)
}
p.StrokePath(&line, 2, keys[0].Color)
}
if active >= 0 {
ax, _ := pt(active, 0)
p.Line(ax, plot.Y, ax, plot.Y+plot.H, 1, k.TextMuted)
}
}
res, _ := ch.build(spec)
return res
}
// burndownDashed draws the ideal line with a distinct pattern.
func burndownDashed(p *ui.Painter, x0, y0, x1, y1 float32, col ui.Color) {
l := float32(math.Hypot(float64(x1-x0), float64(y1-y0)))
var path ui.Path
for t := float32(0); t < l; t += 10 {
e := min(t+6, l)
path.MoveTo(internal.Snap(p, x0+(x1-x0)*t/l), internal.Snap(p, y0+(y1-y0)*t/l))
path.LineTo(internal.Snap(p, x0+(x1-x0)*e/l), internal.Snap(p, y0+(y1-y0)*e/l))
}
p.StrokePath(&path, 1.5, col)
}
type burndownPoint struct{ index, run int }
type burndownCache struct {
data string
version, days, width int
points []burndownPoint
}
// burndownSamples keeps four finite points per device pixel.
func burndownSamples(values []float64, days, width int) []burndownPoint {
var out []burndownPoint
run := 0
for i := 0; i < len(values); {
bucket := i * width / max(days-1, 1)
var first, low, high, last burndownPoint
count := 0
for i < len(values) && i*width/max(days-1, 1) == bucket {
v := values[i]
if math.IsNaN(v) {
run++
i++
continue
}
point := burndownPoint{index: i, run: run}
if count == 0 {
first, low, high = point, point, point
}
if v < values[low.index] {
low = point
}
if v > values[high.index] {
high = point
}
last = point
count++
i++
}
if count == 0 {
continue
}
points := []burndownPoint{first, low, high, last}
slices.SortFunc(points, func(a, b burndownPoint) int { return a.index - b.index })
for j, point := range points {
if j == 0 || points[j-1].index != point.index {
out = append(out, point)
}
}
}
return out
}
+145
View File
@@ -0,0 +1,145 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
var burndownRemaining = []float64{40, 38, 35, 35, 30, 26, math.NaN(), 20, 19}
func burndownTester(remaining []float64, opts BurndownChartOptions, points *[]int) *ui.Tester {
opts.Frame.Label = "Sprint 12 burndown"
opts.Start, opts.End = oct(1), oct(14)
if opts.Total == 0 {
opts.Total = 40
}
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Width(600).Children(func() {
if r := BurndownChart(c, remaining, opts); r.Changed() {
*points = append(*points, r.Point)
}
})
}, 640, 400)
}
func TestBurndownChartReadoutAndKeys(t *testing.T) {
var points []int
tt := burndownTester(burndownRemaining, BurndownChartOptions{}, &points)
if !tt.HasText("Remaining") || !tt.HasText("Ideal") {
t.Fatalf("legend %v", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if !tt.HasText("October 3 · Remaining 35 · Ideal 33.85") {
t.Fatalf("readout %v", tt.Texts())
}
if a := tt.Announcements(); len(a) == 0 || a[len(a)-1] != "October 3 · Remaining 35 · Ideal 33.85" {
t.Fatalf("announce %q", a)
}
tt.Key(0, ui.KeyEnd)
if !tt.HasText("October 14 · Ideal 0") {
t.Fatalf("future day shows only the ideal: %v", tt.Texts())
}
_ = tt.Click("Ideal")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyHome)
if !tt.HasText("October 1 · Remaining 40") || tt.HasText("Ideal 40") {
t.Fatal("hidden series in readout")
}
}
func TestBurndownChartEmptyAndInvalid(t *testing.T) {
var points []int
tt := burndownTester(nil, BurndownChartOptions{Frame: ChartFrame{Status: DataLoading}}, &points)
if !tt.HasText("Loading") || tt.HasText("October") {
t.Fatalf("loading state %v", tt.Texts())
}
for _, bad := range []BurndownChartOptions{{Total: -1}} {
func() {
defer func() {
if recover() == nil {
t.Error("expected panic")
}
}()
burndownTester(nil, bad, &points)
}()
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for too many days")
}
}()
burndownTester(make([]float64, 20), BurndownChartOptions{}, &points)
}
func TestBurndownChartSnapshot(t *testing.T) {
snapshot(t, "burndown_chart", 620, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
BurndownChart(c, burndownRemaining, BurndownChartOptions{Frame: ChartFrame{Label: "Burndown"}, Start: oct(1), End: oct(14), Total: 40})
})
})
}
func TestBurndownChartInvalidAmounts(t *testing.T) {
for _, values := range [][]float64{{-1}, {math.Inf(1)}} {
t.Run("amount", func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected invalid amount panic")
}
}()
var points []int
burndownTester(values, BurndownChartOptions{}, &points)
})
}
}
func TestBurndownChartEmptyFailedDisabled(t *testing.T) {
var points []int
tt := burndownTester(nil, BurndownChartOptions{}, &points)
if !tt.HasText("No data") {
t.Fatal("missing empty state")
}
tt = burndownTester(nil, BurndownChartOptions{Frame: ChartFrame{Status: DataFailed, Error: "Unavailable"}}, &points)
if !tt.HasText("Unavailable") {
t.Fatal("missing error state")
}
tt = ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(true).Children(func() {
r := BurndownChart(c, burndownRemaining, BurndownChartOptions{Frame: ChartFrame{Label: "Burndown"}, Start: oct(1), End: oct(14), Total: 40})
if _, ok := r.Toggled(); ok {
t.Fatal("disabled legend toggled")
}
})
}, 600, 340)
_ = tt.Click("Ideal")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if len(tt.Announcements()) != 0 {
t.Fatal("disabled chart announced a point")
}
}
func TestBurndownChartLargeData(t *testing.T) {
values := make([]float64, 100000)
for i := range values {
values[i] = float64(100000 - i)
}
opts := BurndownChartOptions{Frame: ChartFrame{Label: "Large burndown"}, Start: Date{Year: 1900, Month: 1, Day: 1}, End: Date{Year: 1900, Month: 1, Day: 1}.AddDays(99999), Total: 100000}
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); BurndownChart(c, values, opts) }, 640, 360)
points := burndownSamples(values, 100000, 600)
if len(points) > 2404 {
t.Fatalf("built %d points", len(points))
}
if points[0].index != 0 || points[len(points)-1].index != 99999 {
t.Fatal("downsampling lost endpoints")
}
}
+43
View File
@@ -0,0 +1,43 @@
# 361 KanbanBoard · 任务看板
## 用途
任务看板用于项目管理界面。
## 最小示例
```go
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.KanbanBoard(c, &tasks, mujica.KanbanBoardOptions{Columns: []mujica.KanbanColumn{{Status: "todo"}, {Status: "done", WIP: 2}}, Statuses: statuses})
if move, ok := r.Moved(); ok {
log.Println("move", move.ID, move.To, move.Index)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`tasks *[]Task` 为受控数据。`Columns` 至少一列;`Statuses` 提供其状态;`Members`、`Labels` 解析关联 ID。`Version` 标识原地数据更改;`Disabled` 禁用移动与打开;`Label` 为无障碍名称。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Moved()` 返回已应用的 `KanbanMove{ID, From, To, Index}`;`Opened()` 返回卡片 ID。
## 键盘操作
Tab 聚焦卡片;Enter/Space 打开;Alt+左右跨列;Alt+上下排序;可拖放到卡片之前或列末。
## 限制
修改原切片任务时递增 `Version`。列按 `Columns` 顺序排列;`WIP=0` 表示自由容量。网络与持久化由调用方处理。
+44
View File
@@ -0,0 +1,44 @@
# 362 TaskList · 任务列表
## 用途
任务列表用于项目管理界面。
## 最小示例
```go
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
filter := mujica.TaskShowAll
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TaskList(c, tasks, &filter, mujica.TaskListOptions{GroupBy: mujica.TaskGroupStatus, Statuses: statuses})
if id, ok := r.Changed(); ok {
log.Println("edit", id)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`tasks []Task`、`filter *TaskFilter`;`GroupBy` 为无分组、状态或优先级。按状态分组必传 `Statuses`。`Members` 解析负责人;`Today` 为逾期基准;`Version`、`Disabled`、`Label` 控制数据刷新与交互。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 返回编辑任务 ID;`Opened()` 返回打开任务 ID。筛选值直接写入 `filter`。
## 键盘操作
Tab 在筛选器和任务控件间移动;空格切换完成;Enter 打开;F2 编辑标题。
## 限制
可见行虚拟化;外部原地数据修改递增 `Version`。列表需父容器提供有限高度。
+42
View File
@@ -0,0 +1,42 @@
# 363 TaskItem · 任务行
## 用途
任务行用于项目管理界面。
## 最小示例
```go
task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TaskItem(c, &task, mujica.TaskItemOptions{})
if id, ok := r.Changed(); ok {
log.Println("edit", id)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`task *Task` 保存标题、完成、优先级、日期、负责人;`Members` 解析负责人;`Today` 默认当前日期;`Disabled` 禁用输入。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 返回完成或标题编辑的任务 ID;`Opened()` 返回打开任务 ID。
## 键盘操作
Tab 在复选框和标题间移动;空格切换完成;Enter 打开;F2 或双击开始编辑;Enter 保存;Escape 取消。
## 限制
任务标题保存时去除首尾空白;空标题保留编辑态。业务字段来自调用方。
+43
View File
@@ -0,0 +1,43 @@
# 364 TaskDetailPanel · 任务详情侧栏
## 用途
任务详情侧栏用于项目管理界面。
## 最小示例
```go
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TaskDetailPanel(c, &task, mujica.TaskDetailPanelOptions{Statuses: statuses, Activity: func() { ui.Text(c, "Release planned") }})
if r.Changed() {
log.Println("task edited", task.ID)
}
if r.Closed() {
log.Println("close", task.ID)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`task *Task` 为受控任务;`Statuses`、`Members`、`Labels` 为关联数据;`Activity func()` 构建活动记录;`Today`、`Disabled` 控制日期与交互;`Version` 用于外部原地修改任务、成员或子任务。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 表示字段更改;`Closed()` 表示关闭按钮或 Escape。由调用方控制侧栏显隐。
## 键盘操作
Tab 在字段间移动;选择框使用方向键和 Enter;Escape 返回关闭事件;子任务 Enter 新增。
## 限制
组件是可嵌入的侧栏内容;容器负责布局与滚动。活动记录通过 `Activity` 插槽构建;外部原地更改后递增 `Version`。
+38
View File
@@ -0,0 +1,38 @@
# 365 SubtaskList · 子任务列表
## 用途
子任务列表用于项目管理界面。
## 最小示例
```go
subtasks := []mujica.Subtask{{ID: "one", Title: "Review", Done: true}, {ID: "two", Title: "Ship"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SubtaskList(c, &subtasks, mujica.SubtaskListOptions{}).Changed() {
log.Println("subtasks changed", len(subtasks))
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`subs *[]Subtask` 保存子任务;`Disabled` 禁用所有操作;`Label` 为进度与列表名称;`Version` 用于外部原地修改。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 表示勾选、新增、删除或排序;变更已写入切片。
## 键盘操作
Tab 移动;Space 切换完成;新增输入框 Enter 提交;Alt+上下排序;拖动握柄排序。
## 限制
新增 ID 按 `sub-N` 生成;原有 ID 必须唯一且非空。空白新增内容保留输入。列表虚拟化,最多显示八行高度。
+44
View File
@@ -0,0 +1,44 @@
# 368 SprintBoard · 迭代看板
## 用途
迭代看板用于项目管理界面。
## 最小示例
```go
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
sprint := mujica.Sprint{Name: "Release sprint", Goal: "Ship", Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SprintBoard(c, sprint, &tasks, mujica.SprintBoardOptions{Board: mujica.KanbanBoardOptions{Columns: []mujica.KanbanColumn{{Status: "todo"}, {Status: "done", WIP: 2}}, Statuses: statuses}})
if move, ok := r.Moved(); ok {
log.Println("move", move.ID, move.To)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`Sprint{Name, Goal, Start, End}`;日期必填且顺序有效。`Board` 为看板参数;`Today` 默认当前日期。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
与 `KanbanBoard` 相同;移动更新受控任务并刷新完成数。
## 键盘操作
看板卡片支持 Tab、Enter/Space、Alt+方向键与拖放。
## 限制
完成度按工作流状态统计,取消状态排除。日期为包含首尾的区间;外部原地修改递增 `Board.Version`。
+46
View File
@@ -0,0 +1,46 @@
# 369 BurndownChart · 燃尽图
## 用途
燃尽图用于项目管理界面。
## 最小示例
```go
remaining := []float64{40, 32, 25, 18}
opts := mujica.BurndownChartOptions{Frame: mujica.ChartFrame{Label: "Release burndown"}, Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}, Total: 40}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.BurndownChart(c, remaining, opts)
if r.Changed() {
log.Println("point", r.Point)
}
if name, ok := r.Toggled(); ok {
log.Println("series", name)
}
if r.Retried() {
log.Println("retry")
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`Frame` 提供必填 `Label`、默认 240 DIP `Height`、`Status/Error/Empty`;`Start < End`,`Total > 0`;`Format` 自定义读数;`Version` 刷新原地修改。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 与 `Point` 返回活跃日期索引;`Toggled()` 返回图例名称;`Retried()` 请求重试。
## 键盘操作
Tab 聚焦图例与图表;Space 切换图例;方向键逐日读取;Home/End 首尾;Escape 清除读数。
## 限制
实际值以 `NaN` 表示缺失;数组从 Start 连续对应日期。数据加载与失败由调用方提供;数值须有限且非负。
+36
View File
@@ -0,0 +1,36 @@
# 370 PriorityIndicator · 优先级指示
## 用途
优先级指示用于项目管理界面。
## 最小示例
```go
priority := mujica.PriorityHigh
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.PriorityIndicator(c, priority, mujica.PriorityIndicatorOptions{})
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`p Priority` 决定等级;`IconOnly` 默认 false,开启后保留无障碍名称。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
静态展示。
## 键盘操作
静态展示,通过无障碍名称读取完整等级。
## 限制
等级仅接受 None、Low、Medium、High、Urgent。
+39
View File
@@ -0,0 +1,39 @@
# 371 StatusSelect · 状态选择
## 用途
状态选择用于项目管理界面。
## 最小示例
```go
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
value := "todo"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.StatusSelect(c, &value, statuses, mujica.StatusSelectOptions{}).Changed() {
log.Println("status", value)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`value *string`、`statuses []WorkflowStatus`;状态 `Group` 为 Backlog、Unstarted、Started、Completed、Canceled;`Disabled` 和 `Label` 控制交互与名称。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 表示受控状态 ID 更改。
## 键盘操作
Enter/Space 或上下键打开;上下、Home/End 定位;Enter 确认;Escape 关闭。
## 限制
状态 ID 唯一且非空;当前值须属于状态列表。状态按五个工作流阶段分组。
+39
View File
@@ -0,0 +1,39 @@
# 372 AssigneePicker · 负责人选择
## 用途
负责人选择用于项目管理界面。
## 最小示例
```go
members := []mujica.Member{{ID: "ann", Name: "Ann"}, {ID: "ben", Name: "Ben"}}
ids := []string{"ann"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.AssigneePicker(c, &ids, members, mujica.AssigneePickerOptions{Max: 2}).Changed() {
log.Println("assignees", ids)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`ids *[]string`、`members []Member`;`Max=0` 表示自由数量;`Disabled`、`Label` 控制交互与名称;`Version` 用于外部原地修改。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 表示受控成员 ID 列表更改。
## 键盘操作
Enter/Space 打开;输入搜索;下键进入选项;Space/Enter 勾选;Backspace 移除最后一项;Escape 关闭。
## 限制
成员 ID 唯一且非空;所有已选 ID 必须存在。头像使用姓名回退;网络成员查询由调用方提供。搜索结果按可见行虚拟化。
+38
View File
@@ -0,0 +1,38 @@
# 373 DueDatePicker · 截止日期选择
## 用途
截止日期选择用于项目管理界面。
## 最小示例
```go
var due mujica.Date
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.DueDatePicker(c, &due, mujica.DueDatePickerOptions{}).Changed() {
log.Println("due", due)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`value *Date`;`Today` 默认当前日期;`WeekStart` 默认本地周起始;`Done`、`Disabled`、`Label` 控制展示与交互。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 表示日期更改;返回 `Input` 为日期编辑器。
## 键盘操作
Tab 到快捷项、日期输入与日历按钮;Enter 选择;日历方向键移动;Escape 关闭。
## 限制
零日期表示清空;Next week 选择下一个周起始日。`Done` 让已完成任务显示普通日期。
+39
View File
@@ -0,0 +1,39 @@
# 374 LabelManager · 标签管理
## 用途
标签管理用于项目管理界面。
## 最小示例
```go
labels := []mujica.TaskLabel{{ID: "ui", Name: "UI", Color: mujica.EventInfo}}
selected := []string{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.LabelManager(c, &labels, &selected, mujica.LabelManagerOptions{}).Changed() {
log.Println("labels changed", len(labels), selected)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`labels *[]TaskLabel`、`selected *[]string`;ID 非空唯一;`Color` 为六种 EventColor;`Disabled`、`Label` 控制交互与名称;`Version` 用于外部原地修改。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 表示选择、创建、重命名、颜色或删除变更。删除同步移除已选 ID。
## 键盘操作
Tab 移动;Space 勾选;新增输入 Enter 创建;编辑颜色按钮打开编辑;Enter 保存名称;Escape 结束编辑。
## 限制
名称去除首尾空白并按大小写折叠检测重复;颜色选中立即提交。列表虚拟化,最多显示八行高度。
+39
View File
@@ -0,0 +1,39 @@
# 375 IssueCard · 任务卡片
## 用途
任务卡片用于项目管理界面。
## 最小示例
```go
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.IssueCard(c, task, mujica.IssueCardOptions{Statuses: statuses}).Clicked() {
log.Println("open", task.ID)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`task Task`;`Statuses` 解析状态;`Members`、`Labels` 解析关联数据;`Selected` 显示选中底色与边标;`Disabled` 禁用激活。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
返回 `Element.Clicked()` 为卡片激活。
## 键盘操作
Tab 聚焦;Enter/Space 激活。
## 限制
长标题最多两行;头像组折叠到三个;字段与关联数据由调用方提供。
+38
View File
@@ -0,0 +1,38 @@
# 376 IssueIdBadge · 任务编号
## 用途
任务编号用于项目管理界面。
## 最小示例
```go
id := "ENG-123"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.IssueIdBadge(c, id, mujica.IssueIdBadgeOptions{Copyable: true}).Changed() {
log.Println("copied", id)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`id string`;`Copyable` 默认 false;`Disabled` 禁用复制。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 表示复制,剪贴板由组件写入;同时朗读复制结果。
## 键盘操作
Copyable 开启时 Tab 聚焦,Enter/Space 复制。
## 限制
编号匹配大写项目名、连字符、正整数,例如 ENG-123。复制提示保持 1500 ms。
+36
View File
@@ -0,0 +1,36 @@
# 377 MilestoneProgress · 里程碑进度
## 用途
里程碑进度用于项目管理界面。
## 最小示例
```go
milestone := mujica.Milestone{Title: "Release", Due: mujica.Date{Year: 2026, Month: 10, Day: 14}, Done: 3, Total: 8}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MilestoneProgress(c, milestone, mujica.MilestoneProgressOptions{})
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`Milestone{Title, Due, Done, Total}`;`Today` 默认当前日期;零 Due 表示无日期。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
静态展示。
## 键盘操作
静态展示,进度条通过无障碍名称与百分比读取。
## 限制
要求 0 ≤ Done ≤ Total;零总量显示 0%;完成后显示达成标记。
+36
View File
@@ -0,0 +1,36 @@
# 378 WorkloadView · 成员负载
## 用途
成员负载用于项目管理界面。
## 最小示例
```go
loads := []mujica.Workload{{Member: mujica.Member{ID: "ann", Name: "Ann"}, Assigned: 46, Capacity: 40}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WorkloadView(c, loads, mujica.WorkloadViewOptions{})
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`loads []Workload`;`Unit` 默认小时;`Label` 为列表名称;`Version` 刷新原地数据更改。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
静态展示。
## 键盘操作
静态行通过无障碍名称读取负载与超出量;滚动查看更多成员。
## 限制
成员 ID 唯一;Assigned 有限非负,Capacity 有限正数。可见行虚拟化;父容器需提供有限高度;原地修改递增 Version。
+38
View File
@@ -0,0 +1,38 @@
# 379 TimeTracker · 工时计时
## 用途
工时计时用于项目管理界面。
## 最小示例
```go
var state mujica.TimeTrackerState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.TimeTracker(c, &state, mujica.TimeTrackerOptions{}).Changed() {
log.Println("tracked", state.Total(c.Now()))
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`state *TimeTrackerState`;`Now` 固定时钟供测试;`Disabled` 禁用操作;`Label` 为读数名称。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 表示开始、停止或手动修正;`Entries` 和 `Adjust` 由调用方持有。
## 键盘操作
Tab 移动;Enter/Space 开始停止;停止后打开修正,编辑时长,Apply 提交。
## 限制
只有最后一项可处于运行态;时段按顺序且互不重叠;累计时长非负。隐藏时保留计时状态。
+42
View File
@@ -0,0 +1,42 @@
# 380 PomodoroTimer · 番茄钟
## 用途
番茄钟用于项目管理界面。
## 最小示例
```go
var state mujica.PomodoroState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PomodoroTimer(c, &state, mujica.PomodoroTimerOptions{Focus: 25 * time.Minute})
if r.Changed() {
log.Println("timer", state.Phase(), state.Running())
}
if phase, ok := r.Finished(); ok {
log.Println("finished", phase)
}
}
ui.Render(view, 1000, 700, 1)
```
## 参数
`state *PomodoroState`;`Focus/Break/LongBreak` 默认 25/5/15 分钟;`LongEvery` 默认 4;`Now` 固定时钟;`Disabled`、`Label` 控制交互与名称。
## 状态
业务状态由调用方持有;主题、密度与文本缩放遵循窗口设置。交互控件提供焦点、悬停和禁用状态;展示组件保留语义名称。
## 事件
`Changed()` 返回操作事件;`Finished()` 返回自然完成的阶段,调用方可播放提示。
## 键盘操作
Tab 移动;Enter/Space 开始、暂停、跳过与重置。
## 限制
时长为整秒;自然完成后等待用户启动下一阶段。隐藏组件停止请求帧,恢复显示时读取经过时间。
+3
View File
@@ -83,6 +83,9 @@ func TestDocSnippetsAreCompiledExamples(t *testing.T) {
// TestDocsMatchGalleryPages wants exactly one doc and one gallery page per component ID.
func TestDocsMatchGalleryPages(t *testing.T) {
if n := len(readGlob(t, "docs/components/*.md")); n != 318 {
t.Fatalf("component docs = %d, want 318", n)
}
pages := map[string]int{}
for _, src := range readGlob(t, "examples/gallery/pages_*.go") {
for _, m := range docsPageID.FindAllStringSubmatch(src, -1) {
+76
View File
@@ -0,0 +1,76 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// DueDatePickerOptions configure a DueDatePicker.
type DueDatePickerOptions struct {
// Done stops a past date from showing as overdue.
Done bool
WeekStart WeekStart
// Today anchors shortcuts and overdue marks; zero uses today.
Today Date
Disabled bool
Label string
}
// DueDatePicker combines date shortcuts, a calendar and overdue marks.
func DueDatePicker(c *ui.Context, value *Date, opts DueDatePickerOptions) DateTimeResult {
datetimeCheckDate("DueDatePicker value", *value)
k := Tokens(c)
d := Density(c)
today := datetimeToday(c, opts.Today)
first := calendarFirstWeekday(c, opts.WeekStart)
nextWeek := today.AddDays(7 - (int(today.Weekday())-int(first)+7)%7)
label := opts.Label
if label == "" {
label = msg(c, "due_date_picker.label")
}
old := *value
col := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
var r DateTimeResult
col.Children(func() {
ui.Row(c).Gap(d.Unit()).Wrap().Children(func() {
for _, q := range []struct {
key string
day Date
}{{"due_date_picker.today", today}, {"due_date_picker.tomorrow", today.AddDays(1)}, {"due_date_picker.next_week", nextWeek}} {
variant := Secondary
text := msg(c, q.key)
if *value == q.day {
variant = Outline
text = "✓ " + text
}
b := Button(c, text, ButtonOptions{Variant: variant}).Label(msg(c, q.key))
if *value == q.day {
b.Background(k.Selection).TextColor(k.Text)
}
if b.Clicked() && !b.IsDisabled() {
*value = q.day
}
}
})
r = DatePicker(c, value, DatePickerOptions{Label: label, WeekStart: opts.WeekStart, Today: today, Clearable: true, Disabled: col.IsDisabled()})
if text, late := projectDueText(c, *value, today, opts.Done); text != "" {
fg, icon := k.TextMuted, "calendar"
if late {
fg, icon = k.Danger, "triangle-alert"
}
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(fg).Role(ui.RoleStatus).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(14, 14)
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize))
})
}
})
changed := *value != old
if changed {
slog.Debug("mujica: due_date_picker change", "from", old, "to", *value)
}
return DateTimeResult{Element: col, Input: r.Input, changed: changed}
}
+83
View File
@@ -0,0 +1,83 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func dueTester(v *Date, opts *DueDatePickerOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
o := *opts
o.Today = calendarToday
ui.Column(c).Padding(16).Children(func() {
if DueDatePicker(c, v, o).Changed() {
*n++
}
})
}, 480, 520)
}
func TestDueDatePickerShortcuts(t *testing.T) {
v, n := Date{}, 0
tt := dueTester(&v, &DueDatePickerOptions{}, &n)
_ = tt.Click("Tomorrow")
if v != oct(6) || n != 1 || !tt.HasText("Due tomorrow") {
t.Fatalf("tomorrow %v", v)
}
_ = tt.Click("Next week")
if v != oct(11) {
t.Fatalf("next week (Sunday start) %v", v)
}
_ = tt.Click("Today")
if v != oct(5) || !tt.HasText("Due today") {
t.Fatalf("today %v", v)
}
_ = tt.Click("Open calendar")
_ = tt.Click(datetimeTestLabel(oct(20)))
if v != oct(20) || n != 4 {
t.Fatalf("calendar pick %v (%d)", v, n)
}
}
func TestDueDatePickerOverdueAndDone(t *testing.T) {
v, n := oct(2), 0
opts := &DueDatePickerOptions{}
tt := dueTester(&v, opts, &n)
if !tt.HasText("Overdue by 3 days") {
t.Fatalf("overdue missing: %v", tt.Texts())
}
opts.Done = true
tt.Frame()
if tt.HasText("Overdue") {
t.Fatal("a done task is not overdue")
}
opts.Disabled = true
tt.Frame()
_ = tt.Click("Today")
if v != oct(2) || n != 0 {
t.Fatal("disabled picker changed")
}
}
func TestDueDatePickerSnapshot(t *testing.T) {
v := oct(2)
snapshot(t, "due_date_picker", 360, 140, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { DueDatePicker(c, &v, DueDatePickerOptions{Today: calendarToday}) })
})
}
func TestDueDatePickerSelectedShortcutMark(t *testing.T) {
v, n := oct(5), 0
tt := dueTester(&v, &DueDatePickerOptions{}, &n)
if !tt.HasText("✓ Today") {
t.Fatal("selected shortcut needs a non-color mark")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if v != oct(6) || n != 1 {
t.Fatalf("keyboard shortcut date %v, changes %d", v, n)
}
}
+242
View File
@@ -0,0 +1,242 @@
package mujica_test
import (
"github.com/egoist/mygo/ui"
"log"
mujica "mujicaui"
"time"
)
func ExampleKanbanBoard() {
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.KanbanBoard(c, &tasks, mujica.KanbanBoardOptions{Columns: []mujica.KanbanColumn{{Status: "todo"}, {Status: "done", WIP: 2}}, Statuses: statuses})
if move, ok := r.Moved(); ok {
log.Println("move", move.ID, move.To, move.Index)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleTaskList() {
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
filter := mujica.TaskShowAll
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TaskList(c, tasks, &filter, mujica.TaskListOptions{GroupBy: mujica.TaskGroupStatus, Statuses: statuses})
if id, ok := r.Changed(); ok {
log.Println("edit", id)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleTaskItem() {
task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TaskItem(c, &task, mujica.TaskItemOptions{})
if id, ok := r.Changed(); ok {
log.Println("edit", id)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleTaskDetailPanel() {
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TaskDetailPanel(c, &task, mujica.TaskDetailPanelOptions{Statuses: statuses, Activity: func() { ui.Text(c, "Release planned") }})
if r.Changed() {
log.Println("task edited", task.ID)
}
if r.Closed() {
log.Println("close", task.ID)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleSubtaskList() {
subtasks := []mujica.Subtask{{ID: "one", Title: "Review", Done: true}, {ID: "two", Title: "Ship"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SubtaskList(c, &subtasks, mujica.SubtaskListOptions{}).Changed() {
log.Println("subtasks changed", len(subtasks))
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleSprintBoard() {
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
sprint := mujica.Sprint{Name: "Release sprint", Goal: "Ship", Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SprintBoard(c, sprint, &tasks, mujica.SprintBoardOptions{Board: mujica.KanbanBoardOptions{Columns: []mujica.KanbanColumn{{Status: "todo"}, {Status: "done", WIP: 2}}, Statuses: statuses}})
if move, ok := r.Moved(); ok {
log.Println("move", move.ID, move.To)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleBurndownChart() {
remaining := []float64{40, 32, 25, 18}
opts := mujica.BurndownChartOptions{Frame: mujica.ChartFrame{Label: "Release burndown"}, Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}, Total: 40}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.BurndownChart(c, remaining, opts)
if r.Changed() {
log.Println("point", r.Point)
}
if name, ok := r.Toggled(); ok {
log.Println("series", name)
}
if r.Retried() {
log.Println("retry")
}
}
ui.Render(view, 1000, 700, 1)
}
func ExamplePriorityIndicator() {
priority := mujica.PriorityHigh
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.PriorityIndicator(c, priority, mujica.PriorityIndicatorOptions{})
}
ui.Render(view, 1000, 700, 1)
}
func ExampleStatusSelect() {
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
value := "todo"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.StatusSelect(c, &value, statuses, mujica.StatusSelectOptions{}).Changed() {
log.Println("status", value)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleAssigneePicker() {
members := []mujica.Member{{ID: "ann", Name: "Ann"}, {ID: "ben", Name: "Ben"}}
ids := []string{"ann"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.AssigneePicker(c, &ids, members, mujica.AssigneePickerOptions{Max: 2}).Changed() {
log.Println("assignees", ids)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleDueDatePicker() {
var due mujica.Date
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.DueDatePicker(c, &due, mujica.DueDatePickerOptions{}).Changed() {
log.Println("due", due)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleLabelManager() {
labels := []mujica.TaskLabel{{ID: "ui", Name: "UI", Color: mujica.EventInfo}}
selected := []string{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.LabelManager(c, &labels, &selected, mujica.LabelManagerOptions{}).Changed() {
log.Println("labels changed", len(labels), selected)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleIssueCard() {
statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.IssueCard(c, task, mujica.IssueCardOptions{Statuses: statuses}).Clicked() {
log.Println("open", task.ID)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleIssueIdBadge() {
id := "ENG-123"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.IssueIdBadge(c, id, mujica.IssueIdBadgeOptions{Copyable: true}).Changed() {
log.Println("copied", id)
}
}
ui.Render(view, 1000, 700, 1)
}
func ExampleMilestoneProgress() {
milestone := mujica.Milestone{Title: "Release", Due: mujica.Date{Year: 2026, Month: 10, Day: 14}, Done: 3, Total: 8}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MilestoneProgress(c, milestone, mujica.MilestoneProgressOptions{})
}
ui.Render(view, 1000, 700, 1)
}
func ExampleWorkloadView() {
loads := []mujica.Workload{{Member: mujica.Member{ID: "ann", Name: "Ann"}, Assigned: 46, Capacity: 40}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WorkloadView(c, loads, mujica.WorkloadViewOptions{})
}
ui.Render(view, 1000, 700, 1)
}
func ExampleTimeTracker() {
var state mujica.TimeTrackerState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.TimeTracker(c, &state, mujica.TimeTrackerOptions{}).Changed() {
log.Println("tracked", state.Total(c.Now()))
}
}
ui.Render(view, 1000, 700, 1)
}
func ExamplePomodoroTimer() {
var state mujica.PomodoroState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PomodoroTimer(c, &state, mujica.PomodoroTimerOptions{Focus: 25 * time.Minute})
if r.Changed() {
log.Println("timer", state.Phase(), state.Running())
}
if phase, ok := r.Finished(); ok {
log.Println("finished", phase)
}
}
ui.Render(view, 1000, 700, 1)
}
+80 -1
View File
@@ -1,3 +1,82 @@
package main
var projectsPages = []page{}
const projectsCategory = "Projects"
var projectsPages = append(append(projectsTaskPages, projectsFieldPages...), projectsTimePages...)
var projectsTaskPages = []page{
{ID: "361", Name: "KanbanBoard", ZhName: "任务看板", Category: projectsCategory, Demo: projectsDemoKanbanBoard, Keys: "Tab 聚焦卡片;Enter/Space 打开;Alt+左右跨列;Alt+上下排序;可拖放到卡片之前或列末。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.KanbanBoard(c, &tasks, mujica.KanbanBoardOptions{Columns: []mujica.KanbanColumn{{Status: "todo"}, {Status: "done", WIP: 2}}, Statuses: statuses})
if move, ok := r.Moved(); ok {
log.Println("move", move.ID, move.To, move.Index)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "362", Name: "TaskList", ZhName: "任务列表", Category: projectsCategory, Demo: projectsDemoTaskList, Keys: "Tab 在筛选器和任务控件间移动;空格切换完成;Enter 打开;F2 编辑标题。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
filter := mujica.TaskShowAll
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TaskList(c, tasks, &filter, mujica.TaskListOptions{GroupBy: mujica.TaskGroupStatus, Statuses: statuses})
if id, ok := r.Changed(); ok {
log.Println("edit", id)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "363", Name: "TaskItem", ZhName: "任务行", Category: projectsCategory, Demo: projectsDemoTaskItem, Keys: "Tab 在复选框和标题间移动;空格切换完成;Enter 打开;F2 或双击开始编辑;Enter 保存;Escape 取消。", Code: `task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TaskItem(c, &task, mujica.TaskItemOptions{})
if id, ok := r.Changed(); ok {
log.Println("edit", id)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "364", Name: "TaskDetailPanel", ZhName: "任务详情侧栏", Category: projectsCategory, Demo: projectsDemoTaskDetailPanel, Keys: "Tab 在字段间移动;选择框使用方向键和 Enter;Escape 返回关闭事件;子任务 Enter 新增。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TaskDetailPanel(c, &task, mujica.TaskDetailPanelOptions{Statuses: statuses, Activity: func() { ui.Text(c, "Release planned") }})
if r.Changed() {
log.Println("task edited", task.ID)
}
if r.Closed() {
log.Println("close", task.ID)
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "365", Name: "SubtaskList", ZhName: "子任务列表", Category: projectsCategory, Demo: projectsDemoSubtaskList, Keys: "Tab 移动;Space 切换完成;新增输入框 Enter 提交;Alt+上下排序;拖动握柄排序。", Code: `subtasks := []mujica.Subtask{{ID: "one", Title: "Review", Done: true}, {ID: "two", Title: "Ship"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SubtaskList(c, &subtasks, mujica.SubtaskListOptions{}).Changed() {
log.Println("subtasks changed", len(subtasks))
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "368", Name: "SprintBoard", ZhName: "迭代看板", Category: projectsCategory, Demo: projectsDemoSprintBoard, Keys: "看板卡片支持 Tab、Enter/Space、Alt+方向键与拖放。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
tasks := []mujica.Task{{ID: "ENG-1", Title: "Plan the release", Status: "todo"}}
sprint := mujica.Sprint{Name: "Release sprint", Goal: "Ship", Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SprintBoard(c, sprint, &tasks, mujica.SprintBoardOptions{Board: mujica.KanbanBoardOptions{Columns: []mujica.KanbanColumn{{Status: "todo"}, {Status: "done", WIP: 2}}, Statuses: statuses}})
if move, ok := r.Moved(); ok {
log.Println("move", move.ID, move.To)
}
if id, ok := r.Opened(); ok {
log.Println("open", id)
}
}
ui.Render(view, 1000, 700, 1)`},
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"fmt"
"slices"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
var projectsStatuses = []mujica.WorkflowStatus{
{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted},
{ID: "doing", Name: "In progress", Group: mujica.StatusStarted},
{ID: "done", Name: "Done", Group: mujica.StatusCompleted},
}
var projectsMembers = []mujica.Member{{ID: "ann", Name: "Ann"}, {ID: "ben", Name: "Ben"}, {ID: "cy", Name: "Cy"}}
var projectsLabels = []mujica.TaskLabel{{ID: "ui", Name: "UI", Color: mujica.EventInfo}, {ID: "release", Name: "Release", Color: mujica.EventSuccess}}
func projectsCheck(c *ui.Context, log func(string), value *bool, label string) {
if mujica.Checkbox(c, value, label, mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprintf("%s = %v", label, *value))
}
}
func projectsChoice[T comparable](c *ui.Context, log func(string), value *T, options []mujica.SelectOption[T], label string) {
if mujica.Select(c, value, options, mujica.SelectOptions{Label: label}).Changed() {
log(fmt.Sprintf("%s = %v", label, *value))
}
}
type projectsTaskState struct {
tasks []mujica.Task
disabled bool
version int
group mujica.TaskGrouping
filter mujica.TaskFilter
wip float64
closed bool
}
func projectsTasks(c *ui.Context, key string) *projectsTaskState {
return *ui.Local(c.Root(), key, func() *projectsTaskState {
return &projectsTaskState{wip: 2, tasks: []mujica.Task{
{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh, Due: mujica.Date{Year: 2026, Month: 10, Day: 3}, Assignees: []string{"ann"}, Labels: []string{"ui"}, Subtasks: []mujica.Subtask{{ID: "one", Title: "Review", Done: true}, {ID: "two", Title: "Ship"}}},
{ID: "ENG-2", Title: "Check keyboard paths", Status: "doing", Priority: mujica.PriorityUrgent, Assignees: []string{"ben"}, Labels: []string{"release"}},
{ID: "ENG-3", Title: "Publish docs", Status: "done", Done: true, Priority: mujica.PriorityLow},
}}
})
}
func projectsBoardOptions(s *projectsTaskState) mujica.KanbanBoardOptions {
return mujica.KanbanBoardOptions{Columns: []mujica.KanbanColumn{{Status: "todo"}, {Status: "doing", WIP: int(s.wip)}, {Status: "done"}}, Statuses: projectsStatuses, Members: projectsMembers, Labels: projectsLabels, Version: s.version, Disabled: s.disabled}
}
func projectsLogBoard(s *projectsTaskState, r mujica.KanbanResult, log func(string)) {
if move, ok := r.Moved(); ok {
s.version++
log(fmt.Sprintf("move %s: %s -> %s at %d", move.ID, move.From, move.To, move.Index))
}
if id, ok := r.Opened(); ok {
log("open " + id)
}
}
func projectsDemoKanbanBoard(c *ui.Context, log func(string)) {
s := projectsTasks(c, "361")
projectsCheck(c, log, &s.disabled, "Disabled")
if mujica.NumberInput(c, &s.wip, mujica.NumberInputOptions{Min: 0, Max: 10, Step: 1, Label: "WIP limit"}).Changed() {
log(fmt.Sprintf("WIP = %g", s.wip))
}
ui.ScrollHorizontal(c).Height(420).FillWidth().Children(func() {
ui.Box(c).Width(880).Height(420).Children(func() { projectsLogBoard(s, mujica.KanbanBoard(c, &s.tasks, projectsBoardOptions(s)), log) })
})
}
func projectsDemoTaskList(c *ui.Context, log func(string)) {
s := projectsTasks(c, "362")
projectsCheck(c, log, &s.disabled, "Disabled")
projectsChoice(c, log, &s.group, []mujica.SelectOption[mujica.TaskGrouping]{{Value: mujica.TaskGroupNone, Label: "Ungrouped"}, {Value: mujica.TaskGroupStatus, Label: "By status"}, {Value: mujica.TaskGroupPriority, Label: "By priority"}}, "Grouping")
ui.Box(c).Height(360).FillWidth().Children(func() {
before := s.filter
r := mujica.TaskList(c, s.tasks, &s.filter, mujica.TaskListOptions{Statuses: projectsStatuses, Members: projectsMembers, GroupBy: s.group, Version: s.version, Disabled: s.disabled})
if s.filter != before {
log(fmt.Sprintf("filter = %d", s.filter))
}
if id, ok := r.Changed(); ok {
s.version++
log("edit " + id)
}
if id, ok := r.Opened(); ok {
log("open " + id)
}
})
}
func projectsDemoTaskItem(c *ui.Context, log func(string)) {
s := projectsTasks(c, "363")
projectsCheck(c, log, &s.disabled, "Disabled")
projectsChoice(c, log, &s.tasks[0].Priority, []mujica.SelectOption[mujica.Priority]{{Value: mujica.PriorityLow, Label: "Low"}, {Value: mujica.PriorityHigh, Label: "High"}, {Value: mujica.PriorityUrgent, Label: "Urgent"}}, "Priority")
r := mujica.TaskItem(c, &s.tasks[0], mujica.TaskItemOptions{Members: projectsMembers, Disabled: s.disabled})
if id, ok := r.Changed(); ok {
log("edit " + id)
}
if id, ok := r.Opened(); ok {
log("open " + id)
}
}
func projectsDemoTaskDetailPanel(c *ui.Context, log func(string)) {
s := projectsTasks(c, "364")
projectsCheck(c, log, &s.disabled, "Disabled")
if s.closed {
if mujica.Button(c, "Open details", mujica.ButtonOptions{}).Clicked() {
s.closed = false
log("open details")
}
return
}
ui.Scroll(c).Height(620).FillWidth().Children(func() {
ui.Box(c).MaxWidth(500).FillWidth().Children(func() {
r := mujica.TaskDetailPanel(c, &s.tasks[0], mujica.TaskDetailPanelOptions{Statuses: projectsStatuses, Members: projectsMembers, Labels: projectsLabels, Disabled: s.disabled, Activity: func() { ui.Text(c, "Ann created this task") }})
if r.Changed() {
log("task fields changed")
}
if r.Closed() {
s.closed = true
log("close details")
}
})
})
}
func projectsDemoSubtaskList(c *ui.Context, log func(string)) {
s := projectsTasks(c, "365")
projectsCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Clear subtasks", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() {
s.tasks[0].Subtasks = nil
log("clear subtasks")
}
if mujica.SubtaskList(c, &s.tasks[0].Subtasks, mujica.SubtaskListOptions{Disabled: s.disabled}).Changed() {
log("subtasks changed")
}
}
func projectsDemoSprintBoard(c *ui.Context, log func(string)) {
s := projectsTasks(c, "368")
projectsCheck(c, log, &s.disabled, "Disabled")
sprint := mujica.Sprint{Name: "Release sprint", Goal: "Ship the first release", Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}}
ui.ScrollHorizontal(c).Height(540).FillWidth().Children(func() {
ui.Box(c).Width(880).Height(540).Children(func() {
projectsLogBoard(s, mujica.SprintBoard(c, sprint, &s.tasks, mujica.SprintBoardOptions{Board: projectsBoardOptions(s)}), log)
})
})
}
func projectsDemoLabelManager(c *ui.Context, log func(string)) {
s := *ui.Local(c.Root(), "374", func() *struct {
labels []mujica.TaskLabel
selected []string
disabled bool
} {
return &struct {
labels []mujica.TaskLabel
selected []string
disabled bool
}{labels: slices.Clone(projectsLabels)}
})
projectsCheck(c, log, &s.disabled, "Disabled")
if mujica.LabelManager(c, &s.labels, &s.selected, mujica.LabelManagerOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("labels changed: %d, selected %v", len(s.labels), s.selected))
}
}
+61
View File
@@ -0,0 +1,61 @@
package main
var projectsFieldPages = []page{
{ID: "369", Name: "BurndownChart", ZhName: "燃尽图", Category: projectsCategory, Demo: projectsDemoBurndownChart, Keys: "Tab 聚焦图例与图表;Space 切换图例;方向键逐日读取;Home/End 首尾;Escape 清除读数。", Code: `remaining := []float64{40, 32, 25, 18}
opts := mujica.BurndownChartOptions{Frame: mujica.ChartFrame{Label: "Release burndown"}, Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}, Total: 40}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.BurndownChart(c, remaining, opts)
if r.Changed() {
log.Println("point", r.Point)
}
if name, ok := r.Toggled(); ok {
log.Println("series", name)
}
if r.Retried() {
log.Println("retry")
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "370", Name: "PriorityIndicator", ZhName: "优先级指示", Category: projectsCategory, Demo: projectsDemoPriorityIndicator, Keys: "静态展示,通过无障碍名称读取完整等级。", Code: `priority := mujica.PriorityHigh
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.PriorityIndicator(c, priority, mujica.PriorityIndicatorOptions{})
}
ui.Render(view, 1000, 700, 1)`},
{ID: "371", Name: "StatusSelect", ZhName: "状态选择", Category: projectsCategory, Demo: projectsDemoStatusSelect, Keys: "Enter/Space 或上下键打开;上下、Home/End 定位;Enter 确认;Escape 关闭。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
value := "todo"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.StatusSelect(c, &value, statuses, mujica.StatusSelectOptions{}).Changed() {
log.Println("status", value)
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "372", Name: "AssigneePicker", ZhName: "负责人选择", Category: projectsCategory, Demo: projectsDemoAssigneePicker, Keys: "Enter/Space 打开;输入搜索;下键进入选项;Space/Enter 勾选;Backspace 移除最后一项;Escape 关闭。", Code: `members := []mujica.Member{{ID: "ann", Name: "Ann"}, {ID: "ben", Name: "Ben"}}
ids := []string{"ann"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.AssigneePicker(c, &ids, members, mujica.AssigneePickerOptions{Max: 2}).Changed() {
log.Println("assignees", ids)
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "373", Name: "DueDatePicker", ZhName: "截止日期选择", Category: projectsCategory, Demo: projectsDemoDueDatePicker, Keys: "Tab 到快捷项、日期输入与日历按钮;Enter 选择;日历方向键移动;Escape 关闭。", Code: `var due mujica.Date
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.DueDatePicker(c, &due, mujica.DueDatePickerOptions{}).Changed() {
log.Println("due", due)
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "374", Name: "LabelManager", ZhName: "标签管理", Category: projectsCategory, Demo: projectsDemoLabelManager, Keys: "Tab 移动;Space 勾选;新增输入 Enter 创建;编辑颜色按钮打开编辑;Enter 保存名称;Escape 结束编辑。", Code: `labels := []mujica.TaskLabel{{ID: "ui", Name: "UI", Color: mujica.EventInfo}}
selected := []string{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.LabelManager(c, &labels, &selected, mujica.LabelManagerOptions{}).Changed() {
log.Println("labels changed", len(labels), selected)
}
}
ui.Render(view, 1000, 700, 1)`},
}
@@ -0,0 +1,142 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
"math"
mujica "mujicaui"
)
func projectsDemoBurndownChart(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "369", func() struct {
disabled bool
status mujica.DataStatus
total float64
values []float64
} {
return struct {
disabled bool
status mujica.DataStatus
total float64
values []float64
}{total: 40, values: []float64{40, 38, 32, 34, 26, math.NaN(), 20, 18}}
})
projectsCheck(c, log, &s.disabled, "Disabled")
projectsChoice(c, log, &s.status, []mujica.SelectOption[mujica.DataStatus]{{Value: mujica.DataReady, Label: "Ready"}, {Value: mujica.DataLoading, Label: "Loading"}, {Value: mujica.DataFailed, Label: "Error"}}, "Data state")
if mujica.NumberInput(c, &s.total, mujica.NumberInputOptions{Min: 1, Max: 100, Step: 1, Label: "Total work"}).Changed() {
log(fmt.Sprintf("total = %g", s.total))
}
ui.Box(c).Disabled(s.disabled).FillWidth().Children(func() {
r := mujica.BurndownChart(c, s.values, mujica.BurndownChartOptions{Frame: mujica.ChartFrame{Label: "Release burndown", Status: s.status, Error: "Data unavailable"}, Start: mujica.Date{Year: 2026, Month: 10, Day: 1}, End: mujica.Date{Year: 2026, Month: 10, Day: 14}, Total: s.total})
if r.Changed() {
log(fmt.Sprintf("point = %d", r.Point))
}
if name, ok := r.Toggled(); ok {
log("toggle " + name)
}
if r.Retried() {
s.status = mujica.DataReady
log("retry")
}
})
}
func projectsDemoPriorityIndicator(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "370", func() struct {
priority mujica.Priority
iconOnly bool
} {
return struct {
priority mujica.Priority
iconOnly bool
}{priority: mujica.PriorityHigh}
})
projectsCheck(c, log, &s.iconOnly, "Icon only")
projectsChoice(c, log, &s.priority, []mujica.SelectOption[mujica.Priority]{{Value: mujica.PriorityNone, Label: "None"}, {Value: mujica.PriorityLow, Label: "Low"}, {Value: mujica.PriorityMedium, Label: "Medium"}, {Value: mujica.PriorityHigh, Label: "High"}, {Value: mujica.PriorityUrgent, Label: "Urgent"}}, "Priority")
mujica.PriorityIndicator(c, s.priority, mujica.PriorityIndicatorOptions{IconOnly: s.iconOnly})
}
func projectsDemoStatusSelect(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "371", func() struct {
value string
disabled bool
} {
return struct {
value string
disabled bool
}{value: "todo"}
})
projectsCheck(c, log, &s.disabled, "Disabled")
if mujica.StatusSelect(c, &s.value, projectsStatuses, mujica.StatusSelectOptions{Disabled: s.disabled}).Changed() {
log("status " + s.value)
}
}
func projectsDemoAssigneePicker(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "372", func() struct {
ids []string
disabled bool
max float64
} {
return struct {
ids []string
disabled bool
max float64
}{ids: []string{"ann"}, max: 2}
})
projectsCheck(c, log, &s.disabled, "Disabled")
if mujica.NumberInput(c, &s.max, mujica.NumberInputOptions{Min: 0, Max: 3, Step: 1, Label: "Selection limit"}).Changed() {
log(fmt.Sprintf("limit = %g", s.max))
}
if mujica.AssigneePicker(c, &s.ids, projectsMembers, mujica.AssigneePickerOptions{Max: int(s.max), Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("assignees %v", s.ids))
}
}
func projectsDemoDueDatePicker(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "373", func() struct {
due mujica.Date
disabled, done bool
} {
return struct {
due mujica.Date
disabled, done bool
}{due: mujica.Date{Year: 2026, Month: 10, Day: 3}}
})
projectsCheck(c, log, &s.disabled, "Disabled")
projectsCheck(c, log, &s.done, "Completed")
if mujica.DueDatePicker(c, &s.due, mujica.DueDatePickerOptions{Done: s.done, Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("due %v", s.due))
}
}
func projectsDemoIssueCard(c *ui.Context, log func(string)) {
s := projectsTasks(c, "375")
selected := ui.Local(c.Root(), "375-selected", func() bool { return false })
projectsCheck(c, log, &s.disabled, "Disabled")
projectsCheck(c, log, selected, "Selected")
ui.Box(c).Width(300).Children(func() {
if mujica.IssueCard(c, s.tasks[0], mujica.IssueCardOptions{Statuses: projectsStatuses, Members: projectsMembers, Labels: projectsLabels, Selected: *selected, Disabled: s.disabled}).Clicked() {
*selected = !*selected
log("open " + s.tasks[0].ID)
}
})
}
func projectsDemoIssueIdBadge(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "376", func() struct {
disabled, copyable bool
id string
} {
return struct {
disabled, copyable bool
id string
}{copyable: true, id: "ENG-123"}
})
projectsCheck(c, log, &s.disabled, "Disabled")
projectsCheck(c, log, &s.copyable, "Copyable")
projectsChoice(c, log, &s.id, []mujica.SelectOption[string]{{Value: "ENG-123", Label: "ENG-123"}, {Value: "OPS-7", Label: "OPS-7"}}, "Issue key")
if mujica.IssueIdBadge(c, s.id, mujica.IssueIdBadgeOptions{Copyable: s.copyable, Disabled: s.disabled}).Changed() {
log("copied " + s.id)
}
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func projectsGalleryTester(t *testing.T, id string) (*ui.Tester, *[]string) {
t.Helper()
var events []string
for _, p := range projectsPages {
if p.ID == id {
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Column(c).Fill().Gap(12).Padding(16).Children(func() { p.Demo(c, func(s string) { events = append(events, s) }) })
}, 1200, 1000)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
return tt, &events
}
}
t.Fatal("unknown project page " + id)
return nil, nil
}
func TestProjectsGalleryCatalog(t *testing.T) {
if len(projectsPages) != 18 {
t.Fatalf("pages = %d", len(projectsPages))
}
seen := map[string]bool{}
for _, p := range projectsPages {
if p.Category != "Projects" || p.Keys == "" || p.Code == "" || seen[p.ID] {
t.Fatalf("invalid page %s", p.ID)
}
seen[p.ID] = true
projectsGalleryTester(t, p.ID)
}
if seen["366"] || seen["367"] {
t.Fatal("dropped components have gallery pages")
}
}
func TestProjectsGalleryEvents(t *testing.T) {
for _, tc := range []struct{ id, action, event string }{
{"361", "ENG-1 Plan the release", "open ENG-1"},
{"362", "Done 1", "filter = 2"},
{"363", "Plan the release", "open ENG-1"},
{"364", "Close details", "close details"},
{"365", "Clear subtasks", "clear subtasks"},
{"368", "ENG-1 Plan the release", "open ENG-1"},
{"369", "Ideal", "toggle Ideal"},
{"370", "Icon only", "Icon only = true"},
{"371", "Status", ""},
{"372", "Disabled", "Disabled = true"},
{"373", "Today", "due "},
{"374", "UI", "labels changed:"},
{"375", "ENG-1 Plan the release", "open ENG-1"},
{"376", "Copy ENG-123", "copied ENG-123"},
{"377", "Completed tasks", ""},
{"378", "Ben assigned hours", ""},
{"379", "Start", "tracked "},
{"380", "Start", "timer phase = 0, running = true"},
} {
t.Run(tc.id, func(t *testing.T) {
tt, events := projectsGalleryTester(t, tc.id)
if err := tt.Click(tc.action); err != nil {
t.Fatal(err)
}
switch tc.id {
case "371":
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEnter)
tc.event = "status done"
case "377", "378":
tt.Command("selectAll")
tt.Type("5")
tt.Key(0, ui.KeyEnter)
if tc.id == "377" {
tc.event = "completed = 5"
} else {
tc.event = "assigned = 5"
}
}
found := false
for _, e := range *events {
if strings.HasPrefix(e, tc.event) {
found = true
}
}
if !found {
t.Fatalf("event %q missing in %v", tc.event, *events)
}
})
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
var projectsTimePages = []page{
{ID: "375", Name: "IssueCard", ZhName: "任务卡片", Category: projectsCategory, Demo: projectsDemoIssueCard, Keys: "Tab 聚焦;Enter/Space 激活。", Code: `statuses := []mujica.WorkflowStatus{{ID: "todo", Name: "To do", Group: mujica.StatusUnstarted}, {ID: "done", Name: "Done", Group: mujica.StatusCompleted}}
task := mujica.Task{ID: "ENG-1", Title: "Plan the release", Status: "todo", Priority: mujica.PriorityHigh}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.IssueCard(c, task, mujica.IssueCardOptions{Statuses: statuses}).Clicked() {
log.Println("open", task.ID)
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "376", Name: "IssueIdBadge", ZhName: "任务编号", Category: projectsCategory, Demo: projectsDemoIssueIdBadge, Keys: "Copyable 开启时 Tab 聚焦,Enter/Space 复制。", Code: `id := "ENG-123"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.IssueIdBadge(c, id, mujica.IssueIdBadgeOptions{Copyable: true}).Changed() {
log.Println("copied", id)
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "377", Name: "MilestoneProgress", ZhName: "里程碑进度", Category: projectsCategory, Demo: projectsDemoMilestoneProgress, Keys: "静态展示,进度条通过无障碍名称与百分比读取。", Code: `milestone := mujica.Milestone{Title: "Release", Due: mujica.Date{Year: 2026, Month: 10, Day: 14}, Done: 3, Total: 8}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MilestoneProgress(c, milestone, mujica.MilestoneProgressOptions{})
}
ui.Render(view, 1000, 700, 1)`},
{ID: "378", Name: "WorkloadView", ZhName: "成员负载", Category: projectsCategory, Demo: projectsDemoWorkloadView, Keys: "静态行通过无障碍名称读取负载与超出量;滚动查看更多成员。", Code: `loads := []mujica.Workload{{Member: mujica.Member{ID: "ann", Name: "Ann"}, Assigned: 46, Capacity: 40}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WorkloadView(c, loads, mujica.WorkloadViewOptions{})
}
ui.Render(view, 1000, 700, 1)`},
{ID: "379", Name: "TimeTracker", ZhName: "工时计时", Category: projectsCategory, Demo: projectsDemoTimeTracker, Keys: "Tab 移动;Enter/Space 开始停止;停止后打开修正,编辑时长,Apply 提交。", Code: `var state mujica.TimeTrackerState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.TimeTracker(c, &state, mujica.TimeTrackerOptions{}).Changed() {
log.Println("tracked", state.Total(c.Now()))
}
}
ui.Render(view, 1000, 700, 1)`},
{ID: "380", Name: "PomodoroTimer", ZhName: "番茄钟", Category: projectsCategory, Demo: projectsDemoPomodoroTimer, Keys: "Tab 移动;Enter/Space 开始、暂停、跳过与重置。", Code: `var state mujica.PomodoroState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PomodoroTimer(c, &state, mujica.PomodoroTimerOptions{Focus: 25 * time.Minute})
if r.Changed() {
log.Println("timer", state.Phase(), state.Running())
}
if phase, ok := r.Finished(); ok {
log.Println("finished", phase)
}
}
ui.Render(view, 1000, 700, 1)`},
}
@@ -0,0 +1,83 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"time"
)
func projectsDemoMilestoneProgress(c *ui.Context, log func(string)) {
done := ui.Local(c.Root(), "377", func() float64 { return 3 })
if mujica.NumberInput(c, done, mujica.NumberInputOptions{Min: 0, Max: 8, Step: 1, Label: "Completed tasks"}).Changed() {
log(fmt.Sprintf("completed = %g", *done))
}
mujica.MilestoneProgress(c, mujica.Milestone{Title: "Release", Due: mujica.Date{Year: 2026, Month: 10, Day: 14}, Done: int(*done), Total: 8}, mujica.MilestoneProgressOptions{})
}
func projectsDemoWorkloadView(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "378", func() struct {
assigned float64
loads []mujica.Workload
version int
} {
return struct {
assigned float64
loads []mujica.Workload
version int
}{assigned: 46, loads: []mujica.Workload{{Member: projectsMembers[0], Assigned: 32, Capacity: 40}, {Member: projectsMembers[1], Assigned: 46, Capacity: 40}, {Member: projectsMembers[2], Assigned: 12, Capacity: 20}}}
})
if mujica.NumberInput(c, &s.assigned, mujica.NumberInputOptions{Min: 0, Max: 80, Step: 1, Label: "Ben assigned hours"}).Changed() {
s.loads[1].Assigned = s.assigned
s.version++
log(fmt.Sprintf("assigned = %g", s.assigned))
}
ui.Box(c).Height(240).FillWidth().Children(func() { mujica.WorkloadView(c, s.loads, mujica.WorkloadViewOptions{Version: s.version}) })
}
func projectsDemoTimeTracker(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "379", func() struct {
timer mujica.TimeTrackerState
disabled bool
} {
return struct {
timer mujica.TimeTrackerState
disabled bool
}{}
})
projectsCheck(c, log, &s.disabled, "Disabled")
ui.ScrollHorizontal(c).Height(120).FillWidth().Children(func() {
if mujica.TimeTracker(c, &s.timer, mujica.TimeTrackerOptions{Disabled: s.disabled}).Changed() {
log("tracked " + s.timer.Total(c.Now()).String())
}
})
}
func projectsDemoPomodoroTimer(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "380", func() struct {
timer mujica.PomodoroState
disabled, hidden bool
seconds float64
} {
return struct {
timer mujica.PomodoroState
disabled, hidden bool
seconds float64
}{seconds: 10}
})
projectsCheck(c, log, &s.disabled, "Disabled")
projectsCheck(c, log, &s.hidden, "Hidden")
if mujica.NumberInput(c, &s.seconds, mujica.NumberInputOptions{Min: 1, Max: 1500, Step: 1, Label: "Focus seconds", Disabled: s.timer.Running()}).Changed() {
log(fmt.Sprintf("focus = %g seconds", s.seconds))
}
if s.hidden {
return
}
r := mujica.PomodoroTimer(c, &s.timer, mujica.PomodoroTimerOptions{Focus: time.Duration(s.seconds) * time.Second, Break: 5 * time.Second, LongBreak: 10 * time.Second, LongEvery: 2, Disabled: s.disabled})
if r.Changed() {
log(fmt.Sprintf("timer phase = %d, running = %v", s.timer.Phase(), s.timer.Running()))
}
if phase, ok := r.Finished(); ok {
log(fmt.Sprintf("finished phase %d", phase))
}
}
+87
View File
@@ -0,0 +1,87 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"log/slog"
"mujicaui/internal"
"mujicaui/theme"
)
// IssueCardOptions configure an IssueCard.
type IssueCardOptions struct {
Statuses []WorkflowStatus
Members []Member
Labels []TaskLabel
Selected bool
Disabled bool
}
// IssueCard displays task fields in a selectable card.
func IssueCard(c *ui.Context, task Task, opts IssueCardOptions) *ui.Element {
projectCheckPriority(task.Priority)
st := projectStatus(opts.Statuses, task.Status)
k := Tokens(c)
d := Density(c)
small := FontSize(c, theme.CaptionSize)
b := ui.ButtonBase(c).Column().AlignItems(ui.Stretch).Justify(ui.Start).Padding(d.Unit()*3).Gap(d.Unit()*2).
Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).Background(k.Surface).
Label(task.ID + " " + task.Title).Description(fmt.Sprintf(msg(c, "issue_card.description"), st.Name, PriorityName(c, task.Priority))).Disabled(opts.Disabled)
internal.Ring(b, k.Focus, theme.CardRadius)
switch {
case opts.Selected:
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder)
}
selected := opts.Selected
b.Transition(colorFade).Draw(func(p *ui.Painter, r ui.Rect) {
if selected {
datetimeSelectedMark(p, r, k.AccentText)
}
})
b.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
statusGlyphBox(c, st.Group)
ui.Text(c, task.ID).Font(theme.MonoFont).FontSize(small).TextColor(k.TextMuted).Grow(1)
PriorityIndicator(c, task.Priority, PriorityIndicatorOptions{IconOnly: true})
})
t := ui.Text(c, task.Title).TextColor(k.Text).MaxLines(2).Ellipsis("…")
if st.Group == StatusCanceled {
t.Strikethrough().TextColor(k.TextMuted)
}
if len(task.Labels) == 0 && len(task.Assignees) == 0 {
return
}
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Wrap().Children(func() {
for _, id := range task.Labels {
issueLabelChip(c, projectLabel(opts.Labels, id))
}
ui.Spacer(c)
if len(task.Assignees) > 0 {
items := make([]AvatarItem, len(task.Assignees))
for i, id := range task.Assignees {
items[i] = AvatarItem{Name: projectMember(opts.Members, id).Name}
}
AvatarGroup(c, items, AvatarGroupOptions{Max: 3, Size: 22})
}
})
})
if b.Clicked() && !b.IsDisabled() {
slog.Debug("mujica: issue_card open", "id", task.ID)
}
return b
}
// issueLabelChip displays a label with its color dot.
func issueLabelChip(c *ui.Context, l TaskLabel) {
k := Tokens(c)
d := Density(c)
col := eventColor(k, l.Color)
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Padding(1, d.Unit()*1.5).Radius(theme.ControlRadius).
Border(theme.BorderWidth, k.Border).Children(func() {
ui.Box(c).Size(8, 8).Radius(4).Background(col)
ui.Text(c, l.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine()
})
}
+88
View File
@@ -0,0 +1,88 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func issueCardTester(task Task, opts IssueCardOptions, n *int) *ui.Tester {
opts.Statuses, opts.Members, opts.Labels = projStatuses, projMembers, projLabels
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Width(300).Children(func() {
if IssueCard(c, task, opts).Clicked() {
*n++
}
})
}, 360, 260)
}
func TestIssueCardShowsFieldsAndActivates(t *testing.T) {
n := 0
tt := issueCardTester(projTasks()[1], IssueCardOptions{}, &n)
for _, s := range []string{"AVE-2", "Tune the organ", "Bug", "Urgent", "Mutsumi Wakaba", "Uika Misumi"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %v", s, tt.Texts())
}
}
_ = tt.Click("AVE-2 Tune the organ")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if n != 2 {
t.Fatalf("activations %d", n)
}
}
func TestIssueCardDisabledAndInvalid(t *testing.T) {
n := 0
tt := issueCardTester(projTasks()[0], IssueCardOptions{Disabled: true}, &n)
_ = tt.Click("AVE-1 Write the overture")
if n != 0 {
t.Fatal("disabled card clicked")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for an unknown status")
}
}()
bad := projTasks()[0]
bad.Status = "lost"
issueCardTester(bad, IssueCardOptions{}, &n)
}
func TestIssueCardSnapshot(t *testing.T) {
tasks := projTasks()
snapshot(t, "issue_card", 640, 200, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(12).AlignItems(ui.Start).Children(func() {
for i, task := range tasks[:3] {
ui.Box(c).Width(190).Children(func() {
IssueCard(c, task, IssueCardOptions{Statuses: projStatuses, Members: projMembers, Labels: projLabels, Selected: i == 1})
})
}
})
})
}
func TestIssueCardFocusRing(t *testing.T) {
n := 0
tt := issueCardTester(projTasks()[0], IssueCardOptions{}, &n)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Key(0, ui.KeyTab)
r, ok := tt.Find("AVE-1 Write the overture")
if !ok {
t.Fatal("card missing")
}
img, found := tt.Image(), false
for x := int(r.X) - 5; x < int(r.X)-1; x++ {
for y := int(r.Y) + 8; y < int(r.Y+r.H)-8; y++ {
if datetimeNear(img.RGBAAt(x, y), theme.Light().Focus) {
found = true
}
}
}
if !found {
t.Fatal("card keyboard focus has no outer ring")
}
}
+73
View File
@@ -0,0 +1,73 @@
package mujica
import (
"fmt"
"log/slog"
"regexp"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
var issueIDPattern = regexp.MustCompile(`^[A-Z][A-Z0-9]*-[1-9][0-9]*$`)
// IssueIdBadgeOptions configure an IssueIdBadge.
type IssueIdBadgeOptions struct {
// Copyable makes a click or Enter copy the key.
Copyable bool
Disabled bool
}
// issueCopiedFor is how long "Copied" shows after a copy.
const issueCopiedFor = 1500 * time.Millisecond
// IssueIdBadge displays a monospace issue key with optional copying.
func IssueIdBadge(c *ui.Context, id string, opts IssueIdBadgeOptions) ChoiceResult {
if !issueIDPattern.MatchString(id) {
panic(fmt.Sprintf("mujica: IssueIdBadge %q is not a key like ENG-123", id))
}
k := Tokens(c)
d := Density(c)
small := FontSize(c, theme.CaptionSize)
if !opts.Copyable {
e := ui.Row(c).Padding(d.Unit()/2, d.Unit()*1.5).Radius(theme.ControlRadius).Background(k.Background).
Border(theme.BorderWidth, k.Border).Shrink(0).Label(id)
e.Children(func() { ui.Text(c, id).Font(theme.MonoFont).FontSize(small).TextColor(k.TextMuted).SingleLine() })
return ChoiceResult{Element: e}
}
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Gap(d.Unit()).PaddingX(d.Unit()*1.5).Radius(theme.ControlRadius).
Background(k.Background).Border(theme.BorderWidth, k.Border).Disabled(opts.Disabled)
internal.Ring(b, k.Focus, theme.ControlRadius)
copied := ui.Local(b, "issue-copied", func() time.Time { return time.Time{} })
changed := false
if b.Clicked() && !b.IsDisabled() {
c.WriteClipboard(id)
c.Announce(fmt.Sprintf(msg(c, "issue_id_badge.copied_label"), id))
*copied, changed = c.Now(), true
slog.Debug("mujica: issue_id_badge copy", "id", id)
}
showing := !copied.IsZero() && c.Now().Sub(*copied) < issueCopiedFor
if showing {
c.After(issueCopiedFor - c.Now().Sub(*copied))
}
b.Label(fmt.Sprintf(msg(c, "issue_id_badge.copy"), id))
if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Transition(colorFade).Children(func() {
ui.Text(c, id).Font(theme.MonoFont).FontSize(small).TextColor(k.Text).SingleLine()
icon, col := "copy", k.TextMuted
if showing {
icon, col = "check", k.Success
}
ui.Icon(c, icons.Must(icon)).Size(12, 12).TextColor(col)
if showing {
ui.Text(c, msg(c, "issue_id_badge.copied")).FontSize(small).TextColor(k.Success)
}
})
return ChoiceResult{Element: b, changed: changed}
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestIssueIdBadgeCopy(t *testing.T) {
n, disabled := 0, false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Children(func() {
if IssueIdBadge(c, "ENG-123", IssueIdBadgeOptions{Copyable: true, Disabled: disabled}).Changed() {
n++
}
IssueIdBadge(c, "OPS-7", IssueIdBadgeOptions{})
})
}, 300, 120)
_ = tt.Click("Copy ENG-123")
if tt.Clipboard() != "ENG-123" || n != 1 || !tt.HasText("Copied") {
t.Fatalf("copy %q %d %v", tt.Clipboard(), n, tt.Texts())
}
if a := tt.Announcements(); len(a) != 1 || a[0] != "ENG-123 copied" {
t.Fatalf("announce %q", a)
}
tt.SetClipboard("")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if tt.Clipboard() != "ENG-123" || n != 2 {
t.Fatal("Enter should copy")
}
if !tt.HasText("OPS-7") || tt.HasText("Copy OPS-7") {
t.Fatal("plain badge should not be a button")
}
disabled = true
tt.SetClipboard("")
tt.Frame()
_ = tt.Click("Copy ENG-123")
if tt.Clipboard() != "" || n != 2 {
t.Fatal("disabled badge copied")
}
}
func TestIssueIdBadgeCopiedFades(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
IssueIdBadge(c, "ENG-1", IssueIdBadgeOptions{Copyable: true})
}, 300, 120)
_ = tt.Click("Copy ENG-1")
if !tt.HasText("Copied") {
t.Fatal("no Copied note")
}
time.Sleep(issueCopiedFor + 50*time.Millisecond)
tt.Frame()
if tt.HasText("Copied") {
t.Fatal("Copied should go away")
}
}
func TestIssueIdBadgeInvalid(t *testing.T) {
for _, id := range []string{"eng-1", "ENG-0", "ENG123", ""} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%q: expected panic", id)
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); IssueIdBadge(c, id, IssueIdBadgeOptions{}) }, 10, 10)
}()
}
}
func TestIssueIdBadgeSnapshot(t *testing.T) {
snapshot(t, "issue_id_badge", 300, 60, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(12).AlignItems(ui.Center).Children(func() {
IssueIdBadge(c, "ENG-123", IssueIdBadgeOptions{Copyable: true})
IssueIdBadge(c, "OPS-7", IssueIdBadgeOptions{})
})
})
}
+263
View File
@@ -0,0 +1,263 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// KanbanColumn is one column, holding the tasks in Status.
type KanbanColumn struct {
Status string
// WIP limits card count; zero allows any count.
WIP int
}
// KanbanBoardOptions configure a KanbanBoard.
type KanbanBoardOptions struct {
Columns []KanbanColumn
Statuses []WorkflowStatus
Members []Member
Labels []TaskLabel
// Version must change when tasks change in place.
Version int
Disabled bool
Label string
}
// KanbanMove identifies a moved card and its destination.
type KanbanMove struct {
ID, From, To string
Index int
}
// KanbanResult is the board and what the user did.
type KanbanResult struct {
Element *ui.Element
move *KanbanMove
opened string
}
// Moved returns this frame's applied card move.
func (r KanbanResult) Moved() (KanbanMove, bool) {
if r.move == nil {
return KanbanMove{}, false
}
return *r.move, true
}
// Opened returns the card the user activated.
func (r KanbanResult) Opened() (string, bool) { return r.opened, r.opened != "" }
// kanbanDrag is the ID of a card being dragged.
type kanbanDrag string
// kanbanState caches column indexes and pending focus.
type kanbanState struct {
first *Task
n, ver int
rev, at int
cols [][]int
columns []KanbanColumn
focus string
problem string
// problemAt is the column the problem belongs to.
problemAt string
lists []*ui.ListState
}
// KanbanBoard supports status columns, WIP limits and card moves.
func KanbanBoard(c *ui.Context, tasks *[]Task, opts KanbanBoardOptions) KanbanResult {
if len(opts.Columns) == 0 {
panic("mujica: KanbanBoard needs Columns")
}
for i, col := range opts.Columns {
projectStatus(opts.Statuses, col.Status)
if col.WIP < 0 || slices.ContainsFunc(opts.Columns[:i], func(o KanbanColumn) bool { return o.Status == col.Status }) {
panic(fmt.Sprintf("mujica: KanbanBoard column %q repeats or has a negative WIP", col.Status))
}
}
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "kanban_board.label")
}
r := KanbanResult{}
board := ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Stretch).Grow(1).MinHeight(0).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = board
st := *ui.Local(board, "kanban", func() *kanbanState { return &kanbanState{} })
cols := kanbanColumns(st, *tasks, opts)
apply := func(id, to, before, how string) {
from := slices.IndexFunc(*tasks, func(t Task) bool { return t.ID == id })
ci := slices.IndexFunc(opts.Columns, func(c KanbanColumn) bool { return c.Status == to })
if from < 0 || ci < 0 {
return
}
t := (*tasks)[from]
if wip := opts.Columns[ci].WIP; wip > 0 && t.Status != to && len(cols[ci]) >= wip {
st.problem, st.problemAt = fmt.Sprintf(msg(c, "kanban_board.full"), projectStatus(opts.Statuses, to).Name, wip), to
slog.Debug("mujica: kanban_board wip refused", "id", id, "to", to)
return
}
var dest []string
for _, i := range cols[ci] {
if i != from {
dest = append(dest, (*tasks)[i].ID)
}
}
rest := slices.Delete(*tasks, from, from+1)
pos := func(id string) int { return slices.IndexFunc(rest, func(x Task) bool { return x.ID == id }) }
index, at := len(dest), len(rest)
switch {
case before != "":
index, at = slices.Index(dest, before), pos(before)
case len(dest) > 0:
at = pos(dest[len(dest)-1]) + 1
}
from0 := t.Status
t.Status = to
*tasks = slices.Insert(rest, at, t)
st.rev++
st.focus, st.problem = id, ""
st.lists[ci].ScrollIntoView(index)
r.move = &KanbanMove{ID: id, From: from0, To: to, Index: index}
slog.Debug("mujica: kanban_board move", "id", id, "from", from0, "to", to, "index", index, "by", how)
}
// Apply moves after building all rows.
var pending func()
move := func(id, to, before, how string) {
if !board.IsDisabled() && id != before && pending == nil {
pending = func() { apply(id, to, before, how) }
}
}
board.Children(func() {
for ci, col := range opts.Columns {
kanbanColumn(c, st, ci, col, func(i int) []int { return cols[i] }, *tasks, opts, &r, move)
}
})
if pending != nil {
pending()
c.Invalidate()
}
return r
}
// kanbanColumns returns each column's task indexes, rebuilt when tasks change.
func kanbanColumns(st *kanbanState, tasks []Task, opts KanbanBoardOptions) [][]int {
var first *Task
if len(tasks) > 0 {
first = &tasks[0]
}
if st.cols != nil && st.first == first && st.n == len(tasks) && st.ver == opts.Version && st.at == st.rev && slices.Equal(st.columns, opts.Columns) {
return st.cols
}
st.first, st.n, st.ver, st.at = first, len(tasks), opts.Version, st.rev
st.columns = slices.Clone(opts.Columns)
st.cols = make([][]int, len(opts.Columns))
for i, t := range tasks {
ci := slices.IndexFunc(opts.Columns, func(c KanbanColumn) bool { return c.Status == t.Status })
if ci < 0 {
projectStatus(opts.Statuses, t.Status)
continue
}
st.cols[ci] = append(st.cols[ci], i)
}
for len(st.lists) < len(opts.Columns) {
st.lists = append(st.lists, &ui.ListState{})
}
return st.cols
}
// kanbanColumn draws a header, WIP count and virtual cards.
func kanbanColumn(c *ui.Context, st *kanbanState, ci int, col KanbanColumn, colsOf func(int) []int, tasks []Task, opts KanbanBoardOptions,
r *KanbanResult, move func(id, to, before, how string)) {
k := Tokens(c)
d := Density(c)
idx := colsOf(ci)
status := projectStatus(opts.Statuses, col.Status)
over := col.WIP > 0 && len(idx) > col.WIP
full := col.WIP > 0 && len(idx) >= col.WIP
box := ui.Column(c).Key(col.Status).Width(d.ControlHeight()*7.5).Shrink(0).Gap(d.Unit()*2).Padding(d.Unit()*2).
Radius(theme.CardRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).Label(status.Name)
if id, ok := ui.Drop[kanbanDrag](box); ok && !box.IsDisabled() {
move(string(id), col.Status, "", "drag")
}
if _, ok := ui.DragOver[kanbanDrag](box); ok {
box.Border(theme.BorderWidth, k.AccentText)
}
box.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
statusGlyphBox(c, status.Group)
ui.Text(c, status.Name).Font(theme.SerifFont).TextColor(k.Text).Grow(1).SingleLine()
count := fmt.Sprint(len(idx))
if col.WIP > 0 {
count = fmt.Sprintf("%d / %d", len(idx), col.WIP)
}
fg := k.TextMuted
if over {
fg = k.Danger
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14).TextColor(fg)
}
wip := ui.Text(c, count).FontSize(FontSize(c, theme.CaptionSize)).TextColor(fg).FontFeatures("tnum")
if col.WIP > 0 {
wip.Label(fmt.Sprintf(msg(c, "kanban_board.wip"), len(idx), col.WIP))
}
})
if over {
ui.Text(c, fmt.Sprintf(msg(c, "kanban_board.over"), len(idx)-col.WIP)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).Role(ui.RoleStatus)
} else if full && st.problem != "" && st.problemAt == col.Status {
ui.Text(c, st.problem).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).Role(ui.RoleStatus)
}
ls := st.lists[ci]
ls.Key = func(i int) any { return tasks[idx[i]].ID }
at := func(cidx []int, i int) string {
if i < 0 || i >= len(cidx) {
return ""
}
return tasks[cidx[i]].ID
}
ui.List(c, ls, len(idx), func(i int) {
t := tasks[idx[i]]
cell := ui.Box(c).PaddingY(d.Unit())
if id, ok := ui.Drop[kanbanDrag](cell); ok && !cell.IsDisabled() {
move(string(id), col.Status, t.ID, "drag")
}
if _, ok := ui.DragOver[kanbanDrag](cell); ok {
cell.DrawOver(func(p *ui.Painter, rr ui.Rect) { p.Fill(ui.Rect{X: rr.X, Y: rr.Y, W: rr.W, H: 2}, k.AccentText, 1) })
}
cell.Children(func() {
card := IssueCard(c, t, IssueCardOptions{Statuses: opts.Statuses, Members: opts.Members, Labels: opts.Labels})
if card.IsDisabled() {
return
}
card.Drag(kanbanDrag(t.ID))
if st.focus == t.ID {
card.Focus()
st.focus = ""
}
if card.Clicked() {
r.opened = t.ID
}
switch {
case card.Shortcut(ui.Alt, ui.KeyUp) && i > 0:
move(t.ID, col.Status, at(idx, i-1), "keys")
case card.Shortcut(ui.Alt, ui.KeyDown) && i < len(idx)-1:
move(t.ID, col.Status, at(idx, i+2), "keys")
case card.Shortcut(ui.Alt, ui.KeyLeft) && ci > 0:
move(t.ID, opts.Columns[ci-1].Status, at(colsOf(ci-1), i), "keys")
case card.Shortcut(ui.Alt, ui.KeyRight) && ci < len(opts.Columns)-1:
move(t.ID, opts.Columns[ci+1].Status, at(colsOf(ci+1), i), "keys")
}
})
}).Grow(1).MinHeight(d.ControlHeight() * 3).Children(func() {
if len(idx) == 0 {
ui.Text(c, msg(c, "kanban_board.empty")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit() * 2)
}
})
})
}
+177
View File
@@ -0,0 +1,177 @@
package mujica
import (
"fmt"
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
var kanbanColumns3 = []KanbanColumn{{Status: "todo"}, {Status: "doing", WIP: 3}, {Status: "review"}, {Status: "done"}}
type kanbanProbe struct {
tasks []Task
cols []KanbanColumn
disabled bool
moves []KanbanMove
opened []string
}
func (p *kanbanProbe) tester() *ui.Tester {
if p.cols == nil {
p.cols = kanbanColumns3
}
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Height(560).Padding(8).Children(func() {
r := KanbanBoard(c, &p.tasks, KanbanBoardOptions{Columns: p.cols, Statuses: projStatuses, Members: projMembers,
Labels: projLabels, Disabled: p.disabled})
if m, ok := r.Moved(); ok {
p.moves = append(p.moves, m)
}
if id, ok := r.Opened(); ok {
p.opened = append(p.opened, id)
}
})
}, 1240, 600)
}
// kanbanOrder lists IDs per status in slice order.
func kanbanOrder(tasks []Task, status string) string {
var out []string
for _, t := range tasks {
if t.Status == status {
out = append(out, t.ID)
}
}
return strings.Join(out, ",")
}
func TestKanbanBoardDragAcrossAndWithin(t *testing.T) {
p := &kanbanProbe{tasks: projTasks()}
tt := p.tester()
src, _ := tt.Find("AVE-1 Write the overture")
dst, _ := tt.Find("AVE-4 Rehearse act two")
calDrag(tt, src.X+20, src.Y+20, dst.X+20, dst.Y+10)
if kanbanOrder(p.tasks, "review") != "AVE-1,AVE-4" || kanbanOrder(p.tasks, "todo") != "" || len(p.moves) != 1 ||
p.moves[0] != (KanbanMove{ID: "AVE-1", From: "todo", To: "review", Index: 0}) {
t.Fatalf("drag onto a card: %v %+v", kanbanOrder(p.tasks, "review"), p.moves)
}
src, _ = tt.Find("AVE-2 Tune the organ")
col, _ := tt.Find("In Progress")
calDrag(tt, src.X+20, src.Y+20, col.X+20, col.Y+400)
if kanbanOrder(p.tasks, "doing") != "AVE-3,AVE-2" {
t.Fatalf("drag to the column end: %v", kanbanOrder(p.tasks, "doing"))
}
}
func TestKanbanBoardKeyboardMoves(t *testing.T) {
p := &kanbanProbe{tasks: projTasks()}
tt := p.tester()
_ = tt.Click("AVE-2 Tune the organ")
tt.Key(ui.Alt, ui.KeyDown)
if kanbanOrder(p.tasks, "doing") != "AVE-3,AVE-2" || !tt.Focused("AVE-2 Tune the organ") {
t.Fatalf("Alt+Down %v", kanbanOrder(p.tasks, "doing"))
}
tt.Key(ui.Alt, ui.KeyRight)
if kanbanOrder(p.tasks, "review") != "AVE-4,AVE-2" || !tt.Focused("AVE-2 Tune the organ") {
t.Fatalf("Alt+Right %v", kanbanOrder(p.tasks, "review"))
}
tt.Key(ui.Alt, ui.KeyUp)
if kanbanOrder(p.tasks, "review") != "AVE-2,AVE-4" {
t.Fatalf("Alt+Up %v", kanbanOrder(p.tasks, "review"))
}
tt.Key(0, ui.KeyEnter)
if len(p.opened) != 2 || p.opened[1] != "AVE-2" {
t.Fatalf("opened %v", p.opened)
}
}
func TestKanbanBoardWIPLimit(t *testing.T) {
p := &kanbanProbe{tasks: projTasks(), cols: []KanbanColumn{{Status: "todo"}, {Status: "doing", WIP: 2}, {Status: "review"}, {Status: "done"}}}
tt := p.tester()
if !tt.HasText("2 of 2 cards") {
t.Fatalf("WIP count %v", tt.Texts())
}
_ = tt.Click("AVE-1 Write the overture")
tt.Key(ui.Alt, ui.KeyRight)
if kanbanOrder(p.tasks, "todo") != "AVE-1" || !tt.HasText("In Progress is at its limit of 2 cards.") {
t.Fatalf("WIP not enforced: %v", tt.Texts())
}
p.cols[1].WIP = 1
tt.Frame()
if !tt.HasText("1 over the limit") {
t.Fatal("over-limit warning missing")
}
}
func TestKanbanBoardDisabledAndInvalid(t *testing.T) {
p := &kanbanProbe{tasks: projTasks(), disabled: true}
tt := p.tester()
src, _ := tt.Find("AVE-1 Write the overture")
dst, _ := tt.Find("AVE-4 Rehearse act two")
calDrag(tt, src.X+20, src.Y+20, dst.X+20, dst.Y+10)
_ = tt.Click("AVE-1 Write the overture")
tt.Key(ui.Alt, ui.KeyRight)
if len(p.moves) != 0 || len(p.opened) != 0 || kanbanOrder(p.tasks, "todo") != "AVE-1" {
t.Fatal("disabled board changed")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for a repeated column")
}
}()
q := &kanbanProbe{tasks: projTasks(), cols: []KanbanColumn{{Status: "todo"}, {Status: "todo"}}}
q.tester()
}
func TestKanbanBoardLargeData(t *testing.T) {
p := &kanbanProbe{}
for i := range 100000 {
p.tasks = append(p.tasks, Task{ID: fmt.Sprintf("BIG-%d", i+1), Title: "Card", Status: kanbanColumns3[i%4].Status})
}
p.cols = []KanbanColumn{{Status: "todo"}, {Status: "doing"}, {Status: "review"}, {Status: "done"}}
tt := p.tester()
if n := len(tt.Texts()); n > 600 {
t.Fatalf("built %d labels", n)
}
}
func TestKanbanBoardSnapshot(t *testing.T) {
tasks := projTasks()
snapshot(t, "kanban_board", 1240, 460, func(c *ui.Context) {
ui.Column(c).Height(460).Padding(16).Children(func() {
KanbanBoard(c, &tasks, KanbanBoardOptions{Columns: kanbanColumns3, Statuses: projStatuses, Members: projMembers, Labels: projLabels})
})
})
}
func TestKanbanBoardColumnChanges(t *testing.T) {
p := &kanbanProbe{tasks: projTasks(), cols: []KanbanColumn{{Status: "todo"}, {Status: "doing"}}}
tt := p.tester()
p.cols = []KanbanColumn{{Status: "done"}, {Status: "review"}, {Status: "doing"}}
tt.Frame()
if !tt.HasText("AVE-5 Book the hall") || !tt.HasText("AVE-4 Rehearse act two") {
t.Fatal("columns retain old indexes")
}
}
func TestKanbanBoardForeignDrop(t *testing.T) {
left := []Task{{ID: "A-1", Title: "First", Status: "todo"}}
right := []Task{{ID: "B-1", Title: "Second", Status: "todo"}}
opts := KanbanBoardOptions{Columns: []KanbanColumn{{Status: "todo"}}, Statuses: projStatuses}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).Height(400).Children(func() {
ui.Box(c).Key("left").Width(300).Height(400).Children(func() { KanbanBoard(c, &left, opts) })
ui.Box(c).Key("right").Width(300).Height(400).Children(func() { KanbanBoard(c, &right, opts) })
})
}, 640, 420)
src, _ := tt.Find("A-1 First")
dst, _ := tt.Find("B-1 Second")
calDrag(tt, src.X+20, src.Y+20, dst.X+20, dst.Y+10)
if len(left) != 1 || left[0].ID != "A-1" || len(right) != 1 || right[0].ID != "B-1" {
t.Fatal("foreign drop changed another board")
}
}
+208
View File
@@ -0,0 +1,208 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// LabelManagerOptions configure a LabelManager.
type LabelManagerOptions struct {
Disabled bool
Label string
// Version changes when labels or selection change in place.
Version int
}
// labelManagerState is the label being edited and the new-label draft.
type labelManagerState struct {
editing string
name string
draft string
problem string
}
// LabelManager edits labels and stores chosen IDs in selected.
func LabelManager(c *ui.Context, labels *[]TaskLabel, selected *[]string, opts LabelManagerOptions) ChoiceResult {
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "label_manager.label")
}
changed := false
col := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
st := ui.Local(col, "label-manager", func() *labelManagerState { return &labelManagerState{} })
s := *st
cache := ui.Local(col, "label-data", func() labelDataCache { return labelDataCache{} })
var first *TaskLabel
var selectedFirst *string
if len(*labels) > 0 {
first = &(*labels)[0]
}
if len(*selected) > 0 {
selectedFirst = &(*selected)[0]
}
if !cache.ready || cache.first != first || cache.n != len(*labels) || cache.selectedFirst != selectedFirst || cache.selectedN != len(*selected) || cache.version != opts.Version {
*cache = labelDataCache{first: first, n: len(*labels), selectedFirst: selectedFirst, selectedN: len(*selected), version: opts.Version, ready: true, selected: map[string]bool{}}
known := map[string]bool{}
for _, l := range *labels {
eventColor(k, l.Color)
if l.ID == "" || known[l.ID] {
panic("mujica: LabelManager needs unique non-empty IDs")
}
known[l.ID] = true
}
for _, id := range *selected {
if !known[id] {
panic("mujica: LabelManager selection contains an unknown ID")
}
cache.selected[id] = true
}
}
state := ui.Local(col, "label-rows", func() ui.ListState { return ui.ListState{} })
state.Key = func(i int) any { return (*labels)[i].ID }
live := func() bool { return !col.IsDisabled() }
taken := func(name, except string) bool {
return slices.ContainsFunc(*labels, func(l TaskLabel) bool { return l.ID != except && strings.EqualFold(l.Name, name) })
}
col.Children(func() {
removed := -1
ui.List(c, state, len(*labels), func(i int) {
l := &(*labels)[i]
ui.Row(c).Key(l.ID).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
on := cache.selected[l.ID]
box := ui.CheckboxBase(c, &on).Size(theme.MinHitSize, theme.MinHitSize).Center().Label(l.Name)
internal.Ring(box, k.Focus, theme.ControlRadius)
if box.Draw(func(p *ui.Painter, r ui.Rect) { labelSwatch(p, r, eventColor(k, l.Color), on, k) }).Changed() && live() {
if on {
*selected = append(*selected, l.ID)
} else {
*selected = slices.DeleteFunc(*selected, func(id string) bool { return id == l.ID })
}
changed = true
slog.Debug("mujica: label_manager select", "id", l.ID, "selected", on)
}
if s.editing == l.ID && live() {
labelEditRow(c, s, l, taken, &changed)
return
}
ui.Text(c, l.Name).Grow(1).TextColor(k.Text).SingleLine().Ellipsis("…")
if b := iconAction(c, icons.Must("palette"), fmt.Sprintf(msg(c, "label_manager.edit"), l.Name)); b.Clicked() && live() {
s.editing, s.name, s.problem = l.ID, l.Name, ""
}
if b := iconAction(c, icons.Must("trash-2"), fmt.Sprintf(msg(c, "label_manager.delete"), l.Name)); b.Clicked() && live() {
removed = i
}
})
}).Height(min(float32(len(*labels)), 8) * d.ControlHeight())
if removed >= 0 {
id := (*labels)[removed].ID
slog.Debug("mujica: label_manager delete", "id", id)
*labels = slices.Delete(*labels, removed, removed+1)
*selected = slices.DeleteFunc(*selected, func(x string) bool { return x == id })
changed = true
}
if len(*labels) == 0 {
ui.Text(c, msg(c, "label_manager.empty")).TextColor(k.TextMuted)
}
in := TextInput(c, &s.draft, TextInputOptions{Placeholder: msg(c, "label_manager.new"), Label: msg(c, "label_manager.new"),
Disabled: opts.Disabled, Error: s.problem})
if in.Submitted() && live() {
name := strings.TrimSpace(s.draft)
switch {
case name == "":
case taken(name, ""):
s.problem = fmt.Sprintf(msg(c, "label_manager.taken"), name)
default:
l := TaskLabel{ID: labelNewID(*labels), Name: name, Color: EventColor(len(*labels) % 6)}
*labels = append(*labels, l)
s.draft, s.problem, changed = "", "", true
slog.Debug("mujica: label_manager create", "id", l.ID)
}
}
})
if changed {
cache.ready = false
c.Invalidate()
}
return ChoiceResult{Element: col, changed: changed}
}
// labelEditRow renames a label and picks its color.
func labelEditRow(c *ui.Context, s *labelManagerState, l *TaskLabel, taken func(string, string) bool, changed *bool) {
k := Tokens(c)
d := Density(c)
ui.Column(c).Grow(1).Gap(d.Unit()).Children(func() {
in := TextInput(c, &s.name, TextInputOptions{Label: msg(c, "label_manager.name"), Error: s.problem})
in.Input.AutoFocus()
ui.Row(c).Gap(d.Unit()).Role(ui.RoleRadioGroup).Label(msg(c, "label_manager.color")).Children(func() {
for i := range 6 {
col := EventColor(i)
sel := l.Color
b := ui.RadioBase(c, &sel, col).Size(theme.MinHitSize, theme.MinHitSize).Label(msg(c, labelColorKeys[i]))
internal.Ring(b, k.Focus, theme.ControlRadius)
on := l.Color == col
b.Draw(func(p *ui.Painter, r ui.Rect) { labelSwatch(p, r, eventColor(k, col), on, k) })
if b.Changed() && !b.IsDisabled() {
l.Color, *changed = col, true
slog.Debug("mujica: label_manager color", "id", l.ID, "color", col)
}
}
})
done := in.Submitted() || in.Input.Shortcut(0, ui.KeyEscape)
if in.Submitted() {
name := strings.TrimSpace(s.name)
switch {
case name == "":
done = false
case taken(name, l.ID):
s.problem, done = fmt.Sprintf(msg(c, "label_manager.taken"), name), false
case name != l.Name:
l.Name, *changed = name, true
slog.Debug("mujica: label_manager rename", "id", l.ID)
}
}
if done {
s.editing, s.problem = "", ""
}
})
}
var labelColorKeys = [...]string{"label_manager.accent", "label_manager.info", "label_manager.success", "label_manager.warning",
"label_manager.danger", "label_manager.neutral"}
// labelSwatch draws a color dot with a selection check.
func labelSwatch(p *ui.Painter, r ui.Rect, col ui.Color, on bool, k theme.Tokens) {
box := ui.Rect{X: r.X + r.W/2 - 8, Y: r.Y + r.H/2 - 8, W: 16, H: 16}
p.Fill(box, col, 8)
if on {
internal.Mark(p, ui.Rect{X: box.X + 2, Y: box.Y + 2, W: 12, H: 12}, "check", k.Surface)
}
}
// labelNewID returns an ID no label uses.
func labelNewID(labels []TaskLabel) string {
for n := len(labels) + 1; ; n++ {
id := fmt.Sprintf("label-%d", n)
if !slices.ContainsFunc(labels, func(l TaskLabel) bool { return l.ID == id }) {
return id
}
}
}
type labelDataCache struct {
first *TaskLabel
selectedFirst *string
n, selectedN, version int
ready bool
selected map[string]bool
}
+102
View File
@@ -0,0 +1,102 @@
package mujica
import (
"fmt"
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
func labelTester(labels *[]TaskLabel, sel *[]string, opts *LabelManagerOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Children(func() {
if LabelManager(c, labels, sel, *opts).Changed() {
*n++
}
})
}, 480, 480)
}
func TestLabelManagerSelectCreateDelete(t *testing.T) {
labels, sel, n := slices.Clone(projLabels), []string{}, 0
tt := labelTester(&labels, &sel, &LabelManagerOptions{}, &n)
_ = tt.Click("UI")
if !slices.Equal(sel, []string{"ui"}) {
t.Fatalf("select %v", sel)
}
_ = tt.Click("New label")
tt.Type("Docs")
tt.Key(0, ui.KeyEnter)
if len(labels) != 3 || !tt.HasText(`A label named "Docs" exists.`) {
t.Fatalf("duplicate accepted: %v", labels)
}
tt.Command("selectAll")
tt.Type("Performance")
tt.Key(0, ui.KeyEnter)
if len(labels) != 4 || labels[3].Name != "Performance" || labels[3].ID != "label-4" {
t.Fatalf("create %+v", labels)
}
_ = tt.Click("Delete UI")
if len(labels) != 3 || len(sel) != 0 || n != 3 {
t.Fatalf("delete left %+v sel %v (%d)", labels, sel, n)
}
}
func TestLabelManagerRenameAndColor(t *testing.T) {
labels, sel, n := slices.Clone(projLabels), []string{}, 0
tt := labelTester(&labels, &sel, &LabelManagerOptions{}, &n)
_ = tt.Click("Edit Bug")
_ = tt.Click("Green")
_ = tt.Click("Label name")
tt.Command("selectAll")
tt.Type("UI")
tt.Key(0, ui.KeyEnter)
if labels[0].Color != EventSuccess || labels[0].Name != "Bug" || !tt.HasText(`A label named "UI" exists.`) {
t.Fatalf("color/duplicate %+v", labels[0])
}
tt.Command("selectAll")
tt.Type("Defect")
tt.Key(0, ui.KeyEnter)
if labels[0].Name != "Defect" || tt.HasText("Label name") {
t.Fatalf("rename %+v", labels[0])
}
}
func TestLabelManagerDisabledAndInvalid(t *testing.T) {
labels, sel, n := slices.Clone(projLabels), []string{}, 0
tt := labelTester(&labels, &sel, &LabelManagerOptions{Disabled: true}, &n)
_ = tt.Click("UI")
_ = tt.Click("Delete Bug")
_ = tt.Click("Edit Bug")
if n != 0 || len(labels) != 3 || len(sel) != 0 || tt.HasText("Label name") {
t.Fatal("disabled manager changed")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for an unknown selected label")
}
}()
bad := []string{"ghost"}
labelTester(&labels, &bad, &LabelManagerOptions{}, &n)
}
func TestLabelManagerSnapshot(t *testing.T) {
labels, sel := slices.Clone(projLabels), []string{"bug"}
snapshot(t, "label_manager", 400, 220, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { LabelManager(c, &labels, &sel, LabelManagerOptions{}) })
})
}
func TestLabelManagerLargeData(t *testing.T) {
labels := make([]TaskLabel, 100000)
for i := range labels {
labels[i] = TaskLabel{ID: fmt.Sprint(i), Name: "Tag"}
}
selected, n := []string{}, 0
tt := labelTester(&labels, &selected, &LabelManagerOptions{}, &n)
if built := len(tt.Texts()); built > 200 {
t.Fatalf("built %d labels", built)
}
}
+120
View File
@@ -0,0 +1,120 @@
package mujica
func init() {
addMessages(map[string][2]string{
"projects.overdue": {"Overdue by %d days", "逾期 %d 天"},
"projects.overdue_one": {"Overdue by %d day", "逾期 %d 天"},
"projects.due_today": {"Due today", "今天到期"},
"projects.due_tomorrow": {"Due tomorrow", "明天到期"},
"projects.due": {"Due %s %d", "%s%d日到期"},
"priority_indicator.none": {"No priority", "无优先级"},
"priority_indicator.low": {"Low", "低"},
"priority_indicator.medium": {"Medium", "中"},
"priority_indicator.high": {"High", "高"},
"priority_indicator.urgent": {"Urgent", "紧急"},
"issue_card.description": {"%s, %s", "%s,%s"},
"issue_id_badge.copy": {"Copy %s", "复制 %s"},
"issue_id_badge.copied": {"Copied", "已复制"},
"issue_id_badge.copied_label": {"%s copied", "已复制 %s"},
"status_select.label": {"Status", "状态"},
"status_select.backlog": {"Backlog", "待规划"},
"status_select.unstarted": {"To do", "未开始"},
"status_select.started": {"In progress", "进行中"},
"status_select.completed": {"Done", "已完成"},
"status_select.canceled": {"Canceled", "已取消"},
"assignee_picker.label": {"Assignees", "负责人"},
"assignee_picker.placeholder": {"Unassigned", "未分配"},
"due_date_picker.label": {"Due date", "截止日期"},
"due_date_picker.today": {"Today", "今天"},
"due_date_picker.tomorrow": {"Tomorrow", "明天"},
"due_date_picker.next_week": {"Next week", "下周"},
"task_item.rename": {"Task title", "任务标题"},
"subtask_list.label": {"Subtasks", "子任务"},
"subtask_list.progress": {"%d of %d done", "已完成 %d / %d"},
"subtask_list.drag": {"Drag %s", "拖动 %s"},
"subtask_list.remove": {"Remove %s", "删除 %s"},
"subtask_list.add": {"Add a subtask", "添加子任务"},
"label_manager.label": {"Labels", "标签"},
"label_manager.edit": {"Edit %s", "编辑 %s"},
"label_manager.delete": {"Delete %s", "删除 %s"},
"label_manager.empty": {"No labels yet.", "暂无标签。"},
"label_manager.new": {"New label", "新建标签"},
"label_manager.name": {"Label name", "标签名称"},
"label_manager.color": {"Color", "颜色"},
"label_manager.taken": {"A label named %q exists.", "已有名为 %q 的标签。"},
"label_manager.accent": {"Crimson", "酒红"},
"label_manager.info": {"Blue", "蓝"},
"label_manager.success": {"Green", "绿"},
"label_manager.warning": {"Amber", "琥珀"},
"label_manager.danger": {"Red", "红"},
"label_manager.neutral": {"Gray", "灰"},
"task_list.label": {"Tasks", "任务"},
"task_list.filter": {"Show", "显示"},
"task_list.all": {"All %d", "全部 %d"},
"task_list.open": {"Open %d", "未完成 %d"},
"task_list.done": {"Done %d", "已完成 %d"},
"task_list.empty": {"No tasks here.", "这里没有任务。"},
"task_detail_panel.close": {"Close details", "关闭详情"},
"task_detail_panel.title": {"Title", "标题"},
"task_detail_panel.status": {"Status", "状态"},
"task_detail_panel.priority": {"Priority", "优先级"},
"task_detail_panel.activity": {"Activity", "活动记录"},
"kanban_board.label": {"Board", "看板"},
"kanban_board.full": {"%s is at its limit of %d cards.", "%s 已达 %d 张卡片的上限。"},
"kanban_board.wip": {"%d of %d cards", "%d / %d 张卡片"},
"kanban_board.over": {"%d over the limit", "超出上限 %d 张"},
"kanban_board.empty": {"No cards", "暂无卡片"},
"sprint_board.time": {"Time", "时间"},
"sprint_board.done": {"%d of %d done", "已完成 %d / %d"},
"sprint_board.progress": {"Sprint progress", "迭代进度"},
"sprint_board.starts_in": {"Starts in %d days", "%d 天后开始"},
"sprint_board.starts_in_one": {"Starts in %d day", "%d 天后开始"},
"sprint_board.ended": {"Ended", "已结束"},
"sprint_board.left": {"%d days left", "剩余 %d 天"},
"sprint_board.left_one": {"%d day left", "剩余 %d 天"},
"milestone_progress.count": {"%d of %d tasks done", "已完成 %d / %d 个任务"},
"milestone_progress.reached": {"Reached", "已达成"},
"workload_view.label": {"Workload", "工作负载"},
"workload_view.hours": {"h", "小时"},
"workload_view.over": {"over by %s %s", "超出 %s %s"},
"workload_view.overloaded": {"%d people are over capacity", "%d 人超出负荷"},
"workload_view.overloaded_one": {"%d person is over capacity", "%d 人超出负荷"},
"time_tracker.label": {"Time tracked", "已记录时间"},
"time_tracker.start": {"Start", "开始"},
"time_tracker.stop": {"Stop", "停止"},
"time_tracker.running": {"Tracking…", "计时中…"},
"time_tracker.correct": {"Correct the total", "修正总时长"},
"time_tracker.apply": {"Apply", "应用"},
"pomodoro_timer.label": {"Pomodoro timer", "番茄钟"},
"pomodoro_timer.focus": {"Focus", "专注"},
"pomodoro_timer.break": {"Short break", "短休息"},
"pomodoro_timer.long_break": {"Long break", "长休息"},
"pomodoro_timer.done": {"%s finished.", "%s结束。"},
"pomodoro_timer.left": {"%s, %s left", "%s,剩余 %s"},
"pomodoro_timer.sessions": {"%d of %d sessions before a long break", "距长休息:%d / %d 轮"},
"pomodoro_timer.start": {"Start", "开始"},
"pomodoro_timer.pause": {"Pause", "暂停"},
"pomodoro_timer.skip": {"Skip", "跳过"},
"pomodoro_timer.reset": {"Reset", "重置"},
"burndown_chart.actual": {"Remaining", "剩余"},
"burndown_chart.ideal": {"Ideal", "理想"},
})
}
+72
View File
@@ -0,0 +1,72 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Milestone holds a goal, due date and completion counts.
type Milestone struct {
Title string
Due Date
Done, Total int
}
// MilestoneProgressOptions configure a MilestoneProgress.
type MilestoneProgressOptions struct {
// Today anchors the date note; zero uses the system clock.
Today Date
}
// MilestoneProgress shows completion counts, progress and due dates.
func MilestoneProgress(c *ui.Context, m Milestone, opts MilestoneProgressOptions) *ui.Element {
datetimeCheckDate("MilestoneProgress Due", m.Due)
if m.Total < 0 || m.Done < 0 || m.Done > m.Total {
panic(fmt.Sprintf("mujica: MilestoneProgress %d of %d is not valid", m.Done, m.Total))
}
k := Tokens(c)
d := Density(c)
today := datetimeToday(c, opts.Today)
frac := 0.0
if m.Total > 0 {
frac = float64(m.Done) / float64(m.Total)
}
reached := m.Total > 0 && m.Done == m.Total
pct := fmt.Sprintf("%d%%", int(frac*100))
col := ui.Column(c).Gap(d.Unit()*2).Padding(d.Unit()*3).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).
Background(k.Surface).Role(ui.RoleGroup).Label(m.Title).Description(pct)
col.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Box(c).Size(12, 12).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), k.Ornament)
})
ui.Text(c, m.Title).Font(theme.SerifFont).TextColor(k.Text).Grow(1).SingleLine().Ellipsis("…")
ui.Text(c, pct).TextColor(k.AccentText).FontFeatures("tnum")
})
projectBar(c, frac, m.Title)
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "milestone_progress.count"), m.Done, m.Total)).FontSize(FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).Grow(1).FontFeatures("tnum")
text, late := projectDueText(c, m.Due, today, reached)
fg, icon := k.TextMuted, "calendar"
switch {
case reached:
text, fg, icon = msg(c, "milestone_progress.reached"), k.Success, "circle-check"
case late:
fg, icon = k.Danger, "triangle-alert"
}
if text != "" {
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(fg).Role(ui.RoleStatus).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(14, 14)
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize))
})
}
})
})
return col
}
+55
View File
@@ -0,0 +1,55 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func milestoneTester(m Milestone) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Box(c).Width(360).Children(func() { MilestoneProgress(c, m, MilestoneProgressOptions{Today: calendarToday}) })
}, 400, 200)
}
func TestMilestoneProgressStates(t *testing.T) {
for _, c := range []struct {
m Milestone
want []string
}{
{Milestone{Title: "Act one", Due: oct(12), Done: 3, Total: 8}, []string{"37%", "3 of 8 tasks done", "Due October 12"}},
{Milestone{Title: "Act two", Due: oct(1), Done: 5, Total: 8}, []string{"62%", "Overdue by 4 days"}},
{Milestone{Title: "Premiere", Due: oct(1), Done: 4, Total: 4}, []string{"100%", "Reached"}},
{Milestone{Title: "Encore", Total: 0}, []string{"0%", "0 of 0 tasks done"}},
} {
tt := milestoneTester(c.m)
for _, s := range c.want {
if !tt.HasText(s) {
t.Errorf("%s: missing %q in %v", c.m.Title, s, tt.Texts())
}
}
}
if tt := milestoneTester(Milestone{Title: "Premiere", Due: oct(1), Done: 4, Total: 4}); tt.HasText("Overdue") {
t.Error("a reached milestone is not overdue")
}
}
func TestMilestoneProgressInvalid(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
milestoneTester(Milestone{Title: "x", Done: 5, Total: 3})
}
func TestMilestoneProgressSnapshot(t *testing.T) {
snapshot(t, "milestone_progress", 760, 140, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
for _, m := range []Milestone{{Title: "Act one", Due: oct(12), Done: 3, Total: 8}, {Title: "Act two", Due: oct(1), Done: 5, Total: 8}} {
ui.Box(c).Width(350).Children(func() { MilestoneProgress(c, m, MilestoneProgressOptions{Today: calendarToday}) })
}
})
})
}
+212
View File
@@ -0,0 +1,212 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// PomodoroPhase is the part of the cycle running.
type PomodoroPhase int
const (
PomodoroFocus PomodoroPhase = iota
PomodoroBreak
PomodoroLongBreak
)
// PomodoroState holds the cycle, sessions and running clock.
type PomodoroState struct {
phase PomodoroPhase
focused int
running bool
since time.Time
spent time.Duration
}
// Phase returns the current phase.
func (s *PomodoroState) Phase() PomodoroPhase { return s.phase }
// Sessions returns how many focus sessions finished.
func (s *PomodoroState) Sessions() int { return s.focused }
// Running reports whether the clock runs.
func (s *PomodoroState) Running() bool { return s.running }
func (s *PomodoroState) elapsed(now time.Time) time.Duration {
if s.running {
return s.spent + now.Sub(s.since)
}
return s.spent
}
// PomodoroTimerOptions configure a PomodoroTimer.
type PomodoroTimerOptions struct {
// Focus/Break/LongBreak default to 25/5/15 minutes.
Focus, Break, LongBreak time.Duration
// LongEvery counts sessions per long break; zero means four.
LongEvery int
// Now fixes the clock; zero uses the system clock.
Now time.Time
Disabled bool
Label string
}
// PomodoroResult is the timer and what changed this frame.
type PomodoroResult struct {
Element *ui.Element
changed bool
finished PomodoroPhase
done bool
}
// Changed reports a start, pause, skip or reset.
func (r PomodoroResult) Changed() bool { return r.changed }
// Finished returns the phase that ran out this frame.
func (r PomodoroResult) Finished() (PomodoroPhase, bool) { return r.finished, r.done }
// PomodoroTimer cycles focus and breaks with pause, skip and reset.
func PomodoroTimer(c *ui.Context, s *PomodoroState, opts PomodoroTimerOptions) PomodoroResult {
lengths := [3]time.Duration{opts.Focus, opts.Break, opts.LongBreak}
for i, def := range []time.Duration{25 * time.Minute, 5 * time.Minute, 15 * time.Minute} {
if lengths[i] < 0 || lengths[i]%time.Second != 0 {
panic(fmt.Sprintf("mujica: PomodoroTimer length %v is negative or finer than a second", lengths[i]))
}
if lengths[i] == 0 {
lengths[i] = def
}
}
every := opts.LongEvery
if every < 0 {
panic("mujica: PomodoroTimer LongEvery is negative")
}
if every == 0 {
every = 4
}
now := opts.Now
live := now.IsZero()
if live {
now = c.Now()
}
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "pomodoro_timer.label")
}
r := PomodoroResult{}
next := func() {
if s.phase == PomodoroFocus {
s.focused++
s.phase = PomodoroBreak
if s.focused%every == 0 {
s.phase = PomodoroLongBreak
}
} else {
s.phase = PomodoroFocus
}
s.running, s.spent = false, 0
}
if s.running && s.elapsed(now) >= lengths[s.phase] {
r.finished, r.done = s.phase, true
c.Announce(fmt.Sprintf(msg(c, "pomodoro_timer.done"), msg(c, pomodoroKeys[s.phase])))
slog.Debug("mujica: pomodoro_timer finished", "phase", s.phase)
next()
}
col := ui.Column(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = col
act := func(what string, fn func()) {
if col.IsDisabled() {
return
}
fn()
r.changed = true
slog.Debug("mujica: pomodoro_timer "+what, "phase", s.phase)
}
left := lengths[s.phase] - s.elapsed(now)
secs := int(math.Ceil(left.Seconds()))
frac := 1 - float32(secs)/float32(lengths[s.phase]/time.Second)
col.Children(func() {
phase := msg(c, pomodoroKeys[s.phase])
text := fmt.Sprintf("%02d:%02d", secs/60, secs%60)
ring := ui.Box(c).Size(160, 160).Center().Role(ui.RoleStatus).Label(fmt.Sprintf(msg(c, "pomodoro_timer.left"), phase, CountdownText(c, time.Duration(secs)*time.Second, CountdownWords)))
tone := k.AccentText
if s.phase != PomodoroFocus {
tone = k.Success
}
ring.Draw(func(p *ui.Painter, rr ui.Rect) {
cx, cy := internal.Snap(p, rr.X+rr.W/2), internal.Snap(p, rr.Y+rr.H/2)
p.FillPath(pomodoroSector(p, cx, cy, 70, 76, 2*math.Pi), k.Border)
if frac > 0 && !c.Preferences().ReduceMotion {
p.FillPath(pomodoroSector(p, cx, cy, 70, 76, 2*math.Pi*float64(frac)), tone)
}
})
ring.Children(func() {
ui.Column(c).AlignItems(ui.Center).Gap(2).Children(func() {
ui.Text(c, text).Font(theme.MonoFont).FontSize(FontSize(c, theme.H1Size)).TextColor(k.Text).FontFeatures("tnum")
ui.Text(c, phase).FontSize(FontSize(c, theme.CaptionSize)).TextColor(tone)
})
})
ui.Text(c, fmt.Sprintf(msg(c, "pomodoro_timer.sessions"), s.focused%every, every)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
ui.Row(c).Gap(d.Unit() * 2).Children(func() {
key, icon, variant := "pomodoro_timer.start", "play", Primary
if s.running {
key, icon, variant = "pomodoro_timer.pause", "pause", Secondary
}
if Button(c, msg(c, key), ButtonOptions{Variant: variant, Icon: icons.Must(icon)}).Clicked() {
act("toggle", func() {
if s.running {
s.spent, s.running = s.elapsed(now), false
} else {
s.since, s.running = now, true
}
})
}
if Button(c, msg(c, "pomodoro_timer.skip"), ButtonOptions{Variant: Secondary}).Clicked() {
act("skip", next)
}
if Button(c, msg(c, "pomodoro_timer.reset"), ButtonOptions{Variant: Ghost, Icon: icons.Must("rotate-ccw")}).Clicked() {
act("reset", func() { *s = PomodoroState{} })
}
})
})
if s.running && live {
delay := left % time.Second
if delay == 0 {
delay = time.Second
}
c.After(delay)
}
return r
}
var pomodoroKeys = [...]string{"pomodoro_timer.focus", "pomodoro_timer.break", "pomodoro_timer.long_break"}
// pomodoroSector snaps every changing vertex to the cache grid.
func pomodoroSector(p *ui.Painter, cx, cy, inner, outer float32, end float64) *ui.Path {
var path ui.Path
n := max(2, int(math.Ceil(end/(math.Pi/48))))
for i := 0; i <= n; i++ {
x, y := internal.Polar(cx, cy, outer, end*float64(i)/float64(n))
x, y = internal.Snap(p, x), internal.Snap(p, y)
if i == 0 {
path.MoveTo(x, y)
} else {
path.LineTo(x, y)
}
}
for i := n; i >= 0; i-- {
x, y := internal.Polar(cx, cy, inner, end*float64(i)/float64(n))
path.LineTo(internal.Snap(p, x), internal.Snap(p, y))
}
path.Close()
return &path
}
+132
View File
@@ -0,0 +1,132 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
type pomodoroProbe struct {
s PomodoroState
now time.Time
opts PomodoroTimerOptions
finished []PomodoroPhase
changes int
}
func (p *pomodoroProbe) tester() *ui.Tester {
p.now = time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
o := p.opts
o.Now = p.now
r := PomodoroTimer(c, &p.s, o)
if ph, ok := r.Finished(); ok {
p.finished = append(p.finished, ph)
}
if r.Changed() {
p.changes++
}
}, 400, 400)
}
func TestPomodoroTimerCycle(t *testing.T) {
p := &pomodoroProbe{opts: PomodoroTimerOptions{Focus: 2 * time.Second, Break: time.Second, LongBreak: 3 * time.Second, LongEvery: 2}}
tt := p.tester()
if !tt.HasText("Focus, 2 s left") {
t.Fatalf("start %v", tt.Texts())
}
_ = tt.Click("Start")
p.now = p.now.Add(900 * time.Millisecond)
tt.Frame()
if !tt.HasText("Focus, 2 s left") {
t.Fatal("seconds round up")
}
_ = tt.Click("Pause")
p.now = p.now.Add(time.Hour)
tt.Frame()
if !tt.HasText("Focus, 2 s left") || p.s.Running() {
t.Fatal("pause should hold the clock")
}
_ = tt.Click("Start")
p.now = p.now.Add(1100 * time.Millisecond)
tt.Frame()
if len(p.finished) != 1 || p.finished[0] != PomodoroFocus || p.s.Phase() != PomodoroBreak || p.s.Running() || p.s.Sessions() != 1 {
t.Fatalf("focus end: %v %v", p.finished, p.s.Phase())
}
if a := tt.Announcements(); len(a) == 0 || a[len(a)-1] != "Focus finished." {
t.Fatalf("announce %q", a)
}
_ = tt.Click("Skip")
_ = tt.Click("Skip")
if p.s.Phase() != PomodoroLongBreak || p.s.Sessions() != 2 {
t.Fatalf("second focus should earn a long break: %v", p.s.Phase())
}
_ = tt.Click("Reset")
if p.s.Phase() != PomodoroFocus || p.s.Sessions() != 0 || p.changes != 6 {
t.Fatalf("reset %v (%d)", p.s.Phase(), p.changes)
}
}
func TestPomodoroTimerKeyboardDisabledInvalid(t *testing.T) {
p := &pomodoroProbe{opts: PomodoroTimerOptions{Disabled: true}}
tt := p.tester()
_ = tt.Click("Start")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if p.s.Running() || p.changes != 0 || !tt.HasText("Focus, 25 min left") {
t.Fatal("disabled timer started")
}
q := &pomodoroProbe{}
tt = q.tester()
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !q.s.Running() {
t.Fatal("Enter should start")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
bad := &pomodoroProbe{opts: PomodoroTimerOptions{Focus: 1500 * time.Millisecond}}
bad.tester()
}
func TestPomodoroTimerSnapshot(t *testing.T) {
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
s := PomodoroState{spent: 10 * time.Minute}
snapshot(t, "pomodoro_timer", 360, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { PomodoroTimer(c, &s, PomodoroTimerOptions{Now: now}) })
})
}
func TestPomodoroTimerIdleAndHidden(t *testing.T) {
s, shown, builds := PomodoroState{}, true, 0
tt := ui.NewTester(func(c *ui.Context) {
builds++
Use(c, Settings{})
if shown {
PomodoroTimer(c, &s, PomodoroTimerOptions{})
}
}, 400, 400)
if builds > 2 {
t.Fatalf("idle built %d frames", builds)
}
if err := tt.Click("Start"); err != nil {
t.Fatal(err)
}
shown = false
tt.Frame()
before := builds
time.Sleep(1100 * time.Millisecond)
if builds != before || !s.Running() {
t.Fatalf("hidden timer: builds %d, running %v", builds, s.Running())
}
shown = true
tt.Frame()
if !tt.HasText("Pause") {
t.Fatal("visible timer did not retain its clock")
}
}
+67
View File
@@ -0,0 +1,67 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// PriorityIndicatorOptions configure a PriorityIndicator.
type PriorityIndicatorOptions struct {
// IconOnly hides the text; the name stays in the label.
IconOnly bool
}
var priorityKeys = [...]string{"priority_indicator.none", "priority_indicator.low", "priority_indicator.medium",
"priority_indicator.high", "priority_indicator.urgent"}
// PriorityName is a priority's localized name.
func PriorityName(c *ui.Context, p Priority) string {
projectCheckPriority(p)
return msg(c, priorityKeys[p])
}
// PriorityIndicator displays urgency with shapes and localized text.
func PriorityIndicator(c *ui.Context, p Priority, opts PriorityIndicatorOptions) *ui.Element {
name := PriorityName(c, p)
k := Tokens(c)
d := Density(c)
col := k.TextMuted
switch p {
case PriorityUrgent:
col = k.Danger
case PriorityHigh:
col = k.Warning
}
row := ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Shrink(0).Role(ui.RoleImage).Label(name)
row.Children(func() {
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(pp *ui.Painter, r ui.Rect) { priorityGlyph(pp, r, p, col, k.Surface, k.Border) })
if !opts.IconOnly {
ui.Text(c, name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(col).SingleLine()
}
})
return row
}
// priorityGlyph draws level bars, urgency marks or empty dashes.
func priorityGlyph(p *ui.Painter, r ui.Rect, pr Priority, col, bg, faint ui.Color) {
switch pr {
case PriorityUrgent:
p.Fill(r, col, 2)
p.Fill(ui.Rect{X: r.X + r.W/2 - 1, Y: r.Y + 3, W: 2, H: r.H - 8}, bg, 0)
p.Fill(ui.Rect{X: r.X + r.W/2 - 1, Y: r.Y + r.H - 4, W: 2, H: 2}, bg, 0)
case PriorityNone:
for i := range 3 {
p.Fill(ui.Rect{X: r.X + 1 + float32(i)*4.5, Y: r.Y + r.H/2 - 1, W: 3, H: 2}, col, 0)
}
default:
for i := range 3 {
h := r.H * float32(i+1) / 3
c := faint
if i < int(pr) {
c = col
}
p.Fill(ui.Rect{X: r.X + 1 + float32(i)*4.5, Y: r.Y + r.H - h, W: 3, H: h}, c, 1)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func TestPriorityIndicatorNamesAndShapes(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light, Locale: ZhCN})
ui.Column(c).Children(func() {
for p := PriorityNone; p <= PriorityUrgent; p++ {
PriorityIndicator(c, p, PriorityIndicatorOptions{})
}
PriorityIndicator(c, PriorityHigh, PriorityIndicatorOptions{IconOnly: true})
})
}, 200, 200)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
for _, s := range []string{"无优先级", "低", "中", "高", "紧急"} {
if !tt.HasText(s) {
t.Fatalf("missing %q: %v", s, tt.Texts())
}
}
}
func TestPriorityIndicatorBarsShowLevel(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Children(func() {
for p := PriorityLow; p <= PriorityHigh; p++ {
ui.Box(c).Height(20).Children(func() { PriorityIndicator(c, p, PriorityIndicatorOptions{IconOnly: true}) })
}
})
}, 100, 100)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if !tt.HasText("Medium") {
t.Fatal("icon-only indicator lost its name")
}
img := tt.Image()
k := theme.Light()
for i, want := range []struct {
n int
col ui.Color
}{{1, k.TextMuted}, {2, k.TextMuted}, {3, k.Warning}} {
runs, on := 0, false
for x := 0; x < 16; x++ {
hit := datetimeNear(img.RGBAAt(x, i*20+13), want.col)
if hit && !on {
runs++
}
on = hit
}
if runs != want.n {
t.Errorf("priority %d: %d filled bars, want %d", i+1, runs, want.n)
}
}
}
func TestPriorityIndicatorInvalid(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); PriorityIndicator(c, 9, PriorityIndicatorOptions{}) }, 10, 10)
}
func TestPriorityIndicatorSnapshot(t *testing.T) {
snapshot(t, "priority_indicator", 520, 60, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
for p := PriorityNone; p <= PriorityUrgent; p++ {
PriorityIndicator(c, p, PriorityIndicatorOptions{})
}
})
})
}
+146
View File
@@ -0,0 +1,146 @@
package mujica
import (
"fmt"
"slices"
"github.com/egoist/mygo/ui"
)
// Priority describes urgency; zero means no priority.
type Priority int
const (
PriorityNone Priority = iota
PriorityLow
PriorityMedium
PriorityHigh
PriorityUrgent
)
// Member is a person tasks can be assigned to.
type Member struct {
ID string
Name string
}
// TaskLabel is a colored tag tasks carry.
type TaskLabel struct {
ID string
Name string
Color EventColor
}
// Subtask is one checklist step of a task.
type Subtask struct {
ID string
Title string
Done bool
}
// Task is one unit of project work.
type Task struct {
// ID is the issue key, as ENG-123.
ID string
Title string
Done bool
Priority Priority
Due Date
Status string
Assignees []string
Labels []string
Subtasks []Subtask
}
// StatusGroup is the workflow stage a status belongs to.
type StatusGroup int
const (
StatusBacklog StatusGroup = iota
StatusUnstarted
StatusStarted
StatusCompleted
StatusCanceled
)
// WorkflowStatus is one workflow state.
type WorkflowStatus struct {
ID string
Name string
Group StatusGroup
}
// ProjectResult exposes the element and task events.
type ProjectResult struct {
Element *ui.Element
changed string
opened string
}
// Changed returns the edited task ID.
func (r ProjectResult) Changed() (string, bool) { return r.changed, r.changed != "" }
// Opened returns the activated task ID.
func (r ProjectResult) Opened() (string, bool) { return r.opened, r.opened != "" }
// projectCheckPriority panics on an unknown priority.
func projectCheckPriority(p Priority) {
if p < PriorityNone || p > PriorityUrgent {
panic(fmt.Sprintf("mujica: Priority %d out of range", p))
}
}
// projectMember resolves a member ID or panics.
func projectMember(members []Member, id string) Member {
i := slices.IndexFunc(members, func(m Member) bool { return m.ID == id })
if i < 0 {
panic(fmt.Sprintf("mujica: unknown member %q", id))
}
return members[i]
}
// projectLabel resolves a label ID or panics.
func projectLabel(labels []TaskLabel, id string) TaskLabel {
i := slices.IndexFunc(labels, func(l TaskLabel) bool { return l.ID == id })
if i < 0 {
panic(fmt.Sprintf("mujica: unknown label %q", id))
}
return labels[i]
}
// projectStatus resolves a status ID or panics.
func projectStatus(statuses []WorkflowStatus, id string) WorkflowStatus {
i := slices.IndexFunc(statuses, func(s WorkflowStatus) bool { return s.ID == id })
if i < 0 {
panic(fmt.Sprintf("mujica: unknown status %q", id))
}
return statuses[i]
}
// projectDueText formats a date relative to today.
func projectDueText(c *ui.Context, due, today Date, done bool) (string, bool) {
datetimeCheckDate("project due", due)
if due.IsZero() {
return "", false
}
days := int(due.civil().Sub(today.civil()).Hours() / 24)
switch {
case days < 0 && !done:
return calendarCount(c, "projects.overdue", -days), true
case days == 0:
return msg(c, "projects.due_today"), false
case days == 1:
return msg(c, "projects.due_tomorrow"), false
}
return fmt.Sprintf(msg(c, "projects.due"), datetimeMonthName(c, due.Month), due.Day), false
}
// projectBar draws a named completion bar.
func projectBar(c *ui.Context, frac float64, label string) *ui.Element {
k := Tokens(c)
return ui.Box(c).Height(6).FillWidth().Role(ui.RoleProgress).Label(label).Description(fmt.Sprintf("%d%%", int(frac*100))).
Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(r, k.Border, 3)
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(frac), H: r.H}, k.AccentText, 3)
})
}
+54
View File
@@ -0,0 +1,54 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var projStatuses = []WorkflowStatus{
{ID: "backlog", Name: "Backlog", Group: StatusBacklog},
{ID: "todo", Name: "Todo", Group: StatusUnstarted},
{ID: "doing", Name: "In Progress", Group: StatusStarted},
{ID: "review", Name: "In Review", Group: StatusStarted},
{ID: "done", Name: "Done", Group: StatusCompleted},
{ID: "dropped", Name: "Dropped", Group: StatusCanceled},
}
var projMembers = []Member{{ID: "saki", Name: "Sakiko Togawa"}, {ID: "mutsu", Name: "Mutsumi Wakaba"},
{ID: "uika", Name: "Uika Misumi"}, {ID: "nyamu", Name: "Nyamu Yuutenji"}, {ID: "umiri", Name: "Umiri Yahata"}}
var projLabels = []TaskLabel{{ID: "bug", Name: "Bug", Color: EventDanger}, {ID: "ui", Name: "UI", Color: EventInfo},
{ID: "docs", Name: "Docs", Color: EventSuccess}}
// projTasks returns fresh tasks for one test.
func projTasks() []Task {
return []Task{
{ID: "AVE-1", Title: "Write the overture", Status: "todo", Priority: PriorityHigh, Due: oct(7), Assignees: []string{"saki"}, Labels: []string{"docs"}},
{ID: "AVE-2", Title: "Tune the organ", Status: "doing", Priority: PriorityUrgent, Due: oct(3), Assignees: []string{"mutsu", "uika"}, Labels: []string{"bug"}},
{ID: "AVE-3", Title: "Masks for the ball", Status: "doing", Priority: PriorityMedium, Assignees: []string{"nyamu"}},
{ID: "AVE-4", Title: "Rehearse act two", Status: "review", Priority: PriorityLow, Due: oct(12)},
{ID: "AVE-5", Title: "Book the hall", Status: "done", Done: true, Priority: PriorityNone, Due: oct(1), Labels: []string{"ui"}},
}
}
func TestProjectDueText(t *testing.T) {
var got []string
var late []bool
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
for _, c2 := range []struct {
due Date
done bool
}{{oct(2), false}, {oct(4), false}, {oct(5), false}, {oct(6), false}, {oct(14), false}, {oct(1), true}} {
s, l := projectDueText(c, c2.due, calendarToday, c2.done)
got, late = append(got, s), append(late, l)
}
}, 10, 10)
want := []string{"Overdue by 3 days", "Overdue by 1 day", "Due today", "Due tomorrow", "Due October 14", "Due October 1"}
for i := range want {
if got[i] != want[i] || late[i] != (i < 2) {
t.Fatalf("%d: %q %v", i, got[i], late[i])
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package mujica
import (
"fmt"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// Sprint is one iteration: its name, goal and inclusive dates.
type Sprint struct {
Name string
Goal string
Start, End Date
}
// SprintBoardOptions configure a SprintBoard.
type SprintBoardOptions struct {
Board KanbanBoardOptions
// Today counts the days left; zero uses the system clock.
Today Date
}
// SprintBoard combines iteration information, completion and a task board.
func SprintBoard(c *ui.Context, sprint Sprint, tasks *[]Task, opts SprintBoardOptions) KanbanResult {
datetimeCheckDate("SprintBoard Start", sprint.Start)
datetimeCheckDate("SprintBoard End", sprint.End)
if sprint.Start.IsZero() || sprint.End.Compare(sprint.Start) < 0 {
panic(fmt.Sprintf("mujica: SprintBoard dates %v..%v are not a span", sprint.Start, sprint.End))
}
k := Tokens(c)
d := Density(c)
today := datetimeToday(c, opts.Today)
var r KanbanResult
col := ui.Column(c).Gap(d.Unit() * 4).Grow(1).MinHeight(0).Role(ui.RoleGroup).Label(sprint.Name)
cache := ui.Local(col, "sprint-counts", func() sprintCache { return sprintCache{} })
var first *Task
if len(*tasks) > 0 {
first = &(*tasks)[0]
}
if !cache.ready || cache.first != first || cache.n != len(*tasks) || cache.version != opts.Board.Version || !slices.Equal(cache.statuses, opts.Board.Statuses) {
*cache = sprintCache{first: first, n: len(*tasks), version: opts.Board.Version, statuses: slices.Clone(opts.Board.Statuses), ready: true}
for _, t := range *tasks {
if g := projectStatus(opts.Board.Statuses, t.Status).Group; g != StatusCanceled {
cache.total++
if g == StatusCompleted {
cache.done++
}
}
}
}
done, total := cache.done, cache.total
col.Children(func() {
ui.Row(c).Gap(d.Unit() * 6).AlignItems(ui.Center).Wrap().Children(func() {
ui.Column(c).Gap(d.Unit()).Grow(1).Children(func() {
ui.Text(c, sprint.Name).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).TextColor(k.Text)
if sprint.Goal != "" {
ui.Text(c, sprint.Goal).TextColor(k.TextMuted)
}
ui.Text(c, weekViewTitle(c, sprint.Start, sprint.End)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
sprintStat(c, sprintDaysLeft(c, sprint, today), msg(c, "sprint_board.time"))
frac := 0.0
if total > 0 {
frac = float64(done) / float64(total)
}
ui.Column(c).Width(d.ControlHeight() * 5).Gap(d.Unit()).Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "sprint_board.done"), done, total)).TextColor(k.Text).FontFeatures("tnum")
Progress(c, frac, ProgressOptions{Label: msg(c, "sprint_board.progress")})
})
})
ui.Box(c).Height(12).FillWidth().Draw(func(p *ui.Painter, rr ui.Rect) { internal.RuleDiamond(p, rr, 8, 6, k.Ornament) })
r = KanbanBoard(c, tasks, opts.Board)
})
if _, moved := r.Moved(); moved {
cache.ready = false
c.Invalidate()
}
r.Element = col
return r
}
// sprintDaysLeft formats remaining time relative to the iteration.
func sprintDaysLeft(c *ui.Context, s Sprint, today Date) string {
switch {
case today.Compare(s.Start) < 0:
return calendarCount(c, "sprint_board.starts_in", int(s.Start.civil().Sub(today.civil()).Hours()/24))
case today.Compare(s.End) > 0:
return msg(c, "sprint_board.ended")
}
return calendarCount(c, "sprint_board.left", int(s.End.civil().Sub(today.civil()).Hours()/24)+1)
}
// sprintStat is a figure above its caption.
func sprintStat(c *ui.Context, value, caption string) {
k := Tokens(c)
ui.Column(c).Gap(2).Role(ui.RoleStatus).Label(caption + ": " + value).Children(func() {
ui.Text(c, value).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.AccentText)
ui.Text(c, caption).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
}
type sprintCache struct {
first *Task
n, version, done, total int
statuses []WorkflowStatus
ready bool
}
+87
View File
@@ -0,0 +1,87 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
var projSprint = Sprint{Name: "Sprint 12", Goal: "Ready the masquerade", Start: oct(1), End: oct(14)}
func sprintTester(sprint Sprint, tasks *[]Task, today Date, disabled bool, moves *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Height(640).Padding(8).Children(func() {
r := SprintBoard(c, sprint, tasks, SprintBoardOptions{Today: today, Board: KanbanBoardOptions{Columns: kanbanColumns3,
Statuses: projStatuses, Members: projMembers, Labels: projLabels, Disabled: disabled}})
if _, ok := r.Moved(); ok {
*moves++
}
})
}, 1240, 660)
}
func TestSprintBoardHeaderAndMoves(t *testing.T) {
tasks, n := projTasks(), 0
tt := sprintTester(projSprint, &tasks, calendarToday, false, &n)
for _, s := range []string{"Sprint 12", "Ready the masquerade", "October 1 – October 14, 2026", "10 days left", "1 of 5 done"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %v", s, tt.Texts())
}
}
_ = tt.Click("AVE-4 Rehearse act two")
tt.Key(ui.Alt, ui.KeyRight)
if n != 1 || !tt.HasText("2 of 5 done") {
t.Fatal("moving to Done should count")
}
}
func TestSprintBoardDaysAndDisabled(t *testing.T) {
tasks, n := projTasks(), 0
for _, c := range []struct {
today Date
want string
}{{Date{2026, 9, 30}, "Starts in 1 day"}, {oct(14), "1 day left"}, {oct(15), "Ended"}} {
if tt := sprintTester(projSprint, &tasks, c.today, false, &n); !tt.HasText(c.want) {
t.Errorf("%v: want %q", c.today, c.want)
}
}
tt := sprintTester(projSprint, &tasks, calendarToday, true, &n)
_ = tt.Click("AVE-4 Rehearse act two")
tt.Key(ui.Alt, ui.KeyRight)
if n != 0 {
t.Fatal("disabled board moved")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for an inverted sprint")
}
}()
sprintTester(Sprint{Name: "x", Start: oct(5), End: oct(1)}, &tasks, calendarToday, false, &n)
}
func TestSprintBoardSnapshot(t *testing.T) {
tasks := projTasks()
snapshot(t, "sprint_board", 1240, 560, func(c *ui.Context) {
ui.Column(c).Height(560).Padding(16).Children(func() {
SprintBoard(c, projSprint, &tasks, SprintBoardOptions{Today: calendarToday, Board: KanbanBoardOptions{Columns: kanbanColumns3,
Statuses: projStatuses, Members: projMembers, Labels: projLabels}})
})
})
}
func TestSprintBoardLargeData(t *testing.T) {
tasks := make([]Task, 100000)
for i := range tasks {
tasks[i] = Task{ID: fmt.Sprintf("BIG-%d", i+1), Title: "Task", Status: "todo"}
}
moves := 0
tt := sprintTester(projSprint, &tasks, calendarToday, false, &moves)
if built := len(tt.Texts()); built > 600 {
t.Fatalf("built %d labels", built)
}
if !tt.HasText("0 of 100000 done") {
t.Fatal("incorrect sprint summary")
}
}
+140
View File
@@ -0,0 +1,140 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// StatusSelectOptions configure a StatusSelect.
type StatusSelectOptions struct {
Disabled bool
Label string
}
var statusGroupKeys = [...]string{"status_select.backlog", "status_select.unstarted", "status_select.started",
"status_select.completed", "status_select.canceled"}
// StatusSelect chooses a status grouped by workflow stage.
func StatusSelect(c *ui.Context, value *string, statuses []WorkflowStatus, opts StatusSelectOptions) ChoiceResult {
if len(statuses) == 0 {
panic("mujica: StatusSelect needs statuses")
}
for i, s := range statuses {
if s.Group < StatusBacklog || s.Group > StatusCanceled || s.ID == "" {
panic(fmt.Sprintf("mujica: StatusSelect status %+v is not valid", s))
}
for _, o := range statuses[:i] {
if o.ID == s.ID {
panic(fmt.Sprintf("mujica: StatusSelect status %q repeats", s.ID))
}
}
}
cur := projectStatus(statuses, *value)
k := Tokens(c)
old := *value
pick := old
var s *ui.SelectParts[string]
root := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
epoch := ui.Local(root, "status-epoch", func() struct {
disabled bool
generation int
} {
return struct {
disabled bool
generation int
}{}
})
if disabled := root.IsDisabled(); disabled != epoch.disabled {
epoch.disabled = disabled
epoch.generation++
}
root.Children(func() {
ui.Row(c).Key(epoch.generation).Children(func() { s = ui.SelectBase(c, &pick) })
})
tr := s.Trigger
label := opts.Label
if label == "" {
label = msg(c, "status_select.label")
}
tr.Label(label).Description(cur.Name)
selectField(c, tr, s.Open(), func() {
statusGlyphBox(c, cur.Group)
ui.Text(c, cur.Name).SingleLine().Grow(1).Shrink(1).TextColor(k.Text)
})
if !tr.IsDisabled() {
s.Popup(func(panel *ui.Element) {
selectPanel(c, panel)
_, wh := c.Size()
ui.Scroll(c).MaxHeight(min(320, wh/2)).Children(func() {
for g := StatusBacklog; g <= StatusCanceled; g++ {
first := true
for _, st := range statuses {
if st.Group != g {
continue
}
if first {
ui.Text(c, msg(c, statusGroupKeys[g])).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).
Padding(Density(c).Unit(), Density(c).Unit()*2).Role(ui.RoleColumnHeader)
first = false
}
it := s.Item(st.ID)
selectRow(c, it, it.Highlighted(), st.ID == *value, "")
it.Label(st.Name).Children(func() {
statusGlyphBox(c, st.Group)
ui.Text(c, st.Name).SingleLine()
})
}
}
})
})
}
if pick != *value && !tr.IsDisabled() {
*value = pick
}
changed := *value != old
if changed {
slog.Debug("mujica: status_select change", "from", old, "to", *value)
}
return ChoiceResult{Element: tr, changed: changed}
}
// statusGlyphBox reserves 14 DIPs for a status glyph.
func statusGlyphBox(c *ui.Context, g StatusGroup) {
k := Tokens(c)
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { statusGlyph(p, r, g, k) })
}
// statusGlyph draws a distinct shape for each workflow stage.
func statusGlyph(p *ui.Painter, r ui.Rect, g StatusGroup, k theme.Tokens) {
cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W/2-1
ring := func(col ui.Color) {
var path ui.Path
internal.Arc(&path, cx, cy, rad, 0, 2*math.Pi, true)
p.StrokePath(&path, 1.5, col)
}
switch g {
case StatusBacklog:
for i := 0; i < 8; i += 2 {
var path ui.Path
internal.Arc(&path, cx, cy, rad, float64(i)*math.Pi/4, float64(i+1)*math.Pi/4, true)
p.StrokePath(&path, 1.5, k.TextMuted)
}
case StatusUnstarted:
ring(k.TextMuted)
case StatusStarted:
ring(k.Warning)
p.FillPath(internal.Sector(cx, cy, 0, rad-2, 0, math.Pi), k.Warning)
case StatusCompleted:
p.FillPath(internal.Sector(cx, cy, 0, rad+1, 0, 2*math.Pi), k.Success)
internal.Mark(p, ui.Rect{X: r.X + 3, Y: r.Y + 3, W: r.W - 6, H: r.H - 6}, "check", k.Surface)
case StatusCanceled:
p.FillPath(internal.Sector(cx, cy, 0, rad+1, 0, 2*math.Pi), k.TextMuted)
internal.Mark(p, ui.Rect{X: r.X + 3, Y: r.Y + 3, W: r.W - 6, H: r.H - 6}, "x", k.Surface)
}
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func statusTester(v *string, opts *StatusSelectOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Children(func() {
if StatusSelect(c, v, projStatuses, *opts).Changed() {
*n++
}
})
}, 400, 520)
}
func TestStatusSelectGroupsAndPick(t *testing.T) {
v, n := "todo", 0
tt := statusTester(&v, &StatusSelectOptions{}, &n)
_ = tt.Click("Status")
for _, g := range []string{"Backlog", "To do", "In progress", "Done", "Canceled"} {
if !tt.HasText(g) {
t.Fatalf("group %q missing: %v", g, tt.Texts())
}
}
_ = tt.Click("In Review")
if v != "review" || n != 1 {
t.Fatalf("picked %q", v)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if v != "done" || n != 2 {
t.Fatalf("keyboard picked %q", v)
}
}
func TestStatusSelectDisabledAndInvalid(t *testing.T) {
v, n := "todo", 0
opts := &StatusSelectOptions{Disabled: true}
tt := statusTester(&v, opts, &n)
_ = tt.Click("Status")
tt.Key(0, ui.KeyDown)
if tt.HasText("In Review") || v != "todo" || n != 0 {
t.Fatal("disabled select opened")
}
for _, bad := range []string{"nope", ""} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%q: expected panic", bad)
}
}()
w := bad
statusTester(&w, &StatusSelectOptions{}, &n)
}()
}
}
func TestStatusSelectSnapshot(t *testing.T) {
values := []string{"backlog", "todo", "doing", "done", "dropped"}
snapshot(t, "status_select", 440, 176, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(8).Wrap().Children(func() {
for i := range values {
StatusSelect(c, &values[i], projStatuses, StatusSelectOptions{})
}
})
})
}
func TestStatusSelectAncestorDisableCloses(t *testing.T) {
value, disabled := "todo", false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(disabled).Children(func() { StatusSelect(c, &value, projStatuses, StatusSelectOptions{}) })
}, 400, 520)
if err := tt.Click("Status"); err != nil {
t.Fatal(err)
}
disabled = true
tt.Frame()
if tt.HasText("In Review") {
t.Fatal("ancestor-disabled popup remains visible")
}
disabled = false
tt.Frame()
if tt.HasText("In Review") {
t.Fatal("popup reopens after enabling")
}
}
+158
View File
@@ -0,0 +1,158 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// SubtaskListOptions configure a SubtaskList.
type SubtaskListOptions struct {
Disabled bool
Label string
// Version changes when subtasks change in place.
Version int
}
// subtaskDrag is the ID of a subtask being dragged.
type subtaskDrag string
// SubtaskList edits and reorders subtasks with completion progress.
func SubtaskList(c *ui.Context, subs *[]Subtask, opts SubtaskListOptions) ChoiceResult {
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "subtask_list.label")
}
changed := false
col := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
draft := ui.Local(col, "subtask-draft", func() string { return "" })
cache := ui.Local(col, "subtask-summary", func() subtaskCache { return subtaskCache{} })
var first *Subtask
if len(*subs) > 0 {
first = &(*subs)[0]
}
if !cache.ready || cache.first != first || cache.n != len(*subs) || cache.version != opts.Version {
*cache = subtaskCache{first: first, n: len(*subs), version: opts.Version, ready: true}
seen := map[string]bool{}
for _, s := range *subs {
if s.ID == "" || seen[s.ID] {
panic(fmt.Sprintf("mujica: SubtaskList subtask %q is empty or repeated", s.ID))
}
seen[s.ID] = true
if s.Done {
cache.done++
}
}
}
state := ui.Local(col, "subtask-rows", func() ui.ListState { return ui.ListState{} })
state.Key = func(i int) any { return (*subs)[i].ID }
var pending func()
move := func(from, to int, how string) {
if col.IsDisabled() || to < 0 || to >= len(*subs) || from == to {
return
}
if pending == nil {
pending = func() {
step := (*subs)[from]
*subs = slices.Insert(slices.Delete(*subs, from, from+1), to, step)
changed = true
state.ScrollIntoView(to)
slog.Debug("mujica: subtask_list move", "id", step.ID, "to", to, "by", how)
}
}
}
col.Children(func() {
done := cache.done
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
frac := 0.0
if len(*subs) > 0 {
frac = float64(done) / float64(len(*subs))
}
ui.Box(c).Grow(1).Children(func() { projectBar(c, frac, label) })
ui.Text(c, fmt.Sprintf(msg(c, "subtask_list.progress"), done, len(*subs))).FontSize(FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).FontFeatures("tnum")
})
removed := -1
ui.List(c, state, len(*subs), func(i int) {
s := &(*subs)[i]
row := ui.Row(c).Key(s.ID).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Role(ui.RoleListItem)
if id, ok := ui.Drop[subtaskDrag](row); ok {
if from := slices.IndexFunc(*subs, func(o Subtask) bool { return o.ID == string(id) }); from >= 0 {
move(from, i, "drag")
}
}
if _, over := ui.DragOver[subtaskDrag](row); over {
row.Background(k.Selection)
}
row.Children(func() {
grip := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Center().Cursor(ui.CursorGrab).Label(fmt.Sprintf(msg(c, "subtask_list.drag"), s.Title))
if !grip.IsDisabled() {
grip.Drag(subtaskDrag(s.ID))
}
grip.Children(func() { ui.Icon(c, icons.Must("grip-vertical")).Size(14, 14).TextColor(k.TextMuted) })
v := s.Done
cb := Checkbox(c, &v, s.Title, CheckboxOptions{Disabled: opts.Disabled})
if cb.Changed() && !cb.IsDisabled() {
s.Done, changed = v, true
}
if !cb.IsDisabled() {
if cb.Shortcut(ui.Alt, ui.KeyUp) {
move(i, i-1, "keys")
}
if cb.Shortcut(ui.Alt, ui.KeyDown) {
move(i, i+1, "keys")
}
}
ui.Spacer(c)
if b := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "subtask_list.remove"), s.Title)); b.Clicked() && !b.IsDisabled() {
removed = i
}
})
}).Height(min(float32(len(*subs)), 8) * d.ControlHeight())
if removed >= 0 && !col.IsDisabled() {
slog.Debug("mujica: subtask_list remove", "id", (*subs)[removed].ID)
*subs, changed = slices.Delete(*subs, removed, removed+1), true
}
in := TextInput(c, draft, TextInputOptions{Placeholder: msg(c, "subtask_list.add"), Label: msg(c, "subtask_list.add"), Disabled: opts.Disabled})
if in.Submitted() && strings.TrimSpace(*draft) != "" && !col.IsDisabled() {
*subs = append(*subs, Subtask{ID: subtaskNewID(*subs), Title: strings.TrimSpace(*draft)})
*draft, changed = "", true
slog.Debug("mujica: subtask_list add")
}
})
if pending != nil {
pending()
}
if changed {
cache.ready = false
c.Invalidate()
}
return ChoiceResult{Element: col, changed: changed}
}
// subtaskNewID returns an ID no subtask uses.
func subtaskNewID(subs []Subtask) string {
for n := len(subs) + 1; ; n++ {
id := fmt.Sprintf("sub-%d", n)
if !slices.ContainsFunc(subs, func(s Subtask) bool { return s.ID == id }) {
return id
}
}
}
type subtaskCache struct {
first *Subtask
n, version, done int
ready bool
}
+107
View File
@@ -0,0 +1,107 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
func subtaskSample() []Subtask {
return []Subtask{{ID: "a", Title: "Sketch", Done: true}, {ID: "b", Title: "Score"}, {ID: "c", Title: "Rehearse"}}
}
func subtaskTester(subs *[]Subtask, opts *SubtaskListOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Children(func() {
if SubtaskList(c, subs, *opts).Changed() {
*n++
}
})
}, 480, 480)
}
func subtaskTitles(s []Subtask) string {
out := ""
for _, x := range s {
out += x.ID
}
return out
}
func TestSubtaskListProgressCheckAdd(t *testing.T) {
subs, n := subtaskSample(), 0
tt := subtaskTester(&subs, &SubtaskListOptions{}, &n)
if !tt.HasText("1 of 3 done") {
t.Fatalf("progress %v", tt.Texts())
}
_ = tt.Click("Score")
if !subs[1].Done || !tt.HasText("2 of 3 done") {
t.Fatal("check")
}
_ = tt.Click("Add a subtask")
tt.Type("Bow")
tt.Key(0, ui.KeyEnter)
if len(subs) != 4 || subs[3].Title != "Bow" || subs[3].ID != "sub-4" || !tt.HasText("2 of 4 done") {
t.Fatalf("add %+v", subs)
}
_ = tt.Click("Remove Sketch")
if subtaskTitles(subs) != "bcsub-4" || n != 3 {
t.Fatalf("remove %v (%d)", subtaskTitles(subs), n)
}
}
func TestSubtaskListReorder(t *testing.T) {
subs, n := subtaskSample(), 0
tt := subtaskTester(&subs, &SubtaskListOptions{}, &n)
_ = tt.Click("Rehearse")
tt.Key(ui.Alt, ui.KeyUp)
if subtaskTitles(subs) != "acb" {
t.Fatalf("Alt+Up %v", subtaskTitles(subs))
}
g, _ := tt.Find("Drag Sketch")
target, _ := tt.Find("Drag Score")
calDrag(tt, g.X+g.W/2, g.Y+g.H/2, target.X+40, target.Y+target.H/2)
if subtaskTitles(subs) != "cba" {
t.Fatalf("drag %v", subtaskTitles(subs))
}
}
func TestSubtaskListDisabledAndInvalid(t *testing.T) {
subs, n := subtaskSample(), 0
tt := subtaskTester(&subs, &SubtaskListOptions{Disabled: true}, &n)
_ = tt.Click("Score")
_ = tt.Click("Remove Sketch")
g, _ := tt.Find("Drag Sketch")
calDrag(tt, g.X+g.W/2, g.Y+g.H/2, g.X+g.W/2, g.Y+120)
if n != 0 || subtaskTitles(subs) != "abc" || subs[1].Done {
t.Fatal("disabled list changed")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic for repeated IDs")
}
}()
bad := []Subtask{{ID: "x"}, {ID: "x"}}
subtaskTester(&bad, &SubtaskListOptions{}, &n)
}
func TestSubtaskListSnapshot(t *testing.T) {
subs := subtaskSample()
snapshot(t, "subtask_list", 440, 240, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { SubtaskList(c, &subs, SubtaskListOptions{}) })
})
}
func TestSubtaskListLargeData(t *testing.T) {
subs := make([]Subtask, 100000)
for i := range subs {
subs[i] = Subtask{ID: fmt.Sprint(i), Title: "Step"}
}
n := 0
tt := subtaskTester(&subs, &SubtaskListOptions{}, &n)
if built := len(tt.Texts()); built > 200 {
t.Fatalf("built %d labels", built)
}
}
+115
View File
@@ -0,0 +1,115 @@
package mujica
import (
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// TaskDetailPanelOptions configure a TaskDetailPanel.
type TaskDetailPanelOptions struct {
Statuses []WorkflowStatus
Members []Member
Labels []TaskLabel
// Activity builds the caller's activity log below the fields.
Activity func()
// Today anchors the due date; zero uses the system clock.
Today Date
Disabled bool
// Version changes when task or related data change in place.
Version int
}
// TaskDetailResult exposes the panel, edits and close events.
type TaskDetailResult struct {
Element *ui.Element
changed bool
closed bool
}
// Changed reports a field edit this frame.
func (r TaskDetailResult) Changed() bool { return r.changed }
// Closed reports a click on Close or Escape.
func (r TaskDetailResult) Closed() bool { return r.closed }
// TaskDetailPanel edits task fields and hosts an activity slot.
func TaskDetailPanel(c *ui.Context, task *Task, opts TaskDetailPanelOptions) TaskDetailResult {
projectStatus(opts.Statuses, task.Status)
k := Tokens(c)
d := Density(c)
r := TaskDetailResult{}
panel := ui.Column(c).Gap(d.Unit()*4).Padding(d.Unit()*4).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Radius(theme.CardRadius).Role(ui.RoleGroup).Label(task.ID + " " + task.Title).Disabled(opts.Disabled)
r.Element = panel
opts.Disabled = panel.IsDisabled()
if opts.Disabled {
copied := *task
copied.Assignees, copied.Labels, copied.Subtasks = slices.Clone(task.Assignees), slices.Clone(task.Labels), slices.Clone(task.Subtasks)
task = &copied
}
panel.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
IssueIdBadge(c, task.ID, IssueIdBadgeOptions{Copyable: true, Disabled: opts.Disabled})
ui.Spacer(c)
if b := iconAction(c, icons.Must("x"), msg(c, "task_detail_panel.close")); b.Clicked() && !b.IsDisabled() {
r.closed = true
}
})
title := TextInput(c, &task.Title, TextInputOptions{Label: msg(c, "task_detail_panel.title"), Disabled: opts.Disabled})
title.Input.Font(theme.SerifFont)
r.changed = title.Changed()
taskField(c, msg(c, "task_detail_panel.status"), func() {
r.changed = StatusSelect(c, &task.Status, opts.Statuses, StatusSelectOptions{Disabled: opts.Disabled}).Changed() || r.changed
})
taskField(c, msg(c, "task_detail_panel.priority"), func() {
prios := make([]SelectOption[Priority], 0, 5)
for p := PriorityUrgent; p >= PriorityNone; p-- {
prios = append(prios, SelectOption[Priority]{Value: p, Label: PriorityName(c, p)})
}
r.changed = Select(c, &task.Priority, prios, SelectOptions{Label: msg(c, "task_detail_panel.priority"), Disabled: opts.Disabled}).Changed() || r.changed
})
taskField(c, msg(c, "assignee_picker.label"), func() {
r.changed = AssigneePicker(c, &task.Assignees, opts.Members, AssigneePickerOptions{Disabled: opts.Disabled, Version: opts.Version}).Changed() || r.changed
})
taskField(c, msg(c, "due_date_picker.label"), func() {
r.changed = DueDatePicker(c, &task.Due, DueDatePickerOptions{Done: task.Done, Today: opts.Today, Disabled: opts.Disabled}).Changed() || r.changed
})
taskField(c, msg(c, "label_manager.label"), func() {
options := make([]SelectOption[string], len(opts.Labels))
for i, l := range opts.Labels {
options[i] = SelectOption[string]{Value: l.ID, Label: l.Name}
}
r.changed = MultiSelect(c, &task.Labels, options, MultiSelectOptions{Label: msg(c, "label_manager.label"), Disabled: opts.Disabled}).Changed() || r.changed
})
r.changed = SubtaskList(c, &task.Subtasks, SubtaskListOptions{Disabled: opts.Disabled, Version: opts.Version}).Changed() || r.changed
if opts.Activity != nil {
ui.Box(c).Height(16).FillWidth().Draw(func(p *ui.Painter, rr ui.Rect) { internal.RuleDiamond(p, rr, 8, 6, k.Ornament) })
ui.Text(c, msg(c, "task_detail_panel.activity")).Font(theme.SerifFont).TextColor(k.Text)
opts.Activity()
}
})
if !panel.IsDisabled() && panel.FocusWithin() && c.Shortcut(0, ui.KeyEscape) {
r.closed = true
}
if r.changed {
slog.Debug("mujica: task_detail_panel change", "id", task.ID)
}
if r.closed {
slog.Debug("mujica: task_detail_panel close", "id", task.ID)
}
return r
}
// taskField puts a caption above a control.
func taskField(c *ui.Context, label string, control func()) {
ui.Column(c).Gap(Density(c).Unit()).Children(func() {
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted)
control()
})
}
+147
View File
@@ -0,0 +1,147 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
type detailProbe struct {
task Task
disabled bool
version int
changes, closes int
activityRendered bool
}
func (p *detailProbe) tester() *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Scroll(c).Fill().Children(func() {
ui.Box(c).Width(420).Children(func() {
r := TaskDetailPanel(c, &p.task, TaskDetailPanelOptions{Statuses: projStatuses, Members: projMembers, Labels: projLabels,
Today: calendarToday, Disabled: p.disabled, Version: p.version, Activity: func() {
p.activityRendered = true
ui.Text(c, "Sakiko opened this task")
}})
if r.Changed() {
p.changes++
}
if r.Closed() {
p.closes++
}
})
})
}, 640, 1200)
}
func TestTaskDetailPanelEditsFields(t *testing.T) {
p := &detailProbe{task: projTasks()[0]}
tt := p.tester()
if !tt.HasText("Sakiko opened this task") || !tt.HasText("Activity") {
t.Fatal("activity slot missing")
}
_ = tt.Click("Todo")
_ = tt.Click("In Review")
_ = tt.Click("Tomorrow")
_ = tt.Click("Add a subtask")
tt.Type("Copy parts")
tt.Key(0, ui.KeyEnter)
if p.task.Status != "review" || p.task.Due != oct(6) || len(p.task.Subtasks) != 1 || p.changes != 3 {
t.Fatalf("edited %+v (%d changes)", p.task, p.changes)
}
_ = tt.Click("Title")
tt.Command("selectAll")
tt.Type("Write the requiem")
if p.task.Title != "Write the requiem" {
t.Fatalf("title %q", p.task.Title)
}
_ = tt.Click("Close details")
if p.closes != 1 {
t.Fatal("close not reported")
}
}
func TestTaskDetailPanelDisabled(t *testing.T) {
p := &detailProbe{task: projTasks()[0], disabled: true}
tt := p.tester()
_ = tt.Click("Tomorrow")
_ = tt.Click("Title")
tt.Type("x")
_ = tt.Click("Close details")
if p.changes != 0 || p.closes != 0 || p.task.Title != "Write the overture" {
t.Fatalf("disabled panel changed %+v", p.task)
}
}
func TestTaskDetailPanelSnapshot(t *testing.T) {
task := projTasks()[1]
task.Subtasks = []Subtask{{ID: "s1", Title: "Replace the pipes", Done: true}, {ID: "s2", Title: "Tune by ear"}}
snapshot(t, "task_detail_panel", 460, 900, func(c *ui.Context) {
ui.Box(c).Width(420).Margin(16).Children(func() {
TaskDetailPanel(c, &task, TaskDetailPanelOptions{Statuses: projStatuses, Members: projMembers, Labels: projLabels,
Today: calendarToday, Activity: func() { ui.Text(c, "Mutsumi moved this to In Progress") }})
})
})
}
func TestTaskDetailPanelAncestorDisableClosesLabels(t *testing.T) {
task, disabled := projTasks()[0], false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(disabled).Children(func() {
TaskDetailPanel(c, &task, TaskDetailPanelOptions{Statuses: projStatuses, Members: projMembers, Labels: projLabels})
})
}, 600, 1000)
if err := tt.Click("Docs"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Select all") {
t.Fatal("labels popup did not open")
}
disabled = true
tt.Frame()
if tt.HasText("Select all") {
t.Fatal("disabled panel retained its labels popup")
}
if taskSnapshot(task) != taskSnapshot(projTasks()[0]) {
t.Fatal("disabled panel changed task fields")
}
}
func TestTaskDetailPanelEscapeClosesPopupFirst(t *testing.T) {
p := &detailProbe{task: projTasks()[0]}
tt := p.tester()
if err := tt.Click("Todo"); err != nil {
t.Fatal(err)
}
if !tt.HasText("In Review") {
t.Fatal("status popup did not open")
}
tt.Key(0, ui.KeyEscape)
if p.closes != 0 || tt.HasText("In Review") {
t.Fatalf("popup escape closes = %d, popup visible = %v", p.closes, tt.HasText("In Review"))
}
tt.Key(0, ui.KeyEscape)
if p.closes != 1 {
t.Fatalf("panel escape closes = %d", p.closes)
}
}
// taskSnapshot compares editable task fields.
func taskSnapshot(t Task) string {
return fmt.Sprintf("%q|%q|%d|%v|%q|%q|%v", t.Title, t.Status, t.Priority, t.Due, t.Assignees, t.Labels, t.Subtasks)
}
func TestTaskDetailPanelVersionUpdatesSubtasks(t *testing.T) {
p := &detailProbe{task: projTasks()[0]}
p.task.Subtasks = []Subtask{{ID: "one", Title: "One"}}
tt := p.tester()
p.task.Subtasks[0].Done = true
p.version++
tt.Frame()
if !tt.HasText("1 of 1 done") || p.changes != 0 {
t.Fatalf("external update: changes %d, labels %v", p.changes, tt.Texts())
}
}
+99
View File
@@ -0,0 +1,99 @@
package mujica
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// TaskItemOptions configure a TaskItem.
type TaskItemOptions struct {
Members []Member
// Today anchors the overdue mark; zero uses the system clock.
Today Date
Disabled bool
}
// taskItemEdit is a title being edited inline.
type taskItemEdit struct {
on bool
draft string
}
// TaskItem displays task fields with completion and inline editing.
func TaskItem(c *ui.Context, task *Task, opts TaskItemOptions) ProjectResult {
projectCheckPriority(task.Priority)
for _, id := range task.Assignees {
projectMember(opts.Members, id)
}
k := Tokens(c)
d := Density(c)
today := datetimeToday(c, opts.Today)
r := ProjectResult{}
row := ui.Row(c).MinHeight(d.ControlHeight()).Gap(d.Unit() * 3).AlignItems(ui.Center).PaddingX(d.Unit() * 2).
Role(ui.RoleListItem).Label(task.Title).Disabled(opts.Disabled)
r.Element = row
edit := ui.Local(row, "task-item-edit", func() taskItemEdit { return taskItemEdit{} })
if row.Hovered() {
row.Background(k.SurfaceHover)
}
row.Children(func() {
done := task.Done
if Checkbox(c, &done, "", CheckboxOptions{Disabled: opts.Disabled}).Label(task.Title).Changed() && !row.IsDisabled() {
task.Done, r.changed = done, task.ID
slog.Debug("mujica: task_item done", "id", task.ID, "done", done)
}
if edit.on && !row.IsDisabled() {
in := TextInput(c, &edit.draft, TextInputOptions{Label: msg(c, "task_item.rename")})
in.Element.Grow(1)
in.Input.AutoFocus()
switch {
case in.Submitted() && strings.TrimSpace(edit.draft) != "":
if t := strings.TrimSpace(edit.draft); t != task.Title {
task.Title, r.changed = t, task.ID
slog.Debug("mujica: task_item rename", "id", task.ID)
}
edit.on = false
case in.Input.Shortcut(0, ui.KeyEscape):
edit.on = false
}
return
}
edit.on = false
title := ui.ButtonBase(c).Grow(1).Shrink(1).Justify(ui.Start).MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).Label(task.Title)
internal.Ring(title, k.Focus, theme.ControlRadius)
title.Children(func() {
t := ui.Text(c, task.Title).SingleLine().Ellipsis("…").TextColor(k.Text)
if task.Done {
t.Strikethrough().TextColor(k.TextMuted)
}
})
if !title.IsDisabled() {
if title.DoubleClicked() || title.Shortcut(0, ui.KeyF2) {
*edit = taskItemEdit{on: true, draft: task.Title}
} else if title.Clicked() {
r.opened = task.ID
}
}
PriorityIndicator(c, task.Priority, PriorityIndicatorOptions{IconOnly: true})
if text, late := projectDueText(c, task.Due, today, task.Done); text != "" {
fg := k.TextMuted
if late {
fg = k.Danger
}
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(fg).SingleLine().Shrink(0)
}
if len(task.Assignees) > 0 {
items := make([]AvatarItem, len(task.Assignees))
for i, id := range task.Assignees {
items[i] = AvatarItem{Name: projectMember(opts.Members, id).Name}
}
AvatarGroup(c, items, AvatarGroupOptions{Max: 2, Size: 24})
}
})
return r
}
+102
View File
@@ -0,0 +1,102 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
type taskItemProbe struct {
task Task
disabled bool
changes, openeds []string
}
func (p *taskItemProbe) tester() *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(8).Children(func() {
r := TaskItem(c, &p.task, TaskItemOptions{Members: projMembers, Today: calendarToday, Disabled: p.disabled})
if id, ok := r.Changed(); ok {
p.changes = append(p.changes, id)
}
if id, ok := r.Opened(); ok {
p.openeds = append(p.openeds, id)
}
})
}, 600, 120)
}
func TestTaskItemFieldsAndCheck(t *testing.T) {
p := &taskItemProbe{task: projTasks()[1]}
tt := p.tester()
for _, s := range []string{"Tune the organ", "Urgent", "Overdue by 2 days", "Mutsumi Wakaba"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %v", s, tt.Texts())
}
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !p.task.Done || len(p.changes) != 1 || tt.HasText("Overdue") {
t.Fatalf("check: %+v", p.task)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(p.openeds) != 1 || p.openeds[0] != "AVE-2" {
t.Fatalf("opened %v", p.openeds)
}
}
func TestTaskItemInlineRename(t *testing.T) {
p := &taskItemProbe{task: projTasks()[0]}
tt := p.tester()
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyF2)
tt.Command("selectAll")
tt.Type("Write the finale")
tt.Key(0, ui.KeyEnter)
if p.task.Title != "Write the finale" || len(p.changes) != 1 || !tt.HasText("Write the finale") {
t.Fatalf("rename %q", p.task.Title)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyF2)
tt.Type(" draft")
tt.Key(0, ui.KeyEscape)
if p.task.Title != "Write the finale" || !tt.HasText("Write the finale") {
t.Fatalf("Escape kept %q", p.task.Title)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyF2)
tt.Command("selectAll")
tt.Type(" ")
tt.Key(0, ui.KeyEnter)
if p.task.Title != "Write the finale" {
t.Fatal("an empty title was saved")
}
}
func TestTaskItemDisabled(t *testing.T) {
p := &taskItemProbe{task: projTasks()[0], disabled: true}
tt := p.tester()
_ = tt.Click("Write the overture")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyF2)
if p.task.Done || len(p.changes)+len(p.openeds) != 0 {
t.Fatal("disabled item reacted")
}
}
func TestTaskItemSnapshot(t *testing.T) {
tasks := projTasks()
snapshot(t, "task_item", 560, 220, func(c *ui.Context) {
ui.Column(c).Padding(8).Children(func() {
for i := range tasks {
TaskItem(c, &tasks[i], TaskItemOptions{Members: projMembers, Today: calendarToday})
}
})
})
}
+215
View File
@@ -0,0 +1,215 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// TaskGrouping chooses the sections of a TaskList.
type TaskGrouping int
const (
TaskGroupNone TaskGrouping = iota
TaskGroupStatus
TaskGroupPriority
)
// TaskFilter shows all, open or done tasks.
type TaskFilter int
const (
TaskShowAll TaskFilter = iota
TaskShowOpen
TaskShowDone
)
// TaskListOptions configure a TaskList.
type TaskListOptions struct {
GroupBy TaskGrouping
// Statuses order the status groups; required with TaskGroupStatus.
Statuses []WorkflowStatus
Members []Member
// Version must change when tasks change in place.
Version int
// Today anchors overdue marks; zero uses the system clock.
Today Date
Disabled bool
Label string
}
// taskRow identifies a heading or task.
type taskRow struct {
head string
task int
}
// taskListCache keeps rows until the data, grouping or filter change.
type taskListCache struct {
first *Task
n, ver int
group TaskGrouping
filter TaskFilter
rows []taskRow
counted [3]int
statuses []WorkflowStatus
locale Locale
}
// TaskList groups and filters tasks, virtualizing visible rows.
func TaskList(c *ui.Context, tasks []Task, filter *TaskFilter, opts TaskListOptions) ProjectResult {
if opts.GroupBy < TaskGroupNone || opts.GroupBy > TaskGroupPriority || *filter < TaskShowAll || *filter > TaskShowDone {
panic(fmt.Sprintf("mujica: TaskList grouping %d or filter %d out of range", opts.GroupBy, *filter))
}
if opts.GroupBy == TaskGroupStatus && len(opts.Statuses) == 0 {
panic("mujica: TaskList grouping by status needs Statuses")
}
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "task_list.label")
}
r := ProjectResult{}
root := ui.Column(c).Grow(1).MinHeight(0).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
cache := ui.Local(root, "task-list", func() *taskListCache { return &taskListCache{} })
var first *Task
if len(tasks) > 0 {
first = &tasks[0]
}
if x := *cache; x.rows == nil || x.first != first || x.n != len(tasks) || x.ver != opts.Version || x.group != opts.GroupBy || x.filter != *filter || !slices.Equal(x.statuses, opts.Statuses) || x.locale != current(c).locale {
*cache = &taskListCache{first: first, n: len(tasks), ver: opts.Version, group: opts.GroupBy, filter: *filter, statuses: slices.Clone(opts.Statuses), locale: current(c).locale}
(*cache).rows, (*cache).counted = taskListRows(c, tasks, opts, *filter)
}
rows := (*cache).rows
state := ui.Local(root, "task-list-state", func() *ui.ListState { return &ui.ListState{} })
st := *state
st.Key = func(i int) any {
if rows[i].task < 0 {
return "head:" + rows[i].head
}
return tasks[rows[i].task].ID
}
st.Header = func(i int) bool { return rows[i].task < 0 }
root.Children(func() {
counts := (*cache).counted
sel := int(*filter)
taskListFilter(c, &sel, []string{
fmt.Sprintf(msg(c, "task_list.all"), counts[0]),
fmt.Sprintf(msg(c, "task_list.open"), counts[1]),
fmt.Sprintf(msg(c, "task_list.done"), counts[2]),
})
if sel != int(*filter) && !root.IsDisabled() {
*filter = TaskFilter(sel)
slog.Debug("mujica: task_list filter", "filter", *filter)
}
list := ui.List(c, st, len(rows), func(i int) {
row := rows[i]
if row.task < 0 {
h := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).PaddingX(d.Unit() * 2).Background(k.Background).Role(ui.RoleColumnHeader)
h.Children(func() { ui.Text(c, row.head).Font(theme.SerifFont).TextColor(k.Text) })
h.DrawOver(func(p *ui.Painter, rr ui.Rect) { internal.Rule(p, rr.X, rr.X+rr.W, rr.Y+rr.H-1, k.Ornament) })
return
}
ir := TaskItem(c, &tasks[row.task], TaskItemOptions{Members: opts.Members, Today: opts.Today, Disabled: opts.Disabled})
if id, ok := ir.Changed(); ok {
r.changed = id
(*cache).rows = nil
c.Invalidate()
}
if id, ok := ir.Opened(); ok {
r.opened = id
}
}).Grow(1).MinHeight(d.ControlHeight() * 4)
list.Children(func() {
if len(rows) == 0 {
ui.Text(c, msg(c, "task_list.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 3)
}
})
})
return r
}
// taskListRows computes groups and completion counts.
func taskListRows(c *ui.Context, tasks []Task, opts TaskListOptions, filter TaskFilter) ([]taskRow, [3]int) {
var counts [3]int
keep := make([]int, 0, len(tasks))
for i, t := range tasks {
counts[0]++
if t.Done {
counts[2]++
} else {
counts[1]++
}
if filter == TaskShowAll || (filter == TaskShowDone) == t.Done {
keep = append(keep, i)
}
}
rows := make([]taskRow, 0, len(keep)+8)
switch opts.GroupBy {
case TaskGroupNone:
for _, i := range keep {
rows = append(rows, taskRow{task: i})
}
case TaskGroupStatus:
for _, s := range opts.Statuses {
rows = taskListSection(rows, s.Name, keep, func(i int) bool { return tasks[i].Status == s.ID })
}
for _, i := range keep {
projectStatus(opts.Statuses, tasks[i].Status)
}
case TaskGroupPriority:
for p := PriorityUrgent; p >= PriorityNone; p-- {
rows = taskListSection(rows, PriorityName(c, p), keep, func(i int) bool { return tasks[i].Priority == p })
}
}
return slices.Clip(rows), counts
}
// taskListSection appends a heading and its tasks when any match.
func taskListSection(rows []taskRow, head string, keep []int, in func(int) bool) []taskRow {
start := len(rows)
for _, i := range keep {
if in(i) {
if len(rows) == start {
rows = append(rows, taskRow{head: head, task: -1})
}
rows = append(rows, taskRow{task: i})
}
}
return rows
}
// taskListFilter marks the selected completion filter with an inset.
func taskListFilter(c *ui.Context, value *int, labels []string) {
k, d := Tokens(c), Density(c)
parts := ui.SegmentedBase(c, value, len(labels))
parts.Track.MinHeight(max(d.ControlHeight(), theme.MinHitSize+4)).AlignItems(ui.Stretch).Padding(2).Gap(2).Radius(theme.ControlRadius+2).
Background(k.Background).Border(theme.BorderWidth, k.ControlBorder).Label(msg(c, "task_list.filter"))
parts.Track.Children(func() {
for i, label := range labels {
selected := i == *value
seg := parts.Segment(i).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize).PaddingX(d.Unit() * 3).Radius(theme.ControlRadius).Label(label)
fg := k.TextMuted
if selected {
fg = k.AccentText
seg.Background(k.Selection).FontWeight(600)
seg.Draw(func(p *ui.Painter, r ui.Rect) {
p.Stroke(ui.Rect{X: r.X + 2, Y: r.Y + 2, W: r.W - 4, H: r.H - 4}, k.AccentText, theme.ControlRadius, 1)
})
} else if seg.Hovered() {
fg = k.Text
seg.Background(k.SurfaceHover)
}
internal.Ease(seg, Motion(c, theme.StateDuration), fg)
internal.Ring(seg, k.Focus, theme.ControlRadius)
seg.Children(func() { ui.Text(c, label).SingleLine() })
}
})
}
+147
View File
@@ -0,0 +1,147 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
type taskListProbe struct {
tasks []Task
filter TaskFilter
opts TaskListOptions
changed, opens []string
}
func (p *taskListProbe) tester() *ui.Tester {
p.opts.Members, p.opts.Statuses, p.opts.Today = projMembers, projStatuses, calendarToday
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Height(560).Padding(8).Children(func() {
r := TaskList(c, p.tasks, &p.filter, p.opts)
if id, ok := r.Changed(); ok {
p.changed = append(p.changed, id)
}
if id, ok := r.Opened(); ok {
p.opens = append(p.opens, id)
}
})
}, 640, 580)
}
func textIndex(texts []string, s string) int {
for i, x := range texts {
if x == s {
return i
}
}
return -1
}
func TestTaskListGroupsAndFilter(t *testing.T) {
p := &taskListProbe{tasks: projTasks(), opts: TaskListOptions{GroupBy: TaskGroupStatus}}
tt := p.tester()
tx := tt.Texts()
todo, doing, review, done := textIndex(tx, "Todo"), textIndex(tx, "In Progress"), textIndex(tx, "In Review"), textIndex(tx, "Done")
if !(todo >= 0 && todo < doing && doing < review && review < done) || textIndex(tx, "Backlog") >= 0 {
t.Fatalf("groups %d %d %d %d in %v", todo, doing, review, done, tx)
}
_ = tt.Click("Open 4")
if p.filter != TaskShowOpen || tt.HasText("Book the hall") || !tt.HasText("Tune the organ") {
t.Fatalf("open filter %v", tt.Texts())
}
_ = tt.Click("Done 1")
if tt.HasText("Tune the organ") || !tt.HasText("Book the hall") {
t.Fatal("done filter")
}
_ = tt.Click("Book the hall")
if len(p.opens) != 1 || p.opens[0] != "AVE-5" {
t.Fatalf("opened %v", p.opens)
}
}
func TestTaskListPriorityGroupsAndEdit(t *testing.T) {
p := &taskListProbe{tasks: projTasks(), opts: TaskListOptions{GroupBy: TaskGroupPriority}}
tt := p.tester()
tx := tt.Texts()
if u, h := textIndex(tx, "Urgent"), textIndex(tx, "High"); u < 0 || u > h {
t.Fatalf("priority order %v", tx)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if len(p.changed) != 1 || !p.tasks[1].Done {
t.Fatalf("keyboard check %v %+v", p.changed, p.tasks[1])
}
}
func TestTaskListDisabledEmptyInvalid(t *testing.T) {
p := &taskListProbe{tasks: projTasks(), opts: TaskListOptions{Disabled: true}}
tt := p.tester()
_ = tt.Click("Done 1")
_ = tt.Click("Write the overture")
if p.filter != TaskShowAll || len(p.opens) != 0 {
t.Fatal("disabled list reacted")
}
q := &taskListProbe{}
if tt := q.tester(); !tt.HasText("No tasks here.") {
t.Fatal("no empty state")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
bad := &taskListProbe{filter: 7}
bad.tester()
}
func TestTaskListLargeData(t *testing.T) {
p := &taskListProbe{opts: TaskListOptions{GroupBy: TaskGroupPriority}}
for i := range 100000 {
p.tasks = append(p.tasks, Task{ID: fmt.Sprintf("BIG-%d", i+1), Title: "Task", Status: "todo", Priority: Priority(i % 5)})
}
tt := p.tester()
if n := len(tt.Texts()); n > 200 {
t.Fatalf("built %d labels", n)
}
}
func TestTaskListSnapshot(t *testing.T) {
tasks := projTasks()
filter := TaskShowAll
snapshot(t, "task_list", 600, 420, func(c *ui.Context) {
ui.Column(c).Height(420).Padding(8).Children(func() {
TaskList(c, tasks, &filter, TaskListOptions{GroupBy: TaskGroupStatus, Statuses: projStatuses, Members: projMembers, Today: calendarToday})
})
})
}
func TestTaskListCompletionRefreshesCounts(t *testing.T) {
p := &taskListProbe{tasks: projTasks()}
tt := p.tester()
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !tt.HasText("Open 3") || !tt.HasText("Done 2") {
t.Fatal("completion left cached filter counts")
}
}
func TestTaskListFilterHitAreas(t *testing.T) {
for _, density := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} {
tasks, filter := projTasks(), TaskShowAll
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Density: density})
ui.Box(c).Height(300).Children(func() { TaskList(c, tasks, &filter, TaskListOptions{Members: projMembers}) })
}, 600, 320)
for _, label := range []string{"All 5", "Open 4", "Done 1"} {
r, ok := tt.Find(label)
if !ok || r.W < 28 || r.H < 28 {
t.Fatalf("density %v, %s: bounds %v", density, label, r)
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+131
View File
@@ -0,0 +1,131 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// TimeEntry records a span; zero End means running.
type TimeEntry struct {
Start, End time.Time
}
// TimeTrackerState holds the caller's tracked spans and manual correction.
type TimeTrackerState struct {
Entries []TimeEntry
// Adjust is added to the tracked spans by manual correction.
Adjust time.Duration
}
// Running reports whether the last entry is still open.
func (s *TimeTrackerState) Running() bool {
return len(s.Entries) > 0 && s.Entries[len(s.Entries)-1].End.IsZero()
}
// Total returns the tracked time at now, including the correction.
func (s *TimeTrackerState) Total(now time.Time) time.Duration {
t := s.Adjust
for _, e := range s.Entries {
end := e.End
if end.IsZero() {
end = now
}
t += end.Sub(e.Start)
}
return t
}
// TimeTrackerOptions configure a TimeTracker.
type TimeTrackerOptions struct {
// Now fixes the clock; zero uses the system clock.
Now time.Time
Disabled bool
Label string
}
// TimeTracker records spans and supports manual total corrections.
func TimeTracker(c *ui.Context, s *TimeTrackerState, opts TimeTrackerOptions) ChoiceResult {
now := opts.Now
live := now.IsZero()
if live {
now = c.Now()
}
for i, e := range s.Entries {
if e.Start.IsZero() || e.Start.After(now) || !e.End.IsZero() && (e.End.Before(e.Start) || e.End.After(now)) || e.End.IsZero() && i != len(s.Entries)-1 || i > 0 && e.Start.Before(s.Entries[i-1].End) {
panic("mujica: TimeTracker entries must be ordered spans with only the last open")
}
}
if s.Total(now) < 0 {
panic("mujica: TimeTracker total must be non-negative")
}
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "time_tracker.label")
}
changed := false
row := ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
editing := ui.Local(row, "time-tracker-edit", func() bool { return false })
draft := ui.Local(row, "time-tracker-draft", func() time.Duration { return 0 })
act := func(what string, fn func()) {
if row.IsDisabled() {
return
}
fn()
changed = true
slog.Debug("mujica: time_tracker "+what, "total", s.Total(now))
}
row.Children(func() {
total := s.Total(now)
running := s.Running()
icon, key, variant := "play", "time_tracker.start", Secondary
if running {
icon, key, variant = "pause", "time_tracker.stop", Primary
}
if Button(c, msg(c, key), ButtonOptions{Variant: variant, Icon: icons.Must(icon)}).Clicked() {
act(key[len("time_tracker."):], func() {
if running {
s.Entries[len(s.Entries)-1].End = now
} else {
s.Entries = append(s.Entries, TimeEntry{Start: now})
}
})
}
if *editing && !row.IsDisabled() {
DurationPicker(c, draft, DurationPickerOptions{Seconds: true, Label: msg(c, "time_tracker.correct")})
if Button(c, msg(c, "time_tracker.apply"), ButtonOptions{}).Clicked() {
act("correct", func() { s.Adjust += *draft - total })
*editing = false
}
if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Ghost}).Clicked() {
*editing = false
}
return
}
*editing = false
fg := k.Text
if running {
fg = k.AccentText
}
ui.Text(c, StopwatchText(total)).Font(theme.MonoFont).FontSize(FontSize(c, theme.H3Size)).TextColor(fg).FontFeatures("tnum").
Label(label + ", " + DurationText(c, total.Truncate(time.Second)))
if running {
ui.Text(c, msg(c, "time_tracker.running")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
}
ui.Box(c).Disabled(running).Children(func() {
if b := iconAction(c, icons.Must("pencil"), msg(c, "time_tracker.correct")); b.Clicked() && !b.IsDisabled() {
*editing, *draft = true, total.Truncate(time.Second)
}
})
})
if s.Running() && live {
c.After(100*time.Millisecond - s.Total(now)%(100*time.Millisecond))
}
return ChoiceResult{Element: row, changed: changed}
}
+105
View File
@@ -0,0 +1,105 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestTimeTrackerStartStopCorrect(t *testing.T) {
var s TimeTrackerState
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
n, disabled := 0, false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if TimeTracker(c, &s, TimeTrackerOptions{Now: now, Disabled: disabled}).Changed() {
n++
}
}, 900, 160)
_ = tt.Click("Start")
now = now.Add(25*time.Minute + 30*time.Second)
tt.Frame()
if !s.Running() || !tt.HasText("Time tracked, 25 min 30 s") || !tt.HasText("Tracking…") {
t.Fatalf("running %v", tt.Texts())
}
_ = tt.Click("Correct the total")
if tt.HasText("Apply") {
t.Fatal("correction must wait until tracking stops")
}
_ = tt.Click("Stop")
now = now.Add(time.Hour)
_ = tt.Click("Start")
now = now.Add(4*time.Minute + 30*time.Second)
_ = tt.Click("Stop")
if s.Total(now) != 30*time.Minute || len(s.Entries) != 2 || n != 4 {
t.Fatalf("total %v entries %d changes %d", s.Total(now), len(s.Entries), n)
}
_ = tt.Click("Correct the total")
_ = tt.Click("Minutes")
tt.Command("selectAll")
tt.Type("45")
tt.Key(0, ui.KeyEnter)
_ = tt.Click("Apply")
if s.Total(now) != 45*time.Minute || s.Adjust != 15*time.Minute || !tt.HasText("Time tracked, 45 min") {
t.Fatalf("corrected to %v (adjust %v)", s.Total(now), s.Adjust)
}
disabled = true
tt.Frame()
_ = tt.Click("Start")
if s.Running() {
t.Fatal("disabled tracker started")
}
}
func TestTimeTrackerInvalid(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic for two open entries")
}
}()
now := time.Now()
s := TimeTrackerState{Entries: []TimeEntry{{Start: now}, {Start: now}}}
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); TimeTracker(c, &s, TimeTrackerOptions{Now: now}) }, 10, 10)
}
func TestTimeTrackerSnapshot(t *testing.T) {
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
s := TimeTrackerState{Entries: []TimeEntry{{Start: now.Add(-time.Hour), End: now.Add(-30 * time.Minute)}, {Start: now.Add(-12 * time.Minute)}}}
snapshot(t, "time_tracker", 460, 80, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { TimeTracker(c, &s, TimeTrackerOptions{Now: now}) })
})
}
func TestTimeTrackerRejectsInvalidSpans(t *testing.T) {
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
for _, state := range []TimeTrackerState{
{Entries: []TimeEntry{{Start: now, End: now.Add(time.Hour)}, {Start: now.Add(time.Minute), End: now.Add(2 * time.Hour)}}},
{Entries: []TimeEntry{{Start: now.Add(time.Hour)}}},
{Adjust: -time.Second},
} {
t.Run("span", func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected invalid span panic")
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); TimeTracker(c, &state, TimeTrackerOptions{Now: now}) }, 600, 200)
})
}
}
func TestTimeTrackerCorrectionRemovesFractionalRemainder(t *testing.T) {
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
s := TimeTrackerState{Entries: []TimeEntry{{Start: now.Add(-30500 * time.Millisecond), End: now}}}
tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); TimeTracker(c, &s, TimeTrackerOptions{Now: now}) }, 900, 180)
if err := tt.Click("Correct the total"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Apply"); err != nil {
t.Fatal(err)
}
if s.Total(now) != 30*time.Second {
t.Fatalf("total = %v", s.Total(now))
}
}
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more