From c8d058799d1baaea0f492b0c03b22a4588b63830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 10:25:07 -0400 Subject: [PATCH] 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. --- gallery/demos_project.go | 113 +++++++++++++++++ gallery/demos_team.go | 104 ++++++++++++++++ gallery/fx_demo_test.go | 15 ++- gallery/project_demo_test.go | 37 ++++++ gallery/registry.go | 2 +- input/checkbox.go | 10 +- input/turnoff_test.go | 28 +++++ pixel/messages.go | 8 ++ project/assigneepicker.go | 74 +++++++++++ project/board_test.go | 229 +++++++++++++++++++++++++++++++++++ project/burndown.go | 73 +++++++++++ project/checklist.go | 61 ++++++++++ project/extremes_test.go | 101 +++++++++++++++ project/hms_internal_test.go | 14 +++ project/issuecard.go | 95 +++++++++++++++ project/labelpicker.go | 86 +++++++++++++ project/look_test.go | 139 +++++++++++++++++++++ project/milestones.go | 128 ++++++++++++++++++++ project/pomodoro.go | 146 ++++++++++++++++++++++ project/priority.go | 78 ++++++++++++ project/project.go | 60 +++++++++ project/questboard.go | 156 ++++++++++++++++++++++++ project/roadmap.go | 112 +++++++++++++++++ project/sprintpanel.go | 65 ++++++++++ project/taskitem.go | 55 +++++++++ project/tasklist.go | 57 +++++++++ project/time_test.go | 183 ++++++++++++++++++++++++++++ project/timetracker.go | 100 +++++++++++++++ project/view_test.go | 110 +++++++++++++++++ project/workloadbar.go | 64 ++++++++++ 30 files changed, 2496 insertions(+), 7 deletions(-) create mode 100644 gallery/demos_project.go create mode 100644 gallery/demos_team.go create mode 100644 gallery/project_demo_test.go create mode 100644 input/turnoff_test.go create mode 100644 project/assigneepicker.go create mode 100644 project/board_test.go create mode 100644 project/burndown.go create mode 100644 project/checklist.go create mode 100644 project/extremes_test.go create mode 100644 project/hms_internal_test.go create mode 100644 project/issuecard.go create mode 100644 project/labelpicker.go create mode 100644 project/look_test.go create mode 100644 project/milestones.go create mode 100644 project/pomodoro.go create mode 100644 project/priority.go create mode 100644 project/project.go create mode 100644 project/questboard.go create mode 100644 project/roadmap.go create mode 100644 project/sprintpanel.go create mode 100644 project/taskitem.go create mode 100644 project/tasklist.go create mode 100644 project/time_test.go create mode 100644 project/timetracker.go create mode 100644 project/view_test.go create mode 100644 project/workloadbar.go 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}) }) + }) + } + }) +}