WIP components (as built)
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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` 表示自由容量。网络与持久化由调用方处理。
|
||||
@@ -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`。列表需父容器提供有限高度。
|
||||
@@ -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 取消。
|
||||
|
||||
## 限制
|
||||
|
||||
任务标题保存时去除首尾空白;空标题保留编辑态。业务字段来自调用方。
|
||||
@@ -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`。
|
||||
@@ -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 必须唯一且非空。空白新增内容保留输入。列表虚拟化,最多显示八行高度。
|
||||
@@ -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`。
|
||||
@@ -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 连续对应日期。数据加载与失败由调用方提供;数值须有限且非负。
|
||||
@@ -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。
|
||||
@@ -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 唯一且非空;当前值须属于状态列表。状态按五个工作流阶段分组。
|
||||
@@ -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 必须存在。头像使用姓名回退;网络成员查询由调用方提供。搜索结果按可见行虚拟化。
|
||||
@@ -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` 让已完成任务显示普通日期。
|
||||
@@ -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 结束编辑。
|
||||
|
||||
## 限制
|
||||
|
||||
名称去除首尾空白并按大小写折叠检测重复;颜色选中立即提交。列表虚拟化,最多显示八行高度。
|
||||
@@ -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 激活。
|
||||
|
||||
## 限制
|
||||
|
||||
长标题最多两行;头像组折叠到三个;字段与关联数据由调用方提供。
|
||||
@@ -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。
|
||||
@@ -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%;完成后显示达成标记。
|
||||
@@ -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。
|
||||
@@ -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 提交。
|
||||
|
||||
## 限制
|
||||
|
||||
只有最后一项可处于运行态;时段按顺序且互不重叠;累计时长非负。隐藏时保留计时状态。
|
||||
@@ -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 开始、暂停、跳过与重置。
|
||||
|
||||
## 限制
|
||||
|
||||
时长为整秒;自然完成后等待用户启动下一阶段。隐藏组件停止请求帧,恢复显示时读取经过时间。
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)`},
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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", "理想"},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}) })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||