Add project components 421-435; Checkbox turns only when on

Fifteen project components: QuestBoard, TaskItem, TaskList, Milestones,
Burndown, Pomodoro, Priority, AssigneePicker, LabelPicker, TimeTracker,
Roadmap, SprintPanel, IssueCard, Checklist and WorkloadBar.

QuestBoard is one base that draws TODO, DOING and DONE; arrows move,
Alt with an arrow carries a card, and a drag carries it live. Priority
draws 12x12 bars, and Critical is on fire. Pomodoro banks time on PAUSE
and the tomato sleeps through the rest. Extreme inputs fail fast or
saturate instead of wrapping.

MyGo's CheckboxBase turns its value before Disabled is set, so
input.Checkbox now hands it a copy and keeps only turns of an enabled
base. The home scene test checks the sky row, not one pixel that
falling snow can cover.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 10:25:07 -04:00
1 parent 552b073e81
commit c8d058799d
30 files changed
+2496 -7

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
package gallery
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/project"
)
var projectEntries = []Entry{
{421, "QuestBoard", "任务板", "project", "Quests in three columns. Drag a card across, or hold Alt with the arrows.", demoQuestBoard},
{422, "TaskItem", "任务项", "project", "A box to tick, a title, how urgent, and who.", demoTaskItem},
{423, "TaskList", "任务列表", "project", "Tasks with OPEN, DONE and URGENT chips to narrow them down.", demoTaskList},
{424, "Milestones", "里程碑", "project", "Flags along the road. The little hero stands at today.", demoMilestones},
{425, "Burndown", "燃尽图", "project", "The straight line is the plan. The other one is what happened.", demoBurndown},
{426, "Pomodoro", "番茄钟", "project", "Twenty-five minutes of work, five of rest. The tomato naps on the break.", demoPomodoro},
{427, "Priority", "优先级", "project", "LOW, MEDIUM, HIGH and, for the worst, CRITICAL: it is on fire.", demoPriority},
{428, "AssigneePicker", "指派", "project", "The party lines up. Pick who takes the quest, or nobody.", demoAssigneePicker},
}
type projectDemo struct {
board [3][]string
task project.Task
tasks []project.Task
assignee, changes int
}
func projectState(c *ui.Context) *projectDemo {
return ui.Local(c.Root(), demoKey("project"), func() projectDemo {
return projectDemo{
board: [3][]string{{"SLAY DRAGON", "FIND THE KEY", "SAVE VILLAGE"}, {"GRIND XP"}, {"TALK TO OLD MAN"}},
task: project.Task{Title: "Fix the save bug", Priority: project.High, Assignee: "Brom"},
tasks: []project.Task{
{Title: "Feed the slime", Priority: project.Low, Assignee: "Aria"},
{Title: "Fix the save bug", Priority: project.High, Assignee: "Brom", Done: true},
{Title: "Ship it", Priority: project.Critical},
{Title: "Draw the map", Priority: project.Medium, Assignee: "Cass"},
},
assignee: -1,
}
})
}
func demoQuestBoard(c *ui.Context, log func(string)) {
s := projectState(c)
if project.QuestBoard(c, "Quests", &s.board, project.QuestBoardOptions{}).Moved {
log(fmt.Sprintf("moved: %d to do, %d doing, %d done", len(s.board[0]), len(s.board[1]), len(s.board[2])))
}
}
func demoTaskItem(c *ui.Context, log func(string)) {
s := projectState(c)
if project.TaskItem(c, &s.task, project.TaskItemOptions{}).Changed {
log(fmt.Sprintf("done: %v", s.task.Done))
}
}
func demoTaskList(c *ui.Context, log func(string)) {
s := projectState(c)
if project.TaskList(c, "Tasks", s.tasks, project.TaskListOptions{}).Changed {
s.changes++
log(fmt.Sprintf("change %d", s.changes))
}
}
func demoMilestones(c *ui.Context, log func(string)) {
now := pixel.K(c).Now()
d := func(days int) time.Time { return now.AddDate(0, 0, days) }
project.Milestones(c, "Road", []project.Milestone{
{Title: "ALPHA", Date: d(-40), Done: true}, {Title: "BETA", Date: d(-12), Done: true},
{Title: "DEMO", Date: d(9)}, {Title: "LAUNCH", Date: d(30)}, {Title: "DLC", Date: d(60)},
}, project.MilestonesOptions{})
}
func demoBurndown(c *ui.Context, log func(string)) {
project.Burndown(c, "Sprint 7", 40, 10, []float64{40, 37, 35, 30, 28, 22}, project.BurndownOptions{})
}
func demoPomodoro(c *ui.Context, log func(string)) {
r := project.Pomodoro(c, "Tomato", project.PomodoroOptions{})
if r.Changed {
log("button")
}
if r.Rang {
log("ring")
}
}
func demoPriority(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
for _, l := range []project.Level{project.Low, project.Medium, project.High, project.Critical} {
ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
project.Priority(c, l, project.PriorityOptions{Scale: 2})
pixel.Text(c, k.Msg([]string{"low", "medium", "high", "critical"}[l]), pixel.TextOptions{})
})
}
})
}
func demoAssigneePicker(c *ui.Context, log func(string)) {
s := projectState(c)
if project.AssigneePicker(c, "Party", party[:4], &s.assignee, project.AssigneePickerOptions{}).Changed {
who := "nobody"
if s.assignee >= 0 {
who = party[s.assignee]
}
log("assigned to " + who)
}
}
+104
View File
@@ -0,0 +1,104 @@
package gallery
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/project"
)
var teamEntries = []Entry{
{429, "LabelPicker", "标签选择", "project", "Labels in slots. Toggle the ones that fit.", demoLabelPicker},
{430, "TimeTracker", "计时记录", "project", "START, STOP, and where the hours went.", demoTimeTracker},
{431, "Roadmap", "路线图", "project", "Lanes of work laid over the months. The dashed line is today.", demoRoadmap},
{432, "SprintPanel", "迭代", "project", "Points done and days left. No pressure.", demoSprintPanel},
{433, "IssueCard", "问题卡", "project", "An issue at a glance: number, title, labels, owner. Click to open.", demoIssueCard},
{434, "Checklist", "清单", "project", "Small steps under a bar. Finish them all for a fanfare.", demoChecklist},
{435, "WorkloadBar", "工作量", "project", "Hours per party member. Gold is busy; red is too much.", demoWorkloadBar},
}
var tags = []project.Label{
{Name: "BUG", Slot: pixel.Bad}, {Name: "FEATURE", Slot: pixel.Good}, {Name: "DOCS", Slot: pixel.Info},
{Name: "UI", Slot: pixel.Magic}, {Name: "SPEED", Slot: pixel.Warn}, {Name: "LORE", Slot: pixel.Accent},
}
type teamDemo struct {
on []bool
entries []project.TimeEntry
steps []bool
}
func teamState(c *ui.Context) *teamDemo {
now := pixel.K(c).Now()
return ui.Local(c.Root(), demoKey("team"), func() teamDemo {
return teamDemo{
on: []bool{true, false, false, true, false, false},
entries: []project.TimeEntry{
{Task: "GRIND XP", Start: now.Add(-3 * time.Hour), Stop: now.Add(-2*time.Hour - 35*time.Minute)},
{Task: "MAP THE CAVE", Start: now.Add(-90 * time.Minute), Stop: now.Add(-48 * time.Minute)},
},
steps: []bool{true, true, false, false},
}
})
}
func demoLabelPicker(c *ui.Context, log func(string)) {
s := teamState(c)
if project.LabelPicker(c, "Labels", tags, s.on, project.LabelPickerOptions{}).Changed {
n := 0
for _, on := range s.on {
if on {
n++
}
}
log(fmt.Sprintf("%d labels on", n))
}
}
func demoTimeTracker(c *ui.Context, log func(string)) {
s := teamState(c)
r := project.TimeTracker(c, "Time", "GRIND XP", &s.entries, project.TimeTrackerOptions{})
if r.Started {
log("started")
}
if r.Stopped {
log("stopped")
}
}
func demoRoadmap(c *ui.Context, log func(string)) {
now := pixel.K(c).Now()
from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0)
m := func(month, day int) time.Time { return from.AddDate(0, month, day) }
project.Roadmap(c, "Plan", from, []project.Lane{
{Name: "ENGINE", Bars: []project.Bar{{Title: "PALETTES", Start: m(0, 0), End: m(1, 10)}, {Title: "SPRITES", Start: m(1, 14), End: m(3, 0)}}},
{Name: "GAME", Bars: []project.Bar{{Title: "LEVEL 1", Start: m(1, 0), End: m(4, 0)}}},
{Name: "SOUND", Bars: []project.Bar{{Title: "CHIPTUNE", Start: m(3, 10), End: m(5, 20)}}},
}, project.RoadmapOptions{})
}
func demoSprintPanel(c *ui.Context, log func(string)) {
now := pixel.K(c).Now()
project.SprintPanel(c, project.Sprint{Name: "SPRINT 7: THE DUNGEON", Start: now.AddDate(0, 0, -9), End: now.AddDate(0, 0, 4), Done: 18, Total: 30}, project.SprintPanelOptions{})
}
func demoIssueCard(c *ui.Context, log func(string)) {
is := project.Issue{ID: "#42", Title: "Save file vanishes after the boss", Labels: tags[:1], Assignee: "Brom", Priority: project.Critical}
if project.IssueCard(c, is, project.IssueCardOptions{}).Clicked {
log("open " + is.ID)
}
}
func demoChecklist(c *ui.Context, log func(string)) {
s := teamState(c)
if project.Checklist(c, "Before the quest", []string{"Sharpen the sword", "Pack potions", "Tell mom", "Leave the village"}, s.steps, project.ChecklistOptions{}).Changed {
log(fmt.Sprintf("steps %v", s.steps))
}
}
func demoWorkloadBar(c *ui.Context, log func(string)) {
project.WorkloadBar(c, "Load", []project.Load{{Name: "Aria", Hours: 32}, {Name: "Brom", Hours: 44}, {Name: "Cass", Hours: 18}, {Name: "Dax", Hours: 39}}, project.WorkloadBarOptions{})
}
+11 -4
View File
@@ -76,10 +76,17 @@ func TestHomeSceneFollowsThePaletteStrip(t *testing.T) {
if !ok {
t.Fatalf("the scene kept its season: %q", tt.Texts())
}
// The banner's sky starts in Winter Night's darkest slot.
got, want := tt.Image().RGBAAt(int(r.X)+1, int(r.Y)+1), pixel.WinterNight.Colors[pixel.Well]
if got.R != want.R || got.G != want.G || got.B != want.B {
t.Fatalf("the banner's sky is %v, want %v", got, want)
// The sky's top row is Winter Night's Well, snow aside.
want, well, n := pixel.WinterNight.Colors[pixel.Well], 0, 0
for x := int(r.X); x < int(r.X+r.W); x += 2 {
got := tt.Image().RGBAAt(x, int(r.Y)+1)
if got.R == want.R && got.G == want.G && got.B == want.B {
well++
}
n++
}
if well*4 < n*3 {
t.Fatalf("only %d of %d sky dots are Winter Night's Well", well, n)
}
if _, ok := tt.Find("Winter Night, in use"); !ok {
t.Fatal("the strip does not mark the palette in use")
+37
View File
@@ -0,0 +1,37 @@
package gallery
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
func TestProjectDemosLogTheirActions(t *testing.T) {
// Clicks that only focus a component may log nothing.
quiet := map[string]bool{"Quests": true}
for _, tc := range []struct {
path string
steps []string
}{
{"/c/421-questboard", []string{"Quests", "alt:Right"}}, {"/c/422-taskitem", []string{"Fix the save bug"}},
{"/c/423-tasklist", []string{"OPEN"}}, {"/c/426-pomodoro", []string{"START", "PAUSE"}},
{"/c/428-assigneepicker", []string{"Brom"}}, {"/c/429-labelpicker", []string{"Labels"}},
{"/c/430-timetracker", []string{"START", "STOP"}}, {"/c/433-issuecard", []string{"#42 Save file vanishes after the boss"}},
{"/c/434-checklist", []string{"Tell mom"}},
} {
a, tt := open(tc.path)
slug := tc.path[3:]
for _, step := range tc.steps {
before := len(a.logs[slug])
if k, ok := strings.CutPrefix(step, "alt:"); ok {
tt.Key(ui.Alt, letters[k])
} else {
click(t, tt, step)
}
if len(a.logs[slug]) <= before && !quiet[step] {
t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug])
}
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries, filesEntries, storageEntries)
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries, filesEntries, storageEntries, projectEntries, teamEntries)
func Entries() []Entry { return entries }
+8 -2
View File
@@ -33,7 +33,13 @@ type CheckboxResult struct {
// Checkbox is a labeled 8×8 box with a hand-placed check.
func Checkbox(c *ui.Context, text string, checked *bool, o CheckboxOptions) CheckboxResult {
k := pixel.K(c)
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.CheckboxBase(c, checked).Label(text).Disabled(o.Disabled) })
// The base turns before it learns it is off.
on := *checked
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.CheckboxBase(c, &on).Label(text).Disabled(o.Disabled) })
changed := on != *checked && !base.IsDisabled()
if changed {
*checked = on
}
mark := check
if !*checked {
mark = nil
@@ -41,7 +47,7 @@ func Checkbox(c *ui.Context, text string, checked *bool, o CheckboxOptions) Chec
shell.Size(k.D(12+pixel.Measure(text)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
box(k.Canvas(p, r), text, mark, st)
})
res := CheckboxResult{Element: shell, Changed: base.Changed()}
res := CheckboxResult{Element: shell, Changed: changed}
if res.Changed {
slog.Debug("8bit: checkbox", "label", text, "checked", *checked)
}
+28
View File
@@ -0,0 +1,28 @@
package input_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestCheckboxIgnoresAKeyAsItTurnsOff(t *testing.T) {
for _, inert := range []bool{false, true} {
on, off, changed := false, false, false
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: (&clock{time.Unix(3_000_000, 0)}).now, Inert: off && inert})
changed = input.Checkbox(c, "Box", &on, input.CheckboxOptions{Disabled: off && !inert}).Changed || changed
}, 300, 100)
tt.Key(0, ui.KeyTab)
// Space, sent while on, lands as the box turns off.
off = true
tt.Key(0, ui.KeySpace)
if on || changed {
t.Errorf("inert %v: Space turned the box to %v, changed %v", inert, on, changed)
}
}
}
+8
View File
@@ -50,6 +50,10 @@ var messages = map[Locale]map[string]string{
"in_bin": "IN THE BIN: %d", "empty_trash": "EMPTY", "no_preview": "NO PREVIEW", "kind": "KIND", "size": "SIZE", "created": "CREATED",
"modified": "MODIFIED", "packed": "PACKED", "archive_total": "%d FILES: %s → %s", "link": "LINK", "access": "ACCESS",
"access_levels": "VIEW COMMENT EDIT", "synced": "SYNCED", "syncing": "SYNCING", "sync_failed": "SYNC FAILED",
"todo": "TODO", "doing": "DOING", "low": "LOW", "medium": "MEDIUM", "high": "HIGH", "critical": "CRITICAL", "nobody": "NOBODY",
"filters": "FILTERS", "task_filters": "OPEN DONE URGENT", "quest_complete": "QUEST COMPLETE!", "focus": "FOCUS", "rest": "REST",
"ahead": "AHEAD", "behind": "BEHIND", "ideal": "IDEAL", "work_left": "LEFT", "total": "TOTAL", "days_left": "%d DAYS LEFT",
"last_day": "LAST DAY", "over": "OVER", "sprint_points": "%d/%d POINTS", "hours_of": "%s/%s H",
},
Zh: {
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
@@ -98,6 +102,10 @@ var messages = map[Locale]map[string]string{
"in_bin": "回收站:%d 项", "empty_trash": "清空", "no_preview": "无法预览", "kind": "类型", "size": "大小", "created": "创建",
"modified": "修改", "packed": "压缩后", "archive_total": "%d 个文件:%s → %s", "link": "链接", "access": "权限",
"access_levels": "查看 评论 编辑", "synced": "已同步", "syncing": "同步中", "sync_failed": "同步出错",
"todo": "待办", "doing": "进行中", "low": "低", "medium": "中", "high": "高", "critical": "危急", "nobody": "无人",
"filters": "筛选", "task_filters": "未完成 已完成 火急", "quest_complete": "任务完成!", "focus": "专注", "rest": "休息",
"ahead": "超前", "behind": "落后", "ideal": "理想", "work_left": "剩余", "total": "合计", "days_left": "还剩 %d 天",
"last_day": "最后一天", "over": "已结束", "sprint_points": "%d/%d 点", "hours_of": "%s/%s 小时",
},
}
+74
View File
@@ -0,0 +1,74 @@
package project
import (
"fmt"
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AssigneePickerOptions struct {
Disabled bool
}
type AssigneePickerResult struct {
Element *ui.Element
Changed bool
}
const seatW, seatH = 52, 4 + 32 + 2 + pixel.LineHeight + 2
// AssigneePicker lines up the party; -1 is the empty seat.
func AssigneePicker(c *ui.Context, text string, party []string, chosen *int, o AssigneePickerOptions) AssigneePickerResult {
k := pixel.K(c)
if len(party) == 0 || slices.Contains(party, "") || *chosen < -1 || *chosen >= len(party) {
panic(fmt.Sprintf("project: AssigneePicker has %d members, %d chosen", len(party), *chosen))
}
old := *chosen
var res AssigneePickerResult
res.Element = ui.Row(c).Gap(k.D(4)).FocusGroup(ui.Horizontal).Role(ui.RoleRadioGroup).Label(text).Children(func() {
for i := -1; i < len(party); i++ {
name := k.Msg("nobody")
if i >= 0 {
name = party[i]
}
// The shell draws the seat; the radio stays bare.
sel := *chosen
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.RadioBase(c, &sel, i).Label(name).Disabled(o.Disabled)
})
// The radio picks before it is told it is off.
if sel != *chosen && !base.IsDisabled() {
*chosen = sel
}
on := i == *chosen
shell.Size(k.D(seatW), k.D(seatH)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, seatW, seatH, pixel.Raised, pixel.Face)
if i < 0 {
g.Veil(10, 4, 32, 32, pixel.Shade, 8)
} else {
display.Face(g, 10, 4, name, 2)
}
ink := pixel.Ink
if on {
ink = pixel.Accent
g.Rect(0, 0, seatW, 2, pixel.Accent)
g.Rect(0, seatH-2, seatW, 2, pixel.Accent)
}
l := pixel.Wrap(name, seatW-4, 1)[0]
g.Text((seatW-pixel.Measure(l))/2, 38, l, ink, 1)
g.Finish(st)
})
}
})
res.Changed = *chosen != old
if res.Changed {
slog.Debug("8bit: assignee", "label", text, "member", *chosen)
}
return res
}
+229
View File
@@ -0,0 +1,229 @@
package project_test
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/project"
)
func TestQuestBoardMovesCardsByKeysAndDrags(t *testing.T) {
for _, off := range []bool{false, true} {
cols, moved := [3][]string{{"A", "B"}, {"C"}, {}}, 0
wd := newWorld()
tt := view(700, 400, wd, func(c *ui.Context) {
if project.QuestBoard(c, "Board", &cols, project.QuestBoardOptions{Disabled: off}).Moved {
moved++
}
})
tt.Key(0, ui.KeyTab)
// Alt carries A to DOING; Down, Alt takes C on.
tt.Key(ui.Alt, ui.KeyRight)
tt.Key(0, ui.KeyDown)
tt.Key(ui.Alt, ui.KeyRight)
want := [3][]string{{"B"}, {"A"}, {"C"}}
if off {
want = [3][]string{{"A", "B"}, {"C"}, {}}
}
if !slices.Equal(cols[0], want[0]) || !slices.Equal(cols[1], want[1]) || !slices.Equal(cols[2], want[2]) || off != (moved == 0) {
t.Fatalf("off %v: keys left %q, moved %d", off, cols, moved)
}
if off {
continue
}
// The last card can go no lower.
tt.Key(ui.Alt, ui.KeyDown)
if moved != 2 {
t.Fatalf("Alt+Down on the last card moved it: %d", moved)
}
// Columns are 100 dots apart; cards 20 down, 38 tall.
r := find(t, tt, "Board")
drag := func(x0, y0, x1, y1 float32) {
tt.Press(r.X+2*x0, r.Y+2*y0)
tt.Move(r.X+2*x1, r.Y+2*y1)
tt.Release(r.X+2*x1, r.Y+2*y1)
}
drag(150, 60, 40, 30)
drag(40, 30, 240, 68)
if len(cols[0]) != 0 || !slices.Equal(cols[1], []string{"A"}) || !slices.Equal(cols[2], []string{"C", "B"}) {
t.Fatalf("a press on nothing, then B dropped under C, left %q", cols)
}
// Left walks to A; Alt+Left takes it home.
tt.Key(0, ui.KeyLeft)
tt.Key(ui.Alt, ui.KeyLeft)
if !slices.Equal(cols[0], []string{"A"}) || len(cols[1]) != 0 {
t.Fatalf("Left, Alt+Left left %q", cols)
}
}
mustPanic(t, "a card with no title", func() {
cols := [3][]string{{""}, {}, {}}
view(700, 400, newWorld(), func(c *ui.Context) { project.QuestBoard(c, "B", &cols, project.QuestBoardOptions{}) })
})
}
func TestTaskItemTicksAndShowsWho(t *testing.T) {
task := project.Task{Title: "Slay", Priority: project.High, Assignee: "Brom"}
changed := false
tt := view(500, 200, newWorld(), func(c *ui.Context) {
changed = project.TaskItem(c, &task, project.TaskItemOptions{}).Changed || changed
})
for _, s := range []string{"HIGH", "Brom"} {
find(t, tt, s)
}
click(t, tt, "Slay")
if !task.Done || !changed {
t.Fatal("clicking the task did not tick it")
}
task.Assignee = ""
tt.Frame()
find(t, tt, "NOBODY")
}
func TestTaskListFiltersAndKeepsRows(t *testing.T) {
tasks := []project.Task{
{Title: "Low open", Priority: project.Low},
{Title: "High done", Priority: project.High, Done: true},
{Title: "Critical open", Priority: project.Critical},
}
tt := view(600, 400, newWorld(), func(c *ui.Context) {
project.TaskList(c, "Tasks", tasks, project.TaskListOptions{})
})
shown := func() (out []string) {
for _, tk := range tasks {
if _, ok := tt.Find(tk.Title); ok {
out = append(out, tk.Title)
}
}
return out
}
click(t, tt, "OPEN")
if got := shown(); !slices.Equal(got, []string{"Low open", "Critical open"}) {
t.Fatalf("OPEN shows %q", got)
}
click(t, tt, "URGENT")
if got := shown(); !slices.Equal(got, []string{"Critical open"}) {
t.Fatalf("OPEN and URGENT show %q", got)
}
// The row that stayed still ticks its own task.
click(t, tt, "Critical open")
if !tasks[2].Done || tasks[0].Done {
t.Fatalf("the tick landed on %+v", tasks)
}
click(t, tt, "OPEN")
click(t, tt, "URGENT")
click(t, tt, "DONE")
if got := shown(); !slices.Equal(got, []string{"High done", "Critical open"}) {
t.Fatalf("DONE shows %q", got)
}
click(t, tt, "DONE")
click(t, tt, "URGENT")
tasks[2].Done = false
tt.Frame()
if got := shown(); !slices.Equal(got, []string{"High done", "Critical open"}) {
t.Fatalf("URGENT shows %q", got)
}
}
func TestChecklistCountsAndCheers(t *testing.T) {
done := []bool{true, false}
tt := view(500, 300, newWorld(), func(c *ui.Context) {
project.Checklist(c, "Prep", []string{"Pack", "Go"}, done, project.ChecklistOptions{})
})
if !tt.HasText("1/2") || tt.HasText("QUEST COMPLETE!") {
t.Fatalf("texts %q", tt.Texts())
}
click(t, tt, "Go")
if !done[1] || !tt.HasText("2/2") || !tt.HasText("QUEST COMPLETE!") {
t.Fatalf("after Go: %v, texts %q", done, tt.Texts())
}
mustPanic(t, "two items and one mark", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
project.Checklist(c, "P", []string{"A", "B"}, []bool{false}, project.ChecklistOptions{})
})
})
}
func TestAssigneePickerSeatsAndEmpties(t *testing.T) {
who := -1
tt := view(600, 200, newWorld(), func(c *ui.Context) {
project.AssigneePicker(c, "Party", []string{"Aria", "Brom"}, &who, project.AssigneePickerOptions{})
})
click(t, tt, "Brom")
if who != 1 {
t.Fatalf("Brom chose %d", who)
}
// The chosen seat wears Accent at its top edge.
x, y, _, _ := dots(find(t, tt, "Brom"))
if at(tt.Image(), x+20, y) != pixel.Accent {
t.Fatal("Brom's seat is not marked")
}
click(t, tt, "NOBODY")
if who != -1 {
t.Fatalf("NOBODY chose %d", who)
}
mustPanic(t, "a choice past the party", func() {
w := 2
view(600, 200, newWorld(), func(c *ui.Context) {
project.AssigneePicker(c, "P", []string{"A", "B"}, &w, project.AssigneePickerOptions{})
})
})
}
func TestLabelPickerTogglesSlots(t *testing.T) {
labels := []project.Label{{Name: "BUG", Slot: pixel.Bad}, {Name: "UI", Slot: pixel.Magic}, {Name: "DOCS", Slot: pixel.Info}, {Name: "LORE"}}
on := make([]bool, 4)
tt := view(600, 200, newWorld(), func(c *ui.Context) {
project.LabelPicker(c, "Labels", labels, on, project.LabelPickerOptions{})
})
tt.Key(0, ui.KeyTab)
// Three slots a row: Down from BUG is LORE.
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
r := find(t, tt, "Labels")
tt.ClickAt(r.X+2*(76+38), r.Y+2*11)
if !slices.Equal(on, []bool{false, true, false, true}) {
t.Fatalf("Down, Enter, then a click on UI gave %v", on)
}
mustPanic(t, "a nameless label", func() {
view(600, 200, newWorld(), func(c *ui.Context) {
project.LabelPicker(c, "L", []project.Label{{}}, []bool{false}, project.LabelPickerOptions{})
})
})
}
func TestPickersIgnoreKeysAsTheyTurnOff(t *testing.T) {
for _, inert := range []bool{false, true} {
who, off := -1, false
wd := newWorld()
tt := view(600, 200, wd, func(c *ui.Context) {
project.AssigneePicker(c, "Party", []string{"Aria"}, &who, project.AssigneePickerOptions{Disabled: off && !inert})
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
// Space, sent while on, lands as the picker turns off.
off, wd.inert = true, inert
tt.Key(0, ui.KeySpace)
if who != -1 {
t.Errorf("inert %v: Space as the picker turned off chose %d", inert, who)
}
}
}
func TestLabelPickerSurvivesFewerLabels(t *testing.T) {
labels := []project.Label{{Name: "A"}, {Name: "B"}, {Name: "C"}, {Name: "D"}}
on := make([]bool, 4)
tt := view(600, 200, newWorld(), func(c *ui.Context) {
project.LabelPicker(c, "Labels", labels, on, project.LabelPickerOptions{})
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
labels, on = labels[:1], on[:1]
tt.Frame()
tt.Key(0, ui.KeyEnter)
if !on[0] {
t.Fatal("Enter on the one label left did not toggle it")
}
}
+73
View File
@@ -0,0 +1,73 @@
package project
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type BurndownOptions struct {
// Width and Height are dots; zeros mean 200 by 100.
Width, Height int
}
// Burndown draws the ideal line and the work left.
func Burndown(c *ui.Context, text string, total float64, days int, left []float64, o BurndownOptions) *ui.Element {
k := pixel.K(c)
if !(total > 0) || math.IsInf(total, 0) || days < 1 || len(left) > days+1 {
panic(fmt.Sprintf("project: Burndown of %v over %d days, %d points", total, days, len(left)))
}
top := total
for _, v := range left {
if !(v >= 0) || math.IsInf(v, 0) {
panic(fmt.Sprintf("project: Burndown work left %v", v))
}
top = max(top, v)
}
w, h := pixel.Or("Burndown Width", o.Width, 200), pixel.Or("Burndown Height", o.Height, 100)
// The share comes first, so huge totals stay finite.
ideal := func(d int) float64 { return total * (float64(days-d) / float64(days)) }
verdict, ink := "", pixel.Good
if n := len(left); n > 0 {
verdict = k.Msg("ahead")
if left[n-1] > ideal(n-1) {
verdict, ink = k.Msg("behind"), pixel.Bad
}
}
vals := make([]string, len(left))
for i, v := range left {
vals[i] = strconv.FormatFloat(v, 'g', 4, 64)
}
desc := fmt.Sprintf("%s %g → 0, %d; %s %s", k.Msg("ideal"), total, days, k.Msg("work_left"), strings.Join(vals, ", "))
if verdict != "" {
desc += "; " + verdict
}
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(desc).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Plot inside a frame; day numbers sit under it.
pw, ph := w-8, h-14
// Floats, so huge day counts cannot overflow.
px := func(d int) int { return 4 + int(float64(d)*float64(pw)/float64(days)) }
py := func(v float64) int { return 4 + ph - int(v/top*float64(ph)) }
g.Frame(0, 0, w, h-8, pixel.Sunken, pixel.Well)
g.Line(px(0), py(total), px(days), py(0), pixel.Faint)
for d := 1; d < len(left); d++ {
g.Line(px(d-1), py(left[d-1]), px(d), py(left[d]), pixel.Accent)
}
for d, v := range left {
g.Rect(px(d)-1, py(v)-1, 3, 3, pixel.Accent)
}
for _, d := range []int{0, days / 2, days} {
s := strconv.Itoa(d)
g.Micro(max(0, min(w-pixel.MicroWidth(s), px(d)-pixel.MicroWidth(s)/2)), h-6, s, pixel.Faint, 1)
}
if verdict != "" {
g.Text(w-4-pixel.Measure(verdict), 3, verdict, ink, 1)
}
})
}
+61
View File
@@ -0,0 +1,61 @@
package project
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ChecklistOptions struct {
// Width is in dots; zero means 200.
Width int
Disabled bool
}
type ChecklistResult struct {
Element *ui.Element
Changed bool
}
// Checklist ticks off subtasks under a progress bar.
func Checklist(c *ui.Context, text string, items []string, done []bool, o ChecklistOptions) ChecklistResult {
k := pixel.K(c)
if len(items) == 0 || len(done) != len(items) {
panic(fmt.Sprintf("project: Checklist has %d items and %d marks", len(items), len(done)))
}
w := pixel.Or("Checklist Width", o.Width, 200)
// The bar leaves room for the widest count, "9/9".
bar := w - 4 - pixel.Measure(fmt.Sprintf("%d/%d", len(items), len(items)))
if bar < 8 {
panic(fmt.Sprintf("project: Checklist Width %d leaves a %d-dot bar", w, bar))
}
n := 0
for _, d := range done {
if d {
n++
}
}
var res ChecklistResult
res.Element = ui.Column(c).Width(k.D(w)).Gap(k.D(3)).Role(ui.RoleGroup).Label(text).Children(func() {
ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
feedback.Progress(c, text, float64(n)/float64(len(items)), feedback.ProgressOptions{Width: bar})
pixel.Text(c, fmt.Sprintf("%d/%d", n, len(items)), pixel.TextOptions{Slot: pixel.Faint})
})
for i, it := range items {
res.Changed = input.Checkbox(c, it, &done[i], input.CheckboxOptions{Disabled: o.Disabled}).Changed || res.Changed
}
// A full list says so, as a quest log would.
if n == len(items) {
pixel.Text(c, k.Msg("quest_complete"), pixel.TextOptions{Slot: pixel.Good})
}
})
if res.Changed {
slog.Debug("8bit: checklist", "label", text)
}
return res
}
+101
View File
@@ -0,0 +1,101 @@
package project_test
import (
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/project"
)
func TestExtremesFailFastOrHold(t *testing.T) {
// At 40 dots bar and count fit; at 20, none.
tt := view(400, 200, newWorld(), func(c *ui.Context) {
project.Checklist(c, "C", []string{"A"}, []bool{false}, project.ChecklistOptions{Width: 40})
})
if box, n := find(t, tt, "C"), find(t, tt, "0/1"); n.X+n.W > box.X+box.W {
t.Errorf("the count ends at %v, past the 40-dot list", n.X+n.W)
}
mustPanic(t, "a checklist with no room for a bar", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
project.Checklist(c, "C", []string{"A"}, []bool{false}, project.ChecklistOptions{Width: 20})
})
})
mustPanic(t, "a pomodoro of 292 years", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
project.Pomodoro(c, "P", project.PomodoroOptions{Work: math.MaxInt64})
})
})
wd := newWorld()
far := project.Sprint{Name: "S", Start: wd.clk.t, End: wd.clk.t.AddDate(300, 0, 0), Total: 1}
tt = view(600, 200, wd, func(c *ui.Context) { project.SprintPanel(c, far, project.SprintPanelOptions{}) })
// 300 years away saturates to 106752 days, never below zero.
if !tt.HasText("106752 DAYS LEFT") {
t.Errorf("a sprint 300 years long: %q", tt.Texts())
}
tt = view(600, 300, newWorld(), func(c *ui.Context) {
project.Burndown(c, "Burn", 1e308, 5, []float64{1e308, 1.5e308}, project.BurndownOptions{})
})
x, y, w, _ := dots(find(t, tt, "Burn"))
if count(tt.Image(), x+w-70, y+3, 66, 16, pixel.Bad) == 0 {
t.Error("1.5e308 left of 1e308 is not BEHIND")
}
tt = view(700, 200, newWorld(), func(c *ui.Context) {
project.WorkloadBar(c, "Load", []project.Load{{Name: "A", Hours: 1.5e308}}, project.WorkloadBarOptions{Capacity: 1.5e308})
})
x, y, _, _ = dots(find(t, tt, "A"))
if count(tt.Image(), x+24+pixel.Measure("A"), y, 120, 16, pixel.Warn) == 0 {
t.Error("a full load of 1.5e308 hours shows no Warn fill")
}
wd = newWorld()
es := []project.TimeEntry{
{Task: "A", Start: time.Unix(0, 0).AddDate(-280, 0, 0), Stop: time.Unix(0, 0)},
{Task: "B", Start: time.Unix(0, 0), Stop: wd.clk.t},
}
tt = view(600, 300, wd, func(c *ui.Context) { project.TimeTracker(c, "T", "A", &es, project.TimeTrackerOptions{}) })
if tt.HasText("-") {
t.Errorf("a total past int64 went negative: %q", tt.Texts())
}
}
func TestNarrowAndFutureInputs(t *testing.T) {
wd := newWorld()
s := project.Sprint{Name: "S", Start: wd.clk.t, End: wd.clk.t.AddDate(0, 0, 9), Total: 1}
mustPanic(t, "a sprint panel with no room for a bar", func() {
view(600, 200, newWorld(), func(c *ui.Context) { project.SprintPanel(c, s, project.SprintPanelOptions{Width: 19}) })
})
mustPanic(t, "a roadmap of 121 months", func() {
view(600, 200, newWorld(), func(c *ui.Context) {
project.Roadmap(c, "R", wd.clk.t, []project.Lane{{Name: "E"}}, project.RoadmapOptions{Months: 121})
})
})
// A start a minute ahead, as clocks drift, shows zero.
es := []project.TimeEntry{{Task: "A", Start: wd.clk.t.Add(time.Minute)}}
tt := view(600, 300, wd, func(c *ui.Context) { project.TimeTracker(c, "T", "A", &es, project.TimeTrackerOptions{}) })
if tt.HasText("-") || !tt.HasText("00:00") {
t.Errorf("a start ahead of now reads %q", tt.Texts())
}
}
func TestStopAheadAndHugeSprints(t *testing.T) {
wd := newWorld()
es := []project.TimeEntry{{Task: "A", Start: wd.clk.t.Add(time.Minute)}}
tt := view(600, 300, wd, func(c *ui.Context) { project.TimeTracker(c, "T", "A", &es, project.TimeTrackerOptions{}) })
// STOP before the start ends the entry at its start.
click(t, tt, "STOP")
tt.Frame()
if !es[0].Stop.Equal(es[0].Start) {
t.Fatalf("STOP ahead of the start set %v for %v", es[0].Stop, es[0].Start)
}
tt = view(600, 300, newWorld(), func(c *ui.Context) {
project.Burndown(c, "Burn", 10, math.MaxInt/2, []float64{10}, project.BurndownOptions{})
})
x, y, w, h := dots(find(t, tt, "Burn"))
// The ideal line crosses the right half to zero.
if count(tt.Image(), x+w/2, y+2, w/2-2, h-12, pixel.Faint) == 0 {
t.Fatal("the ideal line never reaches the right half")
}
}
+14
View File
@@ -0,0 +1,14 @@
package project
import (
"testing"
"time"
)
func TestHmsDropsHoursUnderAnHour(t *testing.T) {
for d, want := range map[time.Duration]string{0: "00:00", 59 * time.Second: "00:59", 25 * time.Minute: "25:00", 61 * time.Minute: "1:01:00", 1500 * time.Millisecond: "00:01"} {
if got := hms(d); got != want {
t.Errorf("hms(%v) = %q, want %q", d, got, want)
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package project
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Issue is a bug or a wish, under an id.
type Issue struct {
ID string
Title string
Labels []Label
Assignee string
Priority Level
}
type IssueCardOptions struct {
// Width is in dots; zero means 160.
Width int
Disabled bool
}
type IssueCardResult struct {
Element *ui.Element
Clicked bool
}
// IssueCard shows an issue's id, title, labels and assignee.
func IssueCard(c *ui.Context, is Issue, o IssueCardOptions) IssueCardResult {
k := pixel.K(c)
if is.ID == "" || is.Title == "" {
panic("project: IssueCard needs an id and a title")
}
said := make([]string, 0, len(is.Labels)+2)
for _, l := range is.Labels {
if l.Name == "" {
panic("project: IssueCard has a label with no name")
}
said = append(said, l.Name)
}
who := is.Assignee
if who == "" {
who = k.Msg("nobody")
}
said = append(said, is.Priority.word(k), who)
w := pixel.Or("IssueCard Width", o.Width, 160)
lines := pixel.Wrap(is.Title, w-8, 2)
// The shell draws the card; the button stays bare.
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.ButtonBase(c).Label(is.ID + " " + is.Title).Disabled(o.Disabled)
})
base.Description(strings.Join(said, ", "))
res := IssueCardResult{Element: shell, Clicked: base.Clicked()}
h := 3 + pixel.LineHeight*(len(lines)+2) + 6
shell.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
fill := pixel.Face
if st.Hover {
fill = pixel.Lit
}
g.Frame(0, 0, w, h, pixel.Raised, fill)
g.Text(4, 3, is.ID, pixel.Faint, 1)
mark(g, w-16, 5, is.Priority, 1)
for i, l := range lines {
g.Text(4, 3+pixel.LineHeight*(i+1), l, pixel.Ink, 1)
}
// Labels run until the assignee's seat; the rest wait.
y, x := 3+pixel.LineHeight*(len(lines)+1)+2, 4
for _, l := range is.Labels {
cw := 10 + pixel.Measure(l.Name)
if x+cw > w-24 {
break
}
g.Rect(x, y+5, 7, 7, pixel.Edge)
g.Rect(x+1, y+6, 5, 5, l.Slot)
g.Text(x+10, y, l.Name, pixel.Faint, 1)
x += cw + 6
}
if is.Assignee == "" {
g.Veil(w-20, y, 16, 16, pixel.Shade, 8)
} else {
display.Face(g, w-20, y, is.Assignee, 1)
}
g.Finish(st)
})
if res.Clicked {
slog.Debug("8bit: issue card", "id", is.ID)
}
return res
}
+86
View File
@@ -0,0 +1,86 @@
package project
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type LabelPickerOptions struct {
// Columns of slots; zero means 3.
Columns int
Disabled bool
}
type LabelPickerResult struct {
Element *ui.Element
Changed bool
}
type labelKey struct{}
const slotW, slotH = 76, pixel.LineHeight + 6
// LabelPicker toggles labels laid out in slots.
func LabelPicker(c *ui.Context, text string, labels []Label, on []bool, o LabelPickerOptions) LabelPickerResult {
k := pixel.K(c)
if len(labels) == 0 || len(on) != len(labels) {
panic(fmt.Sprintf("project: LabelPicker has %d labels and %d marks", len(labels), len(on)))
}
for _, l := range labels {
if l.Name == "" {
panic("project: LabelPicker has a label with no name")
}
}
cols := pixel.Or("LabelPicker Columns", o.Columns, 3)
box := ui.Column(c)
cur := ui.Local(box, labelKey{}, func() int { return 0 })
// The labels may have shrunk since the cursor moved.
*cur = min(*cur, len(labels)-1)
var res LabelPickerResult
res.Element = box.Children(func() {
// Arrows walk the slots; Enter, Space or a click toggles.
shell, base, st, picked := pixel.Pad(c, text, cols, len(labels), slotW, slotH, cur, o.Disabled)
if picked >= 0 {
on[picked], res.Changed = !on[picked], true
slog.Debug("8bit: label picker", "label", text, "slot", picked, "on", on[picked])
}
state := k.Msg("off")
if on[*cur] {
state = k.Msg("on")
}
base.Description(labels[*cur].Name + ", " + state)
marks, at := append([]bool(nil), on...), *cur
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, l := range labels {
x, y := i%cols*slotW, i/cols*slotH
fill, ink := pixel.Well, pixel.Faint
if marks[i] {
fill, ink = pixel.Face, pixel.Ink
}
g.Frame(x+1, y+1, slotW-2, slotH-2, pixel.Sunken, fill)
g.Rect(x+5, y+7, 8, 8, pixel.Edge)
g.Rect(x+6, y+8, 6, 6, l.Slot)
room := slotW - 22
if marks[i] {
room -= 12
g.Sprite(x+slotW-15, y+5, icon.Check, 0, pixel.Good, 1)
}
g.Text(x+16, y+3, pixel.Wrap(l.Name, room, 1)[0], ink, 1)
if i == at && st.Focus {
g.Rect(x, y, slotW, 1, pixel.Accent)
g.Rect(x, y+slotH-1, slotW, 1, pixel.Accent)
g.Rect(x, y, 1, slotH, pixel.Accent)
g.Rect(x+slotW-1, y, 1, slotH, pixel.Accent)
}
}
g.Finish(st)
})
})
return res
}
+139
View File
@@ -0,0 +1,139 @@
package project_test
import (
"image"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/project"
)
func TestPriorityIconsRiseThenBurn(t *testing.T) {
levels := []project.Level{project.Low, project.Medium, project.High, project.Critical}
tt := view(400, 200, newWorld(), func(c *ui.Context) {
ui.Row(c).Children(func() {
for _, l := range levels {
project.Priority(c, l, project.PriorityOptions{})
}
})
})
img := tt.Image()
// LOW lights one bar, MEDIUM two, HIGH three.
for _, tc := range []struct {
name string
slot pixel.Slot
dots int
}{{"LOW", pixel.Good, 12}, {"MEDIUM", pixel.Warn, 33}, {"HIGH", pixel.Bad, 63}} {
x, y, _, _ := dots(find(t, tt, tc.name))
if n := count(img, x, y, 12, 12, tc.slot); n != tc.dots {
t.Errorf("%s has %d dots of %v, want %d", tc.name, n, tc.slot, tc.dots)
}
}
// CRITICAL is the flame: gold at its heart.
x, y, _, _ := dots(find(t, tt, "CRITICAL"))
if count(img, x, y, 12, 12, pixel.Warn) == 0 {
t.Fatal("CRITICAL is not on fire")
}
mustPanic(t, "a fifth level", func() {
view(400, 200, newWorld(), func(c *ui.Context) { project.Priority(c, 4, project.PriorityOptions{}) })
})
}
func TestIssueCardOpens(t *testing.T) {
clicked := 0
is := project.Issue{ID: "#42", Title: "Save lost", Labels: []project.Label{{Name: "BUG", Slot: pixel.Bad}}, Assignee: "Brom", Priority: project.Critical}
tt := view(400, 300, newWorld(), func(c *ui.Context) {
if project.IssueCard(c, is, project.IssueCardOptions{}).Clicked {
clicked++
}
})
click(t, tt, "#42 Save lost")
if clicked != 1 {
t.Fatalf("a click opened %d times", clicked)
}
x, y, _, h := dots(find(t, tt, "#42 Save lost"))
// BUG's swatch is Bad on the labels row.
if count(tt.Image(), x+4, y+h-24, 8, 16, pixel.Bad) == 0 {
t.Fatal("the BUG label has no Bad swatch")
}
}
func TestWorkloadBarWarnsAndOverflows(t *testing.T) {
loads := []project.Load{{Name: "Aria", Hours: 30}, {Name: "Brom", Hours: 36}, {Name: "Cass", Hours: 44}}
tt := view(700, 300, newWorld(), func(c *ui.Context) {
project.WorkloadBar(c, "Load", loads, project.WorkloadBarOptions{})
})
// Meters start past the face, the names and gaps.
nameW := max(pixel.Measure("Aria"), pixel.Measure("Brom"), pixel.Measure("Cass"))
for _, tc := range []struct {
name string
slot pixel.Slot
}{{"Aria", pixel.Good}, {"Brom", pixel.Warn}, {"Cass", pixel.Bad}} {
x, y, _, _ := dots(find(t, tt, tc.name))
if count(tt.Image(), x+24+nameW, y, 100, 16, tc.slot) == 0 {
t.Errorf("%s's meter shows no %v", tc.name, tc.slot)
}
}
// Six dots of meter is too few to read.
narrow := 16 + nameW + pixel.Measure("44/40 H") + 12 + 6
mustPanic(t, "a six-dot meter", func() {
view(700, 300, newWorld(), func(c *ui.Context) {
project.WorkloadBar(c, "L", loads, project.WorkloadBarOptions{Width: narrow})
})
})
}
// offPalette counts dots no palette color matches.
func offPalette(img *image.RGBA) int {
b := img.Bounds()
return count(img, 0, 0, b.Dx()/2, b.Dy()/2, none)
}
func TestProjectStaysOnThePaletteWhenOff(t *testing.T) {
builds := map[string]func(c *ui.Context, off bool){
"board": func(c *ui.Context, off bool) {
cols := [3][]string{{"A"}, {"B"}, {}}
project.QuestBoard(c, "X", &cols, project.QuestBoardOptions{Disabled: off})
},
"task": func(c *ui.Context, off bool) {
tk := project.Task{Title: "A", Assignee: "Brom"}
project.TaskItem(c, &tk, project.TaskItemOptions{Disabled: off})
},
"list": func(c *ui.Context, off bool) {
project.TaskList(c, "X", []project.Task{{Title: "A"}}, project.TaskListOptions{Disabled: off})
},
"tomato": func(c *ui.Context, off bool) {
project.Pomodoro(c, "X", project.PomodoroOptions{Disabled: off})
},
"party": func(c *ui.Context, off bool) {
i := 0
project.AssigneePicker(c, "X", []string{"Aria"}, &i, project.AssigneePickerOptions{Disabled: off})
},
"labels": func(c *ui.Context, off bool) {
project.LabelPicker(c, "X", []project.Label{{Name: "A", Slot: pixel.Bad}}, []bool{true}, project.LabelPickerOptions{Disabled: off})
},
"time": func(c *ui.Context, off bool) {
es := []project.TimeEntry{{Task: "A", Start: time.Unix(0, 0)}}
project.TimeTracker(c, "X", "A", &es, project.TimeTrackerOptions{Disabled: off})
},
"issue": func(c *ui.Context, off bool) {
project.IssueCard(c, project.Issue{ID: "#1", Title: "A", Assignee: "Brom"}, project.IssueCardOptions{Disabled: off})
},
"checklist": func(c *ui.Context, off bool) {
project.Checklist(c, "X", []string{"A"}, []bool{true}, project.ChecklistOptions{Disabled: off})
},
}
for name, build := range builds {
for _, inert := range []bool{false, true} {
wd := newWorld()
wd.inert = inert
tt := view(700, 400, wd, func(c *ui.Context) { build(c, !inert) })
if n := offPalette(tt.Image()); n != 0 {
t.Errorf("%s, inert %v: %d dots off the palette", name, inert, n)
}
}
}
}
+128
View File
@@ -0,0 +1,128 @@
package project
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Milestone is a stop on the way, by date.
type Milestone struct {
Title string
Date time.Time
Done bool
}
type MilestonesOptions struct {
// Width is in dots; zero means 260.
Width int
}
var (
// A flag in two frames, to wave.
flag = pixel.MustSprite(`
.ee.........
.e####......
.e######....
.e########..
.e######....
.e####......
.e..........
.e..........
.e..........
.e..........
.e..........
eee.........
.ee.........
.e##........
.e#####.....
.e#######...
.e#######...
.e#####.....
.e##........
.e..........
.e..........
.e..........
.e..........
eee.........`)
hero = pixel.MustSprite(`
..yyyy..
.yyyyyy.
.ylkllk.
..llll..
.aaaaaa.
laaaaaal
.aaaaaa.
.ee..ee.
.ee..ee.
.kk..kk.`)
)
// Milestones plants flags on a path; a hero marks today.
func Milestones(c *ui.Context, text string, ms []Milestone, o MilestonesOptions) *ui.Element {
k := pixel.K(c)
if len(ms) == 0 {
panic("project: Milestones has none")
}
for i, m := range ms {
if m.Title == "" || m.Date.IsZero() || i > 0 && m.Date.Before(ms[i-1].Date) {
panic(fmt.Sprintf("project: Milestones %q is untitled, undated or out of order", m.Title))
}
}
w := pixel.Or("Milestones Width", o.Width, 260)
first, span := ms[0].Date, ms[len(ms)-1].Date.Sub(ms[0].Date)
// x places a date on the path; one sits mid-way.
x := func(t time.Time) int {
if span == 0 {
return w / 2
}
f := max(0, min(1, float64(t.Sub(first))/float64(span)))
return 8 + int(f*float64(w-16))
}
next, gap := len(ms), w
said := make([]string, len(ms))
for i, m := range ms {
state := ""
switch {
case m.Done:
state = ", " + k.Msg("done")
case next == len(ms):
next, state = i, ", "+k.Msg("next")
}
said[i] = m.Title + " " + m.Date.Format("01-02") + state
if i > 0 {
gap = min(gap, x(m.Date)-x(ms[i-1].Date))
}
}
// Titles alternate rows, so each gets two gaps.
room := max(24, min(w/2, 2*gap-4))
now := k.Now()
return ui.Box(c).Size(k.D(w), k.D(58)).Role(ui.RoleImage).Label(text).Description(strings.Join(said, "; ")).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for px := 2; px < w-2; px += 2 {
g.Dot(px, 16, pixel.Shade)
}
wave := g.Step(4) % 2
for i, m := range ms {
fx, ink, frame := x(m.Date), pixel.Faint, 0
switch {
case m.Done:
ink = pixel.Good
case i == next:
ink, frame = pixel.Accent, wave
}
g.Sprite(fx-1, 4, flag, frame, ink, 1)
d := m.Date.Format("01-02")
g.Micro(fx-pixel.MicroWidth(d)/2, 19, d, pixel.Faint, 1)
t := pixel.Wrap(m.Title, room, 1)[0]
g.Text(max(0, min(w-pixel.Measure(t), fx-pixel.Measure(t)/2)), 26+i%2*16, t, pixel.Ink, 1)
}
// The hero bobs where today falls, unless motion is reduced.
g.Sprite(max(0, min(w-8, x(now)-4)), 6-wave, hero, 0, pixel.Ink, 1)
})
}
+146
View File
@@ -0,0 +1,146 @@
package project
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PomodoroOptions struct {
// Work and Rest; zeros mean 25 and 5 minutes.
Work, Rest time.Duration
Disabled bool
}
type PomodoroResult struct {
Element *ui.Element
// Changed is START, PAUSE or RESET; Rang ends a phase.
Changed, Rang bool
}
type pomoKey struct{}
// pomo is the phase and its time, banked while paused.
type pomo struct {
resting bool
left time.Duration
since time.Time
}
// The tomato is awake at work and asleep at rest.
var tomato = pixel.MustSprite(`
................
.......gg.......
....gg.gg.gg....
.....gggggg.....
...errgggggre...
..errrrggrrrre..
.errlrrrrrrrrre.
.erllrrrrrrrrre.
erlrrkrrrrkrrrre
errrrkrrrrkrrrre
errrrrrrrrrrrrre
errrrrrkkrrrrrre
.errrrrrrrrrrre.
.errrrrrrrrrrre.
..eerrrrrrrree..
....eeeeeeee....
................
.......gg.......
....gg.gg.gg....
.....gggggg.....
...errgggggre...
..errrrggrrrre..
.errlrrrrrrrrre.
.erllrrrrrrrrre.
erlrrrrrrrrrrrre
errrkkrrrkkrrrre
errrrrrrrrrrrrre
errrrrrkkrrrrrre
.errrrrrrrrrrre.
.errrrrrrrrrrre.
..eerrrrrrrree..
....eeeeeeee....`)
// Pomodoro times work, then rest, beside a tomato.
func Pomodoro(c *ui.Context, text string, o PomodoroOptions) PomodoroResult {
k := pixel.K(c)
work, rest := o.Work, o.Rest
if work == 0 {
work = 25 * time.Minute
}
if rest == 0 {
rest = 5 * time.Minute
}
if work < time.Second || rest < time.Second || work > day || rest > day {
panic(fmt.Sprintf("project: Pomodoro work %v, rest %v", work, rest))
}
row := ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text)
p := ui.Local(row, pomoKey{}, func() pomo { return pomo{left: work} })
now := k.Now()
left, running := p.left, !p.since.IsZero()
if running {
left -= now.Sub(p.since)
}
var res PomodoroResult
if running && left <= 0 {
// The phase rings; the next waits for START.
p.resting = !p.resting
p.left, p.since, res.Rang = work, time.Time{}, true
if p.resting {
p.left = rest
}
left, running = p.left, false
slog.Debug("8bit: pomodoro rang", "label", text, "resting", p.resting)
}
if running {
// Ask for the moment the seconds turn.
k.At(c, now.Add(left-(left-1)/time.Second*time.Second))
}
phase, frame := k.Msg("focus"), 0
if p.resting {
phase, frame = k.Msg("rest"), 1
}
res.Element = row.Children(func() {
ui.Box(c).Size(k.D(32), k.D(32)).Role(ui.RoleImage).Label(phase).Draw(func(pt *ui.Painter, r ui.Rect) {
g := k.Canvas(pt, r)
// Running, it hops a dot each second, unless reduced.
dy := 0
if running && g.Step(4)%4 == 0 {
dy = 1
}
g.Sprite(0, 1-dy, tomato, frame, pixel.Ink, 2)
})
ui.Column(c).Gap(k.D(2)).Children(func() {
pixel.Text(c, phase, pixel.TextOptions{Slot: pixel.Faint})
// Seconds round up, so 00:00 shows only at the end.
pixel.Text(c, hms(left+time.Second-1), pixel.TextOptions{Scale: 2})
ui.Row(c).Gap(k.D(4)).Children(func() {
label := k.Msg("start")
if running {
label = k.Msg("pause")
}
if input.Button(c, label, input.ButtonOptions{Size: input.Small, Variant: input.Primary, Disabled: o.Disabled}).Clicked {
if running {
p.left, p.since = left, time.Time{}
} else {
p.since = now
}
res.Changed = true
slog.Debug("8bit: pomodoro", "label", text, "running", !p.since.IsZero())
}
if input.Button(c, k.Msg("reset"), input.ButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked {
*p, res.Changed = pomo{left: work}, true
slog.Debug("8bit: pomodoro reset", "label", text)
}
})
})
})
return res
}
+78
View File
@@ -0,0 +1,78 @@
package project
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PriorityOptions struct {
// Scale is 1–4; zero means 1.
Scale int
}
// Bars rise with the level; # dots take its ink.
var bars = [3]*pixel.Sprite{pixel.MustSprite(`
............
............
.........nnn
.........nnn
.........nnn
.....nnn.nnn
.....nnn.nnn
.....nnn.nnn
.###.nnn.nnn
.###.nnn.nnn
.###.nnn.nnn
.###.nnn.nnn`), pixel.MustSprite(`
............
............
.........nnn
.........nnn
.........nnn
.....###.nnn
.....###.nnn
.....###.nnn
.###.###.nnn
.###.###.nnn
.###.###.nnn
.###.###.nnn`), pixel.MustSprite(`
............
............
.........###
.........###
.........###
.....###.###
.....###.###
.....###.###
.###.###.###
.###.###.###
.###.###.###
.###.###.###`)}
var levelInks = [...]pixel.Slot{pixel.Good, pixel.Warn, pixel.Bad, pixel.Bad}
// Priority is a level's icon; critical is on fire.
func Priority(c *ui.Context, l Level, o PriorityOptions) *ui.Element {
k := pixel.K(c)
say := l.word(k)
s := pixel.Or("Priority Scale", o.Scale, 1)
if s > 4 {
panic(fmt.Sprintf("project: Priority Scale %d is past 4", s))
}
return ui.Box(c).Size(k.D(12*s), k.D(12*s)).Role(ui.RoleImage).Label(say).Draw(func(p *ui.Painter, r ui.Rect) {
mark(k.Canvas(p, r), 0, 0, l, s)
})
}
// mark draws a level's icon at x, y.
func mark(g pixel.Canvas, x, y int, l Level, s int) {
art := icon.Flame
if l < Critical {
art = bars[l]
}
g.Sprite(x, y, art, 0, levelInks[l], s)
}
+60
View File
@@ -0,0 +1,60 @@
// Package project holds task, planning and team components.
package project
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Level is how urgent a task is.
type Level int
const (
Low Level = iota
Medium
High
Critical
)
var levels = [...]string{"low", "medium", "high", "critical"}
// word is a level in the locale; unknown ones panic.
func (l Level) word(k pixel.Kit) string {
if l < Low || l > Critical {
panic(fmt.Sprintf("project: unknown level %d", l))
}
return k.Msg(levels[l])
}
// Label is a named tag in a palette slot.
type Label struct {
Name string
Slot pixel.Slot
}
// month is a month's short name in the frame's locale.
func month(k pixel.Kit, m time.Month) string {
return strings.Fields(k.Msg("months"))[m-1]
}
// hms prints d as H:MM:SS, or MM:SS under an hour.
func hms(d time.Duration) string {
d = d.Truncate(time.Second)
h, m, s := int(d.Hours()), int(d.Minutes())%60, int(d.Seconds())%60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%02d:%02d", m, s)
}
// show draws s unnamed, beside an element that names it.
func show(c *ui.Context, k pixel.Kit, s string, slot pixel.Slot, w int) *ui.Element {
return ui.Box(c).Size(k.D(w), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Text(0, 0, pixel.Wrap(s, w, 1)[0], slot, 1)
})
}
+156
View File
@@ -0,0 +1,156 @@
package project
import (
"fmt"
"log/slog"
"slices"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type QuestBoardOptions struct {
// Width is each column's in dots; zero means 96.
Width int
Disabled bool
}
type QuestBoardResult struct {
Element *ui.Element
// Moved reports a card that changed column or place.
Moved bool
}
type boardKey struct{}
// board is the cursor; held while a press carries it.
type board struct {
col, row int
press, held bool
}
// Cards hold two lines of title.
const headH, cardH, colGap = pixel.LineHeight + 4, 2*pixel.LineHeight + 6, 4
var stages = [3]string{"todo", "doing", "done"}
// QuestBoard keeps cards in TODO, DOING and DONE.
func QuestBoard(c *ui.Context, text string, cols *[3][]string, o QuestBoardOptions) QuestBoardResult {
k := pixel.K(c)
cw, most := pixel.Or("QuestBoard Width", o.Width, 96), 1
for _, col := range cols {
if slices.Contains(col, "") {
panic("project: QuestBoard has a card with no title")
}
most = max(most, len(col))
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
})
b := ui.Local(base, boardKey{}, func() board { return board{} })
fit := func() {
b.col = max(0, min(2, b.col))
b.row = max(0, min(len(cols[b.col])-1, b.row))
}
fit()
res := QuestBoardResult{Element: shell}
// move carries the cursor's card to a column and place.
move := func(col, row int) {
if col < 0 || col > 2 || len(cols[b.col]) == 0 {
return
}
limit := len(cols[col])
if col == b.col {
limit--
}
if row = max(0, min(limit, row)); col == b.col && row == b.row {
return
}
title := cols[b.col][b.row]
cols[b.col] = slices.Delete(cols[b.col], b.row, b.row+1)
cols[col] = slices.Insert(cols[col], row, title)
b.col, b.row, res.Moved = col, row, true
}
pressed := false
if !base.IsDisabled() {
for _, m := range []struct {
key ui.Key
dc, dr int
}{{ui.KeyUp, 0, -1}, {ui.KeyDown, 0, 1}, {ui.KeyLeft, -1, 0}, {ui.KeyRight, 1, 0}} {
if base.Shortcut(0, m.key) {
b.col, b.row = b.col+m.dc, b.row+m.dr
fit()
}
if base.Shortcut(ui.Alt, m.key) {
move(b.col+m.dc, b.row+m.dr)
}
}
_, _, pressed = base.Dragged()
}
if pressed {
x, y, _ := base.PointerPosition()
col, dy := int(x/k.Dot())/(cw+colGap), int(y/k.Dot())-headH
row := dy / cardH
switch {
case !b.press:
// A new press takes the card under it, if any.
b.held = col >= 0 && col < 3 && dy >= 0 && row < len(cols[col])
if b.held {
b.col, b.row = col, row
}
case b.held:
move(col, max(0, dy)/cardH)
}
}
b.held, b.press = b.held && pressed, pressed
names := [3]string{k.Msg(stages[0]), k.Msg(stages[1]), k.Msg(stages[2])}
if n := len(cols[b.col]); n > 0 {
base.Description(fmt.Sprintf("%s %d/%d, %s", names[b.col], b.row+1, n, cols[b.col][b.row]))
} else {
base.Description(names[b.col] + ", " + k.Msg("empty"))
}
shown := [3][]string{slices.Clone(cols[0]), slices.Clone(cols[1]), slices.Clone(cols[2])}
at, held := [2]int{b.col, b.row}, b.held
shell.Size(k.D(3*cw+2*colGap), k.D(headH+most*cardH+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for ci, col := range shown {
x := ci * (cw + colGap)
g.Frame(x, 0, cw, g.H, pixel.Sunken, pixel.Well)
g.Text(x+4, 2, names[ci]+" "+strconv.Itoa(len(col)), pixel.Faint, 1)
g.HLine(x+2, headH-1, cw-4, pixel.Shade)
for ri, t := range col {
cx, cy := x+3, headH+ri*cardH+1
on := ci == at[0] && ri == at[1]
fill := pixel.Face
// A carried card lifts off its shadow.
if on && held {
g.Rect(cx+1, cy+1, cw-6, cardH-2, pixel.Shade)
cx, cy, fill = cx-1, cy-1, pixel.Lit
}
g.Frame(cx, cy, cw-6, cardH-2, pixel.Raised, fill)
ink, room := pixel.Ink, cw-14
if ci == 2 {
ink, room = pixel.Faint, room-14
g.Sprite(cx+cw-20, cy+3, icon.Check, 0, pixel.Good, 1)
}
for i, l := range pixel.Wrap(t, room, 2) {
g.Text(cx+4, cy+2+i*pixel.LineHeight, l, ink, 1)
}
if on && (st.Focus || held) {
g.Rect(cx-1, cy-1, cw-4, 1, pixel.Accent)
g.Rect(cx-1, cy+cardH-2, cw-4, 1, pixel.Accent)
g.Rect(cx-1, cy-1, 1, cardH, pixel.Accent)
g.Rect(cx+cw-6, cy-1, 1, cardH, pixel.Accent)
}
}
}
g.Finish(st)
})
if res.Moved {
slog.Debug("8bit: quest board", "label", text, "column", b.col, "row", b.row)
}
return res
}
+112
View File
@@ -0,0 +1,112 @@
package project
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Bar is work on a lane from Start to End.
type Bar struct {
Title string
Start, End time.Time
// Slot colors the bar's cap; zero picks one by order.
Slot pixel.Slot
}
// Lane is a row of bars: a team or theme.
type Lane struct {
Name string
Bars []Bar
}
type RoadmapOptions struct {
// Months shown; zero means 6.
Months int
// MonthWidth is in dots; zero means 44.
MonthWidth int
}
var capSlots = []pixel.Slot{pixel.Info, pixel.Good, pixel.Warn, pixel.Magic, pixel.Bad}
const laneH = pixel.LineHeight + 8
// Roadmap lays bars on lanes under months, from from's.
func Roadmap(c *ui.Context, text string, from time.Time, lanes []Lane, o RoadmapOptions) *ui.Element {
k := pixel.K(c)
months, mw := pixel.Or("Roadmap Months", o.Months, 6), pixel.Or("Roadmap MonthWidth", o.MonthWidth, 44)
if len(lanes) == 0 || months > 120 {
panic(fmt.Sprintf("project: Roadmap of %d lanes over %d months", len(lanes), months))
}
nameW, said := 0, make([]string, len(lanes))
for i, l := range lanes {
var bs []string
for _, b := range l.Bars {
if b.Title == "" || !b.Start.Before(b.End) {
panic(fmt.Sprintf("project: Roadmap bar %q runs %v to %v", b.Title, b.Start, b.End))
}
bs = append(bs, b.Title+" "+b.Start.Format("01-02")+"–"+b.End.Format("01-02"))
}
if l.Name == "" {
panic("project: Roadmap has a lane with no name")
}
nameW, said[i] = max(nameW, pixel.Measure(l.Name)), l.Name+": "+strings.Join(bs, ", ")
}
nameW += 8
start := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, from.Location())
end := start.AddDate(0, months, 0)
// x places a time in its month's column, within view.
x := func(t time.Time) int {
if !start.Before(t) {
return nameW
}
if !t.Before(end) {
return nameW + months*mw
}
m := (t.Year()-start.Year())*12 + int(t.Month()-start.Month())
from := start.AddDate(0, m, 0)
f := float64(t.Sub(from)) / float64(from.AddDate(0, 1, 0).Sub(from))
return nameW + m*mw + int(f*float64(mw))
}
top := pixel.LineHeight + 4
w, h, now := nameW+months*mw, top+len(lanes)*laneH, k.Now()
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(strings.Join(said, "; ")).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for m := range months {
mx := nameW + m*mw
g.Text(mx+3, 0, month(k, start.AddDate(0, m, 0).Month()), pixel.Faint, 1)
for y := top; y < h; y += 2 {
g.Dot(mx, y, pixel.Shade)
}
}
g.HLine(nameW, top-2, months*mw, pixel.Shade)
for i, l := range lanes {
y := top + i*laneH
g.Text(0, y+4, l.Name, pixel.Ink, 1)
for j, b := range l.Bars {
// Bars outside the months shrink to nothing.
x0, x1 := x(b.Start), x(b.End)
if x1-x0 < 4 {
continue
}
slot := b.Slot
if slot == 0 {
slot = capSlots[j%len(capSlots)]
}
g.Frame(x0, y+2, x1-x0, laneH-4, pixel.Raised, pixel.Face)
g.Rect(x0+2, y+4, 3, laneH-8, slot)
g.Clip(x0+7, y+2, x1-x0-9, laneH-4, func() { g.Text(x0+7, y+4, b.Title, pixel.Ink, 1) })
}
}
// Today is a dashed Accent line, while in view.
if !now.Before(start) && now.Before(end) {
for tx, y := x(now), top-2; y < h; y += 3 {
g.Rect(tx, y, 1, 2, pixel.Accent)
}
}
})
}
+65
View File
@@ -0,0 +1,65 @@
package project
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Sprint is a stretch of work, counted in points.
type Sprint struct {
Name string
Start, End time.Time
Done, Total int
}
type SprintPanelOptions struct {
// Width is in dots; zero means 200.
Width int
}
const day = 24 * time.Hour
// SprintPanel shows a sprint's points done and days left.
func SprintPanel(c *ui.Context, s Sprint, o SprintPanelOptions) *ui.Element {
k := pixel.K(c)
if s.Name == "" || !s.Start.Before(s.End) || s.Total < 1 || s.Done < 0 || s.Done > s.Total {
panic(fmt.Sprintf("project: SprintPanel %q from %v to %v, %d of %d", s.Name, s.Start, s.End, s.Done, s.Total))
}
w, now := pixel.Or("SprintPanel Width", o.Width, 200), k.Now()
// Room for the percent; a zero bar would mean 120.
bar := w - 4 - pixel.MicroWidth("100%")
if bar < 8 {
panic(fmt.Sprintf("project: SprintPanel Width %d leaves a %d-dot bar", w, bar))
}
rest := s.End.Sub(now)
// Rounded up without adding, so far ends cannot overflow.
left, ink := fmt.Sprintf(k.Msg("days_left"), 1+(rest-1)/day), pixel.Faint
switch {
case rest <= 0:
left, ink = k.Msg("over"), pixel.Bad
case rest <= day:
left, ink = k.Msg("last_day"), pixel.Warn
}
if rest > 0 {
// The count turns when a whole day runs out.
k.At(c, now.Add(rest-(rest-1)/day*day))
}
// The panel hugs its column instead of the parent's width.
return layout.Panel(c, layout.PanelOptions{Style: pixel.Raised}, func() {
ui.Column(c).Width(k.D(w)).Gap(k.D(3)).Role(ui.RoleGroup).Label(s.Name).Children(func() {
pixel.Text(c, s.Name, pixel.TextOptions{Wrap: true, MaxLines: 1})
pixel.Text(c, s.Start.Format("01-02")+" → "+s.End.Format("01-02"), pixel.TextOptions{Slot: pixel.Faint})
feedback.Progress(c, s.Name, float64(s.Done)/float64(s.Total), feedback.ProgressOptions{Width: bar, Percent: true})
ui.Row(c).FillWidth().Gap(k.D(4)).Children(func() {
pixel.Text(c, fmt.Sprintf(k.Msg("sprint_points"), s.Done, s.Total), pixel.TextOptions{}).Grow(1)
pixel.Text(c, left, pixel.TextOptions{Slot: ink})
})
})
}).AlignSelf(ui.Start)
}
+55
View File
@@ -0,0 +1,55 @@
package project
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Task is a thing to do.
type Task struct {
Title string
Done bool
Priority Level
// Assignee is a party member's name; empty is nobody.
Assignee string
}
type TaskItemOptions struct {
Disabled bool
}
type TaskItemResult struct {
Element *ui.Element
Changed bool
}
// TaskItem is a checkbox, title, priority and assignee.
func TaskItem(c *ui.Context, t *Task, o TaskItemOptions) TaskItemResult {
k := pixel.K(c)
if t.Title == "" {
panic("project: TaskItem has no title")
}
var res TaskItemResult
res.Element = ui.Row(c).FillWidth().Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
res.Changed = input.Checkbox(c, t.Title, &t.Done, input.CheckboxOptions{Disabled: o.Disabled}).Changed
ui.Box(c).Grow(1)
Priority(c, t.Priority, PriorityOptions{})
// Nobody shows as an empty dotted seat.
if t.Assignee == "" {
ui.Box(c).Size(k.D(16), k.D(16)).Role(ui.RoleImage).Label(k.Msg("nobody")).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Veil(0, 0, 16, 16, pixel.Shade, 8)
})
return
}
display.Avatar(c, t.Assignee, display.AvatarOptions{})
})
if res.Changed {
slog.Debug("8bit: task", "done", t.Done)
}
return res
}
+57
View File
@@ -0,0 +1,57 @@
package project
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TaskListOptions struct {
// Width is in dots; zero means 240.
Width int
Disabled bool
}
type TaskListResult struct {
Element *ui.Element
// Changed reports a task checked or a filter turned.
Changed bool
}
type filterKey struct{}
// TaskList shows tasks under OPEN, DONE and URGENT filters.
func TaskList(c *ui.Context, text string, tasks []Task, o TaskListOptions) TaskListResult {
k := pixel.K(c)
col := ui.Column(c).Width(k.D(pixel.Or("TaskList Width", o.Width, 240))).Gap(k.D(4)).Role(ui.RoleGroup).Label(text)
on := ui.Local(col, filterKey{}, func() []bool { return make([]bool, 3) })
var res TaskListResult
res.Element = col.Children(func() {
if data.FilterBar(c, k.Msg("filters"), strings.Fields(k.Msg("task_filters")), *on, data.FilterBarOptions{Disabled: o.Disabled}).Changed {
res.Changed = true
slog.Debug("8bit: task list filters", "label", text, "on", *on)
}
shown := 0
for i := range tasks {
t := &tasks[i]
// OPEN and DONE alone narrow; both or neither keep all.
open, done, urgent := (*on)[0], (*on)[1], (*on)[2]
if open != done && t.Done != done || urgent && t.Priority < High {
continue
}
shown++
// Rows come and go with filters; each keeps a key.
ui.Box(c).Key(i).FillWidth().Children(func() {
res.Changed = TaskItem(c, t, TaskItemOptions{Disabled: o.Disabled}).Changed || res.Changed
})
}
if shown == 0 {
pixel.Text(c, k.Msg("empty"), pixel.TextOptions{Slot: pixel.Faint})
}
})
return res
}
+183
View File
@@ -0,0 +1,183 @@
package project_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/project"
)
func TestPomodoroWorksRestsAndBanks(t *testing.T) {
wd := newWorld()
changed, rang := 0, 0
tt := view(500, 200, wd, func(c *ui.Context) {
r := project.Pomodoro(c, "Tomato", project.PomodoroOptions{Work: time.Minute, Rest: 30 * time.Second})
if r.Changed {
changed++
}
if r.Rang {
rang++
}
})
click(t, tt, "START")
later(tt, wd, 20*time.Second)
click(t, tt, "PAUSE")
// Paused, the time holds; started again, it runs on.
later(tt, wd, time.Hour)
if !tt.HasText("00:40") || changed != 2 {
t.Fatalf("paused at 20 s: texts %q, changed %d", tt.Texts(), changed)
}
x, y, _, _ := dots(find(t, tt, "FOCUS"))
awake := badge(tt, x, y, 32)
click(t, tt, "START")
later(tt, wd, 40*time.Second)
if rang != 1 || !tt.HasText("00:30") {
t.Fatalf("work ran out: rang %d, texts %q", rang, tt.Texts())
}
// At rest the tomato shuts its eyes.
if x, y, _, _ = dots(find(t, tt, "REST")); badge(tt, x, y, 32) == awake {
t.Fatal("the tomato looks the same at rest")
}
click(t, tt, "RESET")
if !tt.HasText("01:00") || changed != 4 {
t.Fatalf("RESET: texts %q, changed %d", tt.Texts(), changed)
}
find(t, tt, "FOCUS")
}
func TestTimeTrackerStartsStopsAndTotals(t *testing.T) {
wd := newWorld()
entries := []project.TimeEntry{{Task: "MAP", Start: wd.clk.t.Add(-time.Hour), Stop: wd.clk.t.Add(-30 * time.Minute)}}
tt := view(600, 300, wd, func(c *ui.Context) {
project.TimeTracker(c, "Time", "GRIND", &entries, project.TimeTrackerOptions{})
})
click(t, tt, "START")
later(tt, wd, 90*time.Second)
if len(entries) != 2 || !entries[1].Stop.IsZero() || !tt.HasText("01:30") {
t.Fatalf("90 s after START: %+v, texts %q", entries, tt.Texts())
}
click(t, tt, "STOP")
later(tt, wd, time.Minute)
if entries[1].Stop.Sub(entries[1].Start) != 90*time.Second || !tt.HasText("31:30") {
t.Fatalf("STOP: %+v, texts %q", entries, tt.Texts())
}
mustPanic(t, "two running entries", func() {
es := []project.TimeEntry{{Task: "A", Start: wd.clk.t}, {Task: "B", Start: wd.clk.t}}
view(600, 300, newWorld(), func(c *ui.Context) { project.TimeTracker(c, "T", "A", &es, project.TimeTrackerOptions{}) })
})
}
func TestSprintPanelCountsDays(t *testing.T) {
wd := newWorld()
s := project.Sprint{Name: "S7", Start: wd.clk.t.AddDate(0, 0, -9), End: wd.clk.t.Add(3*24*time.Hour + time.Hour), Done: 18, Total: 30}
tt := view(600, 300, wd, func(c *ui.Context) { project.SprintPanel(c, s, project.SprintPanelOptions{}) })
if !tt.HasText("4 DAYS LEFT") || !tt.HasText("18/30 POINTS") {
t.Fatalf("texts %q", tt.Texts())
}
later(tt, wd, 3*24*time.Hour)
if !tt.HasText("LAST DAY") {
t.Fatalf("an hour left: %q", tt.Texts())
}
later(tt, wd, 2*time.Hour)
if !tt.HasText("OVER") {
t.Fatalf("past the end: %q", tt.Texts())
}
mustPanic(t, "more done than the total", func() {
bad := s
bad.Done = 31
view(600, 300, newWorld(), func(c *ui.Context) { project.SprintPanel(c, bad, project.SprintPanelOptions{}) })
})
}
func TestMilestonesFlagTheNextOne(t *testing.T) {
wd := newWorld()
d := func(days int) time.Time { return wd.clk.t.AddDate(0, 0, days) }
ms := []project.Milestone{{Title: "A", Date: d(-10), Done: true}, {Title: "B", Date: d(10)}, {Title: "C", Date: d(30)}}
tt := view(600, 200, wd, func(c *ui.Context) { project.Milestones(c, "Road", ms, project.MilestonesOptions{Width: 200}) })
x, y, _, _ := dots(find(t, tt, "Road"))
img := tt.Image()
// Done flies Good, next flies Accent, later ones Faint.
flags := []pixel.Slot{pixel.Good, pixel.Accent, pixel.Faint}
for i, at := range []int{8, 100, 192} {
if count(img, x+at, y+5, 8, 5, flags[i]) == 0 {
t.Errorf("flag %d at %d is not %v", i, at, flags[i])
}
}
mustPanic(t, "dates out of order", func() {
bad := []project.Milestone{{Title: "A", Date: d(5)}, {Title: "B", Date: d(1)}}
view(600, 200, newWorld(), func(c *ui.Context) { project.Milestones(c, "R", bad, project.MilestonesOptions{}) })
})
}
func TestBurndownJudgesTheLastDay(t *testing.T) {
for _, tc := range []struct {
left []float64
ink pixel.Slot
}{{[]float64{10, 9, 9}, pixel.Bad}, {[]float64{10, 7, 4}, pixel.Good}} {
tt := view(600, 300, newWorld(), func(c *ui.Context) {
project.Burndown(c, "Burn", 10, 5, tc.left, project.BurndownOptions{})
})
x, y, w, _ := dots(find(t, tt, "Burn"))
// The verdict sits top right: BEHIND Bad, AHEAD Good.
if count(tt.Image(), x+w-70, y+3, 66, 16, tc.ink) == 0 {
t.Errorf("%v: no verdict in slot %v", tc.left, tc.ink)
}
}
mustPanic(t, "more days of data than the sprint", func() {
view(600, 300, newWorld(), func(c *ui.Context) { project.Burndown(c, "B", 10, 1, []float64{1, 2, 3}, project.BurndownOptions{}) })
})
}
func TestRoadmapMarksTodayAndBars(t *testing.T) {
wd := newWorld()
from := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
lanes := []project.Lane{{Name: "ENG", Bars: []project.Bar{{Title: "A", Start: from, End: from.AddDate(0, 2, 0)}}}}
tt := view(700, 200, wd, func(c *ui.Context) {
project.Roadmap(c, "Plan", from, lanes, project.RoadmapOptions{Months: 4, MonthWidth: 40})
})
x, y, _, h := dots(find(t, tt, "Plan"))
// Today, Sep 2, falls in September's column, by its days.
nameW := pixel.Measure("ENG") + 8
sep := from.AddDate(0, 1, 0)
today := x + nameW + 40 + int(40*float64(wd.clk.t.Sub(sep))/float64(sep.AddDate(0, 1, 0).Sub(sep)))
if count(tt.Image(), today, y+20, 1, h-20, pixel.Accent) == 0 {
t.Fatal("no Accent line at today")
}
if count(tt.Image(), x+nameW+2, y+24, 3, 8, pixel.Info) == 0 {
t.Fatal("the first bar has no Info cap")
}
mustPanic(t, "a bar that ends first", func() {
bad := []project.Lane{{Name: "E", Bars: []project.Bar{{Title: "A", Start: from, End: from}}}}
view(700, 200, newWorld(), func(c *ui.Context) { project.Roadmap(c, "P", from, bad, project.RoadmapOptions{}) })
})
}
// badge is the slots of an n-dot square there.
func badge(tt *ui.Tester, x, y, n int) string {
b := make([]byte, 0, n*n)
for j := y; j < y+n; j++ {
for i := x; i < x+n; i++ {
b = append(b, byte(at(tt.Image(), i, j)))
}
}
return string(b)
}
func TestRoadmapLinesUpWithMonths(t *testing.T) {
wd := newWorld()
from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
wd.clk.t = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
lanes := []project.Lane{{Name: "E", Bars: []project.Bar{{Title: "B", Start: wd.clk.t, End: wd.clk.t.AddDate(0, 0, 14)}}}}
tt := view(900, 200, wd, func(c *ui.Context) {
project.Roadmap(c, "Plan", from, lanes, project.RoadmapOptions{Months: 3, MonthWidth: 100})
})
x, y, _, _ := dots(find(t, tt, "Plan"))
// February, today and B start 100 dots past the names.
feb, img := x+pixel.Measure("E")+8+100, tt.Image()
if at(img, feb, y+18) != pixel.Accent || at(img, feb-1, y+23) != pixel.Back || at(img, feb, y+23) == pixel.Back {
t.Fatalf("at February's line: today %v; bar %v after %v", at(img, feb, y+18), at(img, feb, y+23), at(img, feb-1, y+23))
}
}
+100
View File
@@ -0,0 +1,100 @@
package project
import (
"fmt"
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// TimeEntry is time on a task; zero Stop is running.
type TimeEntry struct {
Task string
Start, Stop time.Time
}
type TimeTrackerOptions struct {
// Width is in dots; zero means 220.
Width int
Disabled bool
}
type TimeTrackerResult struct {
Element *ui.Element
Started, Stopped bool
}
// TimeTracker starts and stops entries and totals them.
func TimeTracker(c *ui.Context, text, task string, entries *[]TimeEntry, o TimeTrackerOptions) TimeTrackerResult {
k := pixel.K(c)
es, now := *entries, k.Now()
if task == "" {
panic("project: TimeTracker has no task")
}
for i, e := range es {
// Only the last entry may still run.
if e.Task == "" || e.Start.IsZero() || e.Stop.IsZero() && i < len(es)-1 || !e.Stop.IsZero() && e.Stop.Before(e.Start) {
panic(fmt.Sprintf("project: TimeTracker entry %d of %q runs %v to %v", i, e.Task, e.Start, e.Stop))
}
}
running := len(es) > 0 && es[len(es)-1].Stop.IsZero()
var res TimeTrackerResult
res.Element = ui.Column(c).Width(k.D(pixel.Or("TimeTracker Width", o.Width, 220))).Gap(k.D(4)).Role(ui.RoleGroup).Label(text).Children(func() {
ui.Row(c).FillWidth().Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
shown := time.Duration(0)
if running {
// A start ahead of now, as clocks drift, reads zero.
shown = max(0, now.Sub(es[len(es)-1].Start))
k.At(c, now.Add(time.Second-shown%time.Second))
}
pixel.Text(c, hms(shown), pixel.TextOptions{Scale: 2}).Grow(1)
label, v := k.Msg("start"), input.Primary
if running {
label, v = k.Msg("stop"), input.Danger
}
if !input.Button(c, label, input.ButtonOptions{Variant: v, Disabled: o.Disabled}).Clicked {
return
}
if running {
// A stop before a drifted start ends it there.
last := &(*entries)[len(es)-1]
last.Stop, res.Stopped = now, true
if now.Before(last.Start) {
last.Stop = last.Start
}
} else {
*entries, res.Started = append(es, TimeEntry{Task: task, Start: now}), true
}
slog.Debug("8bit: time tracker", "label", text, "running", res.Started, "entries", len(*entries))
})
// Newest first; the total counts the running one too.
total := time.Duration(0)
for i := len(es) - 1; i >= 0; i-- {
e := es[i]
d, ink := e.Stop.Sub(e.Start), pixel.Faint
if e.Stop.IsZero() {
d, ink = max(0, now.Sub(e.Start)), pixel.Accent
}
// Centuries of entries saturate instead of wrapping.
if d > math.MaxInt64-total {
d = math.MaxInt64 - total
}
total += d
// New rows come in on top; each keeps a key.
ui.Row(c).Key(i).FillWidth().Gap(k.D(4)).Children(func() {
pixel.Text(c, e.Task, pixel.TextOptions{Wrap: true, MaxLines: 1}).Grow(1)
pixel.Text(c, hms(d), pixel.TextOptions{Slot: ink})
})
}
ui.Row(c).FillWidth().Gap(k.D(4)).Children(func() {
pixel.Text(c, k.Msg("total"), pixel.TextOptions{}).Grow(1)
pixel.Text(c, hms(total), pixel.TextOptions{})
})
})
return res
}
+110
View File
@@ -0,0 +1,110 @@
package project_test
import (
"bytes"
"image"
"log/slog"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
// world is the settings a test view runs under.
type world struct {
clk *clock
reduce, inert bool
}
func newWorld() *world {
return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}}
}
// view builds on a fake clock, two pixels a dot.
func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert, Reduce: wd.reduce})
build(c)
}, w, h)
return tt
}
// later moves the clock on and builds a frame.
func later(tt *ui.Tester, wd *world, d time.Duration) {
wd.clk.t = wd.clk.t.Add(d)
tt.Frame()
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatalf("%v in %q", err, tt.Texts())
}
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
// none is a color outside the palette.
const none pixel.Slot = 255
// at is the slot at dot x, y, or none.
func at(img *image.RGBA, x, y int) pixel.Slot {
c := img.RGBAAt(2*x, 2*y)
for s, want := range pixel.SpringDay.Colors {
if c.R == want.R && c.G == want.G && c.B == want.B {
return pixel.Slot(s)
}
}
return none
}
// count is how many dots in a box hold s.
func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
n := 0
for j := y; j < y+h; j++ {
for i := x; i < x+w; i++ {
if at(img, i, j) == s {
n++
}
}
}
return n
}
// dots is a found box in dots.
func dots(r ui.Rect) (x, y, w, h int) {
return int(r.X / 2), int(r.Y / 2), int(r.W / 2), int(r.H / 2)
}
func mustPanic(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s did not panic", name)
}
}()
fn()
}
// logged runs fn with the debug log captured.
func logged(fn func()) string {
var buf bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(old)
fn()
return buf.String()
}
+64
View File
@@ -0,0 +1,64 @@
package project
import (
"fmt"
"math"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Load is a member's hours of work.
type Load struct {
Name string
Hours float64
}
type WorkloadBarOptions struct {
// Capacity is each member's hours; zero means 40.
Capacity float64
// Width is in dots; zero means 240.
Width int
}
// WorkloadBar shows each member's load against capacity.
func WorkloadBar(c *ui.Context, text string, loads []Load, o WorkloadBarOptions) *ui.Element {
k := pixel.K(c)
cp := o.Capacity
if cp == 0 {
cp = 40
}
if !(cp > 0) || math.IsInf(cp, 0) || len(loads) == 0 {
panic(fmt.Sprintf("project: WorkloadBar of %d members, capacity %v", len(loads), cp))
}
num := func(v float64) string { return strconv.FormatFloat(v, 'g', 4, 64) }
nameW, valW, vals := 0, 0, make([]string, len(loads))
for i, l := range loads {
if l.Name == "" || !(l.Hours >= 0) || math.IsInf(l.Hours, 0) {
panic(fmt.Sprintf("project: WorkloadBar member %q at %v hours", l.Name, l.Hours))
}
vals[i] = fmt.Sprintf(k.Msg("hours_of"), num(l.Hours), num(cp))
nameW, valW = max(nameW, pixel.Measure(l.Name)), max(valW, pixel.Measure(vals[i]))
}
w := pixel.Or("WorkloadBar Width", o.Width, 240)
// Face, name, meter and hours share a row, three gaps.
meterW := w - 16 - nameW - valW - 12
if meterW < 12 {
panic(fmt.Sprintf("project: WorkloadBar Width %d leaves a %d-dot meter", w, meterW))
}
return ui.Column(c).Width(k.D(w)).Gap(k.D(3)).Role(ui.RoleGroup).Label(text).Children(func() {
for i, l := range loads {
ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
display.Avatar(c, l.Name, display.AvatarOptions{})
show(c, k, l.Name, pixel.Ink, nameW)
// Shares of capacity: past 0.8 warns, past 1 is Bad.
feedback.Meter(c, l.Name, l.Hours/cp, feedback.MeterOptions{Max: 1.25, Warning: 0.8, Critical: 1, Width: meterW})
ui.Box(c).Width(k.D(valW)).Children(func() { pixel.Text(c, vals[i], pixel.TextOptions{Slot: pixel.Faint}) })
})
}
})
}