diff --git a/gallery/demos_project.go b/gallery/demos_project.go new file mode 100644 index 0000000..d082279 --- /dev/null +++ b/gallery/demos_project.go @@ -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) + } +} diff --git a/gallery/demos_team.go b/gallery/demos_team.go new file mode 100644 index 0000000..838f838 --- /dev/null +++ b/gallery/demos_team.go @@ -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{}) +} diff --git a/gallery/fx_demo_test.go b/gallery/fx_demo_test.go index 77c7695..a69da6e 100644 --- a/gallery/fx_demo_test.go +++ b/gallery/fx_demo_test.go @@ -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") diff --git a/gallery/project_demo_test.go b/gallery/project_demo_test.go new file mode 100644 index 0000000..bf8d778 --- /dev/null +++ b/gallery/project_demo_test.go @@ -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]) + } + } + } +} diff --git a/gallery/registry.go b/gallery/registry.go index 18deaa6..a89b9e7 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -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 } diff --git a/input/checkbox.go b/input/checkbox.go index 61bd222..178e700 100644 --- a/input/checkbox.go +++ b/input/checkbox.go @@ -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) } diff --git a/input/turnoff_test.go b/input/turnoff_test.go new file mode 100644 index 0000000..3756113 --- /dev/null +++ b/input/turnoff_test.go @@ -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) + } + } +} diff --git a/pixel/messages.go b/pixel/messages.go index d961dec..b92ac43 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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 小时", }, } diff --git a/project/assigneepicker.go b/project/assigneepicker.go new file mode 100644 index 0000000..bf9de14 --- /dev/null +++ b/project/assigneepicker.go @@ -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 +} diff --git a/project/board_test.go b/project/board_test.go new file mode 100644 index 0000000..3b871c2 --- /dev/null +++ b/project/board_test.go @@ -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") + } +} diff --git a/project/burndown.go b/project/burndown.go new file mode 100644 index 0000000..377efcc --- /dev/null +++ b/project/burndown.go @@ -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) + } + }) +} diff --git a/project/checklist.go b/project/checklist.go new file mode 100644 index 0000000..1a4622b --- /dev/null +++ b/project/checklist.go @@ -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 +} diff --git a/project/extremes_test.go b/project/extremes_test.go new file mode 100644 index 0000000..95027cf --- /dev/null +++ b/project/extremes_test.go @@ -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") + } +} diff --git a/project/hms_internal_test.go b/project/hms_internal_test.go new file mode 100644 index 0000000..f75db96 --- /dev/null +++ b/project/hms_internal_test.go @@ -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) + } + } +} diff --git a/project/issuecard.go b/project/issuecard.go new file mode 100644 index 0000000..8088bad --- /dev/null +++ b/project/issuecard.go @@ -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 +} diff --git a/project/labelpicker.go b/project/labelpicker.go new file mode 100644 index 0000000..a57c084 --- /dev/null +++ b/project/labelpicker.go @@ -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 +} diff --git a/project/look_test.go b/project/look_test.go new file mode 100644 index 0000000..9341937 --- /dev/null +++ b/project/look_test.go @@ -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) + } + } + } +} diff --git a/project/milestones.go b/project/milestones.go new file mode 100644 index 0000000..b3deb99 --- /dev/null +++ b/project/milestones.go @@ -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) + }) +} diff --git a/project/pomodoro.go b/project/pomodoro.go new file mode 100644 index 0000000..ff755e4 --- /dev/null +++ b/project/pomodoro.go @@ -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 +} diff --git a/project/priority.go b/project/priority.go new file mode 100644 index 0000000..6501b59 --- /dev/null +++ b/project/priority.go @@ -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) +} diff --git a/project/project.go b/project/project.go new file mode 100644 index 0000000..0fe11e5 --- /dev/null +++ b/project/project.go @@ -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) + }) +} diff --git a/project/questboard.go b/project/questboard.go new file mode 100644 index 0000000..20108fc --- /dev/null +++ b/project/questboard.go @@ -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 +} diff --git a/project/roadmap.go b/project/roadmap.go new file mode 100644 index 0000000..2a8b060 --- /dev/null +++ b/project/roadmap.go @@ -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) + } + } + }) +} diff --git a/project/sprintpanel.go b/project/sprintpanel.go new file mode 100644 index 0000000..8b43c2e --- /dev/null +++ b/project/sprintpanel.go @@ -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) +} diff --git a/project/taskitem.go b/project/taskitem.go new file mode 100644 index 0000000..b5ea9e3 --- /dev/null +++ b/project/taskitem.go @@ -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 +} diff --git a/project/tasklist.go b/project/tasklist.go new file mode 100644 index 0000000..6fcbba9 --- /dev/null +++ b/project/tasklist.go @@ -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 +} diff --git a/project/time_test.go b/project/time_test.go new file mode 100644 index 0000000..56bf51f --- /dev/null +++ b/project/time_test.go @@ -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)) + } +} diff --git a/project/timetracker.go b/project/timetracker.go new file mode 100644 index 0000000..d1d895f --- /dev/null +++ b/project/timetracker.go @@ -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 +} diff --git a/project/view_test.go b/project/view_test.go new file mode 100644 index 0000000..fb8cdda --- /dev/null +++ b/project/view_test.go @@ -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() +} diff --git a/project/workloadbar.go b/project/workloadbar.go new file mode 100644 index 0000000..d3077b9 --- /dev/null +++ b/project/workloadbar.go @@ -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}) }) + }) + } + }) +}