Add datetime components 196-215; Kit.At; inert stepper arrows

Calendars, date and time inputs, pickers, clocks, timers, a sun dial,
a season wheel, an agenda and a day schedule. Kit.At rebuilds at a
moment (relative times, midnight). Stepper and NumberInput arrows stop
under Inert. DateInput parks a popup focus as its own.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 23:56:10 -04:00
1 parent 5c3c883b7d
commit a2f2796455
38 files changed
+2651 -5

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
- Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots.
- `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. Integer options default through `pixel.Or`.
- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; one-shot timing steps with `k.Since`.
- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; one-shot timing steps with `k.Since`; a view that changes at a known moment asks `k.At`.
- A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option read through `k.Cue`, or a value that rises), never on its first frame. A state screen (`ErrorState`, `SuccessState`) starts when it appears. Decorative motion jumps to its end under Reduce Motion; countdowns and loaders keep time.
- Containers take content as a final `func()`; interactive results are `NameResult`.
- A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`, a drag's release) is read at paint time, or set on the element after its children.
+33
View File
@@ -0,0 +1,33 @@
package datetime
import (
"fmt"
"slices"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AgendaOptions struct{}
// Agenda groups events by day, earliest first.
func Agenda(c *ui.Context, events []Event, o AgendaOptions) *ui.Element {
k := pixel.K(c)
sorted := slices.Clone(events)
slices.SortStableFunc(sorted, func(a, b Event) int { return a.Start.Compare(b.Start) })
return ui.Column(c).Gap(k.D(2)).Role(ui.RoleList).Children(func() {
var last time.Time
for i, e := range sorted {
if i == 0 || !sameDay(e.Start, last) {
head := fmt.Sprintf("%s %d %s", weekdays(k)[e.Start.Weekday()], e.Start.Day(), monthName(k, e.Start.Month()))
ui.Box(c).Padding(k.D(4), 0, 0, 0).Children(func() {
pixel.Text(c, head, pixel.TextOptions{Slot: pixel.Faint})
})
}
last = e.Start
EventChip(c, e, EventChipOptions{})
}
})
}
+66
View File
@@ -0,0 +1,66 @@
package datetime
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// AlarmEntry is an alarm: time, days, and if it rings.
type AlarmEntry struct {
Hour, Minute int
// Days ring on, Sunday first.
Days [7]bool
On bool
}
type AlarmOptions struct {
Disabled bool
}
type AlarmResult struct {
Element *ui.Element
Changed bool
}
// Alarm lists alarms: time, days and a lever each.
func Alarm(c *ui.Context, text string, alarms []AlarmEntry, o AlarmOptions) AlarmResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(4)).Role(ui.RoleList).Label(text)
res := AlarmResult{Element: col}
names := weekdays(k)
col.Children(func() {
for i := range alarms {
a := &alarms[i]
if a.Hour < 0 || a.Hour > 23 || a.Minute < 0 || a.Minute > 59 {
panic(fmt.Sprintf("datetime: alarm %02d:%02d is no time", a.Hour, a.Minute))
}
at := fmt.Sprintf("%02d:%02d", a.Hour, a.Minute)
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Role(ui.RoleListItem).Label(at).Children(func() {
ink := pixel.Faint
if a.On {
ink = pixel.Ink
}
pixel.Text(c, at, pixel.TextOptions{Slot: ink, Scale: 2})
ui.Row(c).Gap(k.D(3)).Children(func() {
for d, n := range names {
day := pixel.Faint
if a.Days[d] {
day = pixel.Accent
}
pixel.Text(c, n, pixel.TextOptions{Slot: day})
}
})
if input.Switch(c, "", &a.On, input.SwitchOptions{Label: at, Disabled: o.Disabled}).Changed {
res.Changed = true
slog.Debug("8bit: alarm", "label", text, "at", at, "on", a.On)
}
})
}
})
return res
}
+45
View File
@@ -0,0 +1,45 @@
package datetime
import (
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AnalogClockOptions struct {
// Size is the face's width in dots; zero means 48.
Size int
}
// AnalogClock ticks each second; its hands are Bresenham lines.
func AnalogClock(c *ui.Context, o AnalogClockOptions) *ui.Element {
k := pixel.K(c)
size := pixel.Or("AnalogClock Size", o.Size, 48)
k.Tick(c, 1)
now := k.Now()
h, m, s := now.Clock()
// hand ends at turn (0–1 from twelve) and length r.
hand := func(cx, cy int, turn float64, r int) (int, int) {
a := turn * 2 * math.Pi
return cx + int(math.Round(float64(r)*math.Sin(a))), cy - int(math.Round(float64(r)*math.Cos(a)))
}
return ui.Box(c).Size(k.D(size), k.D(size)).Role(ui.RoleImage).Label(now.Format("15:04:05")).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
cx, cy, rad := size/2, size/2, size/2-1
g.Disc(cx, cy, rad, pixel.Face)
g.Circle(cx, cy, rad, pixel.Edge)
for i := range 12 {
x, y := hand(cx, cy, float64(i)/12, rad-3)
g.Dot(x, y, pixel.Edge)
}
x, y := hand(cx, cy, (float64(h%12)+float64(m)/60)/12, rad/2)
g.Line(cx, cy, x, y, pixel.Ink)
x, y = hand(cx, cy, (float64(m)+float64(s)/60)/60, rad-5)
g.Line(cx, cy, x, y, pixel.Ink)
x, y = hand(cx, cy, float64(s)/60, rad-4)
g.Line(cx, cy, x, y, pixel.Accent)
g.Dot(cx, cy, pixel.Accent)
})
}
+29
View File
@@ -0,0 +1,29 @@
package datetime
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
)
type CalendarOptions struct {
Disabled bool
}
type CalendarResult struct {
Element *ui.Element
// Picked is a click, Enter or Space on a day.
Changed, Picked bool
}
// Calendar moves *date through a month grid; today is marked.
func Calendar(c *ui.Context, text string, date *time.Time, o CalendarOptions) CalendarResult {
old := *date
e, _, picked := month(c, text, date, o.Disabled, false, func(d time.Time) look { return look{chosen: sameDay(d, *date)} })
res := CalendarResult{Element: e, Changed: !date.Equal(old), Picked: picked}
if res.Changed || res.Picked {
slog.Debug("8bit: calendar", "label", text, "date", date.Format("2006-01-02"), "picked", picked)
}
return res
}
+191
View File
@@ -0,0 +1,191 @@
package datetime_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestAnalogClockHandsPoint(t *testing.T) {
clk := newClock()
clk.t = time.Date(2026, 10, 11, 3, 0, 0, 0, time.UTC)
tt := view(200, 200, clk, false, func(c *ui.Context) {
datetime.AnalogClock(c, datetime.AnalogClockOptions{Size: 48})
})
r, img := find(t, tt, "03:00:00"), tt.Image()
cx, cy := int(r.X)+48, int(r.Y)+48
// Three o'clock: the hour hand runs right, the others up.
if slot(t, img, cx+20, cy) != pixel.Ink || slot(t, img, cx, cy-30) != pixel.Accent {
t.Fatal("the hands do not point at three o'clock")
}
clk.t = clk.t.Add(15 * time.Second)
tt.Frame()
if !shows(tt, "03:00:15") || slot(t, tt.Image(), cx+30, cy) != pixel.Accent {
t.Fatal("the second hand did not reach fifteen")
}
keeps(t, tt.Image())
}
func TestDigitalClockBlinksItsColons(t *testing.T) {
clk := newClock()
tt := view(400, 100, clk, false, func(c *ui.Context) {
datetime.DigitalClock(c, datetime.DigitalClockOptions{})
})
r := find(t, tt, "09:30:00")
// Two digits take 64 pixels, then an 8-pixel gap.
colon := ui.Rect{X: r.X + 72, Y: r.Y, W: 12, H: r.H}
on := count(t, tt.Image(), colon, pixel.Bad)
clk.t = clk.t.Add(500 * time.Millisecond)
tt.Frame()
if off := count(t, tt.Image(), colon, pixel.Bad); on == 0 || off != 0 {
t.Fatalf("colon dots %d then %d", on, off)
}
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if count(t, tt.Image(), colon, pixel.Bad) == 0 {
t.Fatal("reduced, the colon went dark")
}
}
func TestCountdownTurnsBadAndEndsOnce(t *testing.T) {
clk := newClock()
deadline, done := clk.t.Add(15*time.Second), 0
tt := view(300, 100, clk, false, func(c *ui.Context) {
if datetime.Countdown(c, "Bomb", deadline, datetime.CountdownOptions{}).Done {
done++
}
})
r := find(t, tt, "Bomb")
if n := count(t, tt.Image(), r, pixel.Bad); n != 0 {
t.Fatal("15 s left is Bad already")
}
clk.t = clk.t.Add(6 * time.Second)
tt.Frame()
if count(t, tt.Image(), r, pixel.Bad) == 0 {
t.Fatal("9 s left is not Bad")
}
clk.t = clk.t.Add(10 * time.Second)
tt.Frame()
tt.Frame()
if done != 1 {
t.Fatalf("Done %d times", done)
}
keeps(t, tt.Image())
}
func TestStopwatchTimesAndLaps(t *testing.T) {
clk := newClock()
var started, lapped, reset int
tt := view(300, 300, clk, false, func(c *ui.Context) {
r := datetime.Stopwatch(c, "Run", datetime.StopwatchOptions{})
if r.Started {
started++
}
if r.Lapped {
lapped++
}
if r.Reset {
reset++
}
})
click(t, tt, "START")
clk.t = clk.t.Add(3500 * time.Millisecond)
tt.Frame()
click(t, tt, "LAP")
if !shows(tt, "00:03.5") || !tt.HasText("LAP 1 00:03.5") {
t.Fatalf("texts %q", tt.Texts())
}
click(t, tt, "STOP")
clk.t = clk.t.Add(time.Minute)
tt.Frame()
if !shows(tt, "00:03.5") {
t.Fatalf("a stopped watch moved: %q", tt.Texts())
}
click(t, tt, "RESET")
if !shows(tt, "00:00.0") || started != 1 || lapped != 1 || reset != 1 {
t.Fatalf("reset left %q", tt.Texts())
}
}
func TestTimerRingsOnceAndFlashes(t *testing.T) {
clk := newClock()
done, set := 0, time.Minute
tt := view(400, 200, clk, false, func(c *ui.Context) {
if datetime.Timer(c, "Egg", &set, datetime.TimerOptions{}).Done {
done++
}
})
if !shows(tt, "01:00") {
t.Fatalf("a new timer shows %q", tt.Texts())
}
click(t, tt, "START")
clk.t = clk.t.Add(30 * time.Second)
tt.Frame()
click(t, tt, "PAUSE")
clk.t = clk.t.Add(time.Hour)
tt.Frame()
if !shows(tt, "00:30") {
t.Fatalf("paused at %q", tt.Texts())
}
click(t, tt, "START")
clk.t = clk.t.Add(31 * time.Second)
tt.Frame()
tt.Frame()
r := find(t, tt, "00:00")
lit := count(t, tt.Image(), r, pixel.Bad)
clk.t = clk.t.Add(250 * time.Millisecond)
tt.Frame()
if done != 1 || lit+count(t, tt.Image(), r, pixel.Bad) == 0 || lit > 0 && count(t, tt.Image(), r, pixel.Bad) > 0 {
t.Fatalf("done %d; the zero did not flash", done)
}
}
func TestRelativeTimeWordsAndUpdates(t *testing.T) {
clk := newClock()
for d, want := range map[time.Duration]string{
-20 * time.Second: "JUST NOW", -5 * time.Minute: "5 MIN AGO", 2 * time.Hour: "IN 2 H",
-30 * time.Hour: "YESTERDAY", 30 * time.Hour: "TOMORROW", 75 * time.Hour: "IN 3 DAYS",
} {
at := clk.t.Add(d)
tt := view(300, 60, clk, false, func(c *ui.Context) { datetime.RelativeTime(c, at, datetime.RelativeTimeOptions{}) })
if !shows(tt, want) {
t.Fatalf("%v: %q, want %q", d, tt.Texts(), want)
}
}
at := clk.t.Add(-90 * time.Second)
tt := view(300, 60, clk, false, func(c *ui.Context) { datetime.RelativeTime(c, at, datetime.RelativeTimeOptions{}) })
clk.t = clk.t.Add(time.Minute)
tt.Frame()
if !shows(tt, "2 MIN AGO") {
t.Fatalf("a minute on: %q", tt.Texts())
}
}
func TestRelativeTimeAsksForTheNextMinute(t *testing.T) {
clk := newClock()
at := clk.t.Add(-90 * time.Second)
tt := view(300, 60, clk, false, func(c *ui.Context) { datetime.RelativeTime(c, at, datetime.RelativeTimeOptions{}) })
pixel.ResetDue()
before := time.Now()
tt.Frame()
// The words change at two minutes: thirty seconds on.
if wait := pixel.Due().Sub(before); wait < 29*time.Second || wait > 31*time.Second {
t.Fatalf("next repaint in %v", wait)
}
}
func TestTimerShowsANewSettingAtOnce(t *testing.T) {
set := time.Minute
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
datetime.Timer(c, "Egg", &set, datetime.TimerOptions{})
})
r := find(t, tt, "MIN")
tt.Press(r.X+r.W+12, r.Y+8)
if set != 0 || !shows(tt, "00:00") {
t.Fatalf("set %v while the display reads %q", set, tt.Texts())
}
tt.Release(r.X+r.W+12, r.Y+8)
}
+55
View File
@@ -0,0 +1,55 @@
package datetime
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CountdownOptions struct{}
type CountdownResult struct {
Element *ui.Element
// Done is true in the frame time runs out.
Done bool
}
type countKey struct{}
// counting is the deadline counted to, and whether it passed.
type counting struct {
to time.Time
over bool
}
// Countdown shows time left to deadline, Bad near the end.
func Countdown(c *ui.Context, text string, deadline time.Time, o CountdownOptions) CountdownResult {
k := pixel.K(c)
e := ui.Box(c).Role(ui.RoleStatus).Label(text)
st := ui.Local(e, countKey{}, func() counting { return counting{} })
if !st.to.Equal(deadline) {
*st = counting{to: deadline}
}
left := max(0, deadline.Sub(k.Now()))
// Seconds round up: zero shows only at the end.
shown := (left + time.Second - 1).Truncate(time.Second)
res := CountdownResult{Element: e}
if left == 0 && !st.over {
st.over, res.Done = true, true
slog.Debug("8bit: countdown done", "label", text)
}
if left > 0 {
k.At(c, deadline.Add(-shown+time.Second))
}
s, ink := clock(shown), pixel.Ink
if left < 10*time.Second {
ink = pixel.Bad
}
e.Description(s).Size(k.D(pixel.Measure(s)*2), k.D(pixel.LineHeight*2)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Text(0, 0, s, ink, 2)
})
return res
}
+77
View File
@@ -0,0 +1,77 @@
package datetime
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DateInputOptions struct {
Disabled bool
}
type DateInputResult struct {
Element *ui.Element
Changed bool
}
type dateKey struct{}
// popping is whether the calendar is open and focused.
type popping struct{ open, held bool }
// DateInput shows YYYY-MM-DD; a press opens a calendar under it.
func DateInput(c *ui.Context, text string, date *time.Time, o DateInputOptions) DateInputResult {
k := pixel.K(c)
old := *date
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.ButtonBase(c).Role(ui.RolePopUpButton).Label(text).Disabled(o.Disabled)
})
pop := ui.Local(base, dateKey{}, func() popping { return popping{} })
open, was := &pop.open, pop.open
if base.Clicked() {
*open = !*open
}
// Closing with the field, its focus parks as the field's.
if o.Disabled || pixel.Current(c).Inert {
if *open && pop.held {
base.Focus()
pixel.Park(c, base, true)
}
*open = false
}
shown := date.Format("2006-01-02")
base.Description(shown)
shell.Size(k.D(pixel.Measure(shown)+24), k.D(pixel.LineHeight+6)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
g.Text(4, 3, shown, pixel.Ink, 1)
g.Sprite(g.W-icon.Calendar.W-4, (g.H-icon.Calendar.H)/2, icon.Calendar, 0, pixel.Faint, 1)
g.Finish(st)
})
ui.PopoverBase(c, base, open, func(panel *ui.Element) {
panel.Padding(k.D(5)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(func() {
e, grid, picked := month(c, text, date, false, true, func(d time.Time) look { return look{chosen: sameDay(d, *date)} })
// The grid holds focus before its chain links.
pop.held = grid.Focused() || e.FocusWithin()
if picked {
*open = false
}
})
})
if *open != was {
slog.Debug("8bit: date input popup", "label", text, "open", *open)
}
res := DateInputResult{Element: shell, Changed: !date.Equal(old)}
if res.Changed {
slog.Debug("8bit: date input", "label", text, "date", date.Format("2006-01-02"))
}
return res
}
+51
View File
@@ -0,0 +1,51 @@
package datetime
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
)
type DateRangeOptions struct {
Disabled bool
}
type DateRangeResult struct {
Element *ui.Element
Changed bool
}
type rangeKey struct{}
// ranging is the keys' day; tail picks the end next.
type ranging struct {
cur time.Time
tail bool
}
// DateRange picks a start, then an end; days between dither.
func DateRange(c *ui.Context, text string, start, end *time.Time, o DateRangeOptions) DateRangeResult {
home := ui.Box(c)
st := ui.Local(home, rangeKey{}, func() ranging { return ranging{cur: *start} })
e, _, picked := month(c, text, &st.cur, o.Disabled, false, func(d time.Time) look {
day := time.Date(d.Year(), d.Month(), d.Day(), 12, 0, 0, 0, time.UTC)
from := time.Date(start.Year(), start.Month(), start.Day(), 12, 0, 0, 0, time.UTC)
to := time.Date(end.Year(), end.Month(), end.Day(), 12, 0, 0, 0, time.UTC)
return look{chosen: sameDay(d, *start) || sameDay(d, *end), between: day.Compare(from) > 0 && day.Before(to)}
})
res := DateRangeResult{Element: e}
if picked {
switch d := st.cur; {
case !st.tail:
*start, *end = onDay(*start, d), onDay(*end, d)
case d.Before(*start):
*start, *end = onDay(*start, d), onDay(*end, *start)
default:
*end = onDay(*end, d)
}
st.tail, res.Changed = !st.tail, true
slog.Debug("8bit: date range", "label", text, "start", start.Format("2006-01-02"), "end", end.Format("2006-01-02"))
}
return res
}
+57
View File
@@ -0,0 +1,57 @@
// Package datetime picks, shows and counts dates and times.
package datetime
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Event is a timed entry of a day.
type Event struct {
Start time.Time
Length time.Duration
Title string
}
// monthName is m's short name in the frame's locale.
func monthName(k pixel.Kit, m time.Month) string {
return strings.Fields(k.Msg("months"))[m-1]
}
// weekdays are the day names, Sunday first.
func weekdays(k pixel.Kit) []string { return strings.Fields(k.Msg("weekdays")) }
// clock prints d as MM:SS, or H:MM:SS past an hour.
func clock(d time.Duration) string {
s := int(d / time.Second)
if s >= 3600 {
return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60)
}
return fmt.Sprintf("%02d:%02d", s/60, s%60)
}
// sameDay reports whether a and b share a date.
func sameDay(a, b time.Time) bool {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
// onDay moves t to d's date, keeping clock and place.
func onDay(t, d time.Time) time.Time {
y, m, day := d.Date()
h, mi, s := t.Clock()
return time.Date(y, m, day, h, mi, s, t.Nanosecond(), t.Location())
}
// today is now; the view rebuilds as the day turns.
func today(c *ui.Context, k pixel.Kit) time.Time {
n := k.Now()
k.At(c, time.Date(n.Year(), n.Month(), n.Day()+1, 0, 0, 0, 0, n.Location()))
return n
}
+91
View File
@@ -0,0 +1,91 @@
package datetime_test
import (
"image"
"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 }
// newClock reads Sunday 11 October 2026, 09:30 UTC.
func newClock() *clock { return &clock{time.Date(2026, 10, 11, 9, 30, 0, 0, time.UTC)} }
// view builds on a fake clock; inert veils every control.
func view(w, h int, clk *clock, inert bool, build func(c *ui.Context)) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now, Inert: inert})
build(c)
}, w, h)
}
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
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
func shows(tt *ui.Tester, label string) bool {
_, ok := tt.Find(label)
return ok
}
// slot is the palette slot of pixel x, y.
func slot(t *testing.T, img *image.RGBA, x, y int) pixel.Slot {
t.Helper()
c := img.RGBAAt(x, y)
for s, pc := range pixel.SpringDay.Colors {
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
return pixel.Slot(s)
}
}
t.Fatalf("pixel %d,%d is %v, off the palette", x, y, c)
return 0
}
// keeps fails on off-palette colors or split dots.
func keeps(t *testing.T, img *image.RGBA) {
t.Helper()
b := img.Bounds()
for y := 0; y+1 < b.Dy(); y += 2 {
for x := 0; x+1 < b.Dx(); x += 2 {
slot(t, img, x, y)
c := img.RGBAAt(x, y)
if img.RGBAAt(x+1, y) != c || img.RGBAAt(x, y+1) != c || img.RGBAAt(x+1, y+1) != c {
t.Fatalf("dot at %d,%d is split", x/2, y/2)
}
}
}
}
// count tallies dots of slot s in r.
func count(t *testing.T, img *image.RGBA, r ui.Rect, s pixel.Slot) int {
t.Helper()
n := 0
for y := int(r.Y); y < int(r.Y+r.H); y += 2 {
for x := int(r.X); x < int(r.X+r.W); x += 2 {
if slot(t, img, x, y) == s {
n++
}
}
}
return n
}
func day(t time.Time) string { return t.Format("2006-01-02") }
+86
View File
@@ -0,0 +1,86 @@
package datetime
import (
"fmt"
"slices"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DayScheduleOptions struct {
// From and To bound the hours; zeros mean 8–18.
From, To int
}
// hourH is an hour row's height; gutter holds labels.
const hourH, gutter = pixel.LineHeight + 4, 24
// DaySchedule places events on hour rows by start and length.
func DaySchedule(c *ui.Context, events []Event, o DayScheduleOptions) *ui.Element {
k := pixel.K(c)
from, to := o.From, o.To
if from == 0 && to == 0 {
from, to = 8, 18
}
if from < 0 || to > 24 || from >= to {
panic(fmt.Sprintf("datetime: DaySchedule hours %d to %d", from, to))
}
sorted := slices.Clone(events)
slices.SortStableFunc(sorted, func(a, b Event) int { return a.Start.Compare(b.Start) })
// Chains of overlapping events share the width in lanes.
lanes, shared := make([]int, len(sorted)), make([]int, len(sorted))
var ends []time.Time
var last time.Time
head := 0
for i, e := range sorted {
if i > 0 && e.Start.Compare(last) >= 0 {
for j := head; j < i; j++ {
shared[j] = len(ends)
}
head, ends = i, nil
}
lane := slices.IndexFunc(ends, func(end time.Time) bool { return end.Compare(e.Start) <= 0 })
if lane < 0 {
lane, ends = len(ends), append(ends, time.Time{})
}
lanes[i], ends[lane] = lane, e.Start.Add(e.Length)
if end := ends[lane]; end.Compare(last) > 0 || i == head {
last = end
}
}
for j := head; j < len(sorted); j++ {
shared[j] = len(ends)
}
box := ui.Box(c).FillWidth().Height(k.D((to - from) * hourH)).Role(ui.RoleList)
w, check := pixel.WrapWidth(c, box)
return box.Draw(func(p *ui.Painter, r ui.Rect) {
check(r)
g := k.Canvas(p, r)
for h := from; h < to; h++ {
y := (h - from) * hourH
g.Micro(0, y+1, fmt.Sprintf("%02d:00", h), pixel.Faint, 1)
g.Checker(gutter, y, g.W-gutter, 1, pixel.Edge)
}
}).Children(func() {
for i, e := range sorted {
// Minutes from the top row, cut to the hours shown.
top := e.Start.Hour()*60 + e.Start.Minute() - from*60
end := min(top+int(e.Length/time.Minute), (to-from)*60)
if top = max(0, top); end <= top {
continue
}
h := max(3, (end-top)*hourH/60)
y := min(top*hourH/60, (to-from)*hourH-h)
laneW := max(4, (w-gutter)/shared[i])
ui.Box(c).Absolute().Left(k.D(gutter+lanes[i]*laneW)).Top(k.D(y)).Size(k.D(laneW-1), k.D(h)).Role(ui.RoleListItem).
Label(e.Start.Format("15:04") + " " + e.Title).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.AccentLit)
g.Clip(1, 1, g.W-2, g.H-2, func() { g.Text(3, 0, e.Title, pixel.Ink, 1) })
})
}
})
}
+43
View File
@@ -0,0 +1,43 @@
package datetime
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type DigitalClockOptions struct {
// Scale is 1–4; zero means 2.
Scale int
}
// DigitalClock shows HH:MM:SS in seven segments; colons blink.
func DigitalClock(c *ui.Context, o DigitalClockOptions) *ui.Element {
k := pixel.K(c)
sc := pixel.Or("DigitalClock Scale", o.Scale, 2)
if sc > 4 {
panic(fmt.Sprintf("datetime: DigitalClock Scale %d outside 1–4", sc))
}
k.Tick(c, 1)
now := k.Now()
colon := func() {
ui.Box(c).Size(k.D(3*sc), k.D(11*sc)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// The colon blinks each half second, unless reduced.
if k.Reduce() || g.Step(2)%2 == 0 {
g.Rect(sc, 3*sc, sc, sc, pixel.Bad)
g.Rect(sc, 7*sc, sc, sc, pixel.Bad)
}
})
}
return ui.Row(c).Gap(k.D(2 * sc)).Role(ui.RoleText).Label(now.Format("15:04:05")).Children(func() {
text.SevenSegment(c, now.Format("15"), text.SevenSegmentOptions{Scale: sc})
colon()
text.SevenSegment(c, now.Format("04"), text.SevenSegmentOptions{Scale: sc})
colon()
text.SevenSegment(c, now.Format("05"), text.SevenSegmentOptions{Scale: sc})
})
}
+30
View File
@@ -0,0 +1,30 @@
package datetime
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type EventChipOptions struct {
// Slot colors the chip's edge; zero means Accent.
Slot pixel.Slot
}
// EventChip is a small chip with time and title.
func EventChip(c *ui.Context, e Event, o EventChipOptions) *ui.Element {
k := pixel.K(c)
edge := o.Slot
if edge == 0 {
edge = pixel.Accent
}
at := e.Start.Format("15:04")
w := pixel.Measure(at) + pixel.Measure(e.Title) + 14
return ui.Box(c).Size(k.D(w), k.D(pixel.LineHeight+2)).Role(ui.RoleText).Label(at + " " + e.Title).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Flat, pixel.Lit)
g.Rect(1, 1, 2, g.H-2, edge)
g.Text(6, 1, at, pixel.Faint, 1)
g.Text(10+pixel.Measure(at), 1, e.Title, pixel.Ink, 1)
})
}
+121
View File
@@ -0,0 +1,121 @@
package datetime
import (
"fmt"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// cell is a day's square in dots.
const cell = pixel.LineHeight
// look is how one day of a month grid draws.
type look struct{ chosen, between bool }
// addMonths moves t n months, keeping the day inside.
func addMonths(t time.Time, n int) time.Time {
y, m, d := t.Date()
first := time.Date(y, m+time.Month(n), 1, 0, 0, 0, 0, t.Location())
last := first.AddDate(0, 1, -1).Day()
return onDay(t, time.Date(first.Year(), first.Month(), min(d, last), 0, 0, 0, 0, t.Location()))
}
// month moves *cur by keys; clicks, Enter and Space pick.
func month(c *ui.Context, text string, cur *time.Time, disabled, focus bool, mark func(time.Time) look) (e, grid *ui.Element, picked bool) {
k := pixel.K(c)
e = ui.Column(c).Gap(k.D(2)).Role(ui.RoleGroup).Label(text)
e.Children(func() {
ui.Row(c).Width(k.D(7 * cell)).AlignItems(ui.Center).Children(func() {
if input.IconButton(c, icon.ChevronLeft, k.Msg("prev"), input.IconButtonOptions{Size: input.Small, Disabled: disabled}).Clicked {
*cur = addMonths(*cur, -1)
}
ui.Row(c).Grow(1).Justify(ui.Center).Children(func() {
pixel.Text(c, fmt.Sprintf("%s %d", monthName(k, cur.Month()), cur.Year()), pixel.TextOptions{})
})
if input.IconButton(c, icon.ChevronRight, k.Msg("next"), input.IconButtonOptions{Size: input.Small, Disabled: disabled}).Clicked {
*cur = addMonths(*cur, 1)
}
})
names := weekdays(k)
ui.Box(c).Size(k.D(7*cell), k.D(pixel.LineHeight)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, n := range names {
// Micro letters leave room between two-letter names.
if pixel.MicroOK(n) {
g.Micro(i*cell+(cell-pixel.MicroWidth(n))/2, 6, n, pixel.Faint, 1)
} else {
g.Text(i*cell+(cell-pixel.Measure(n))/2, 0, n, pixel.Faint, 1)
}
}
})
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleTable).Label(text).Disabled(disabled)
})
grid = base
if focus {
base.AutoFocus()
}
y, m, d := cur.Date()
for _, mv := range []struct {
key ui.Key
to time.Time
}{
{ui.KeyLeft, cur.AddDate(0, 0, -1)}, {ui.KeyRight, cur.AddDate(0, 0, 1)},
{ui.KeyUp, cur.AddDate(0, 0, -7)}, {ui.KeyDown, cur.AddDate(0, 0, 7)},
{ui.KeyPageUp, addMonths(*cur, -1)}, {ui.KeyPageDown, addMonths(*cur, 1)},
{ui.KeyHome, cur.AddDate(0, 0, 1-d)}, {ui.KeyEnd, addMonths(cur.AddDate(0, 0, 1-d), 1).AddDate(0, 0, -1)},
} {
if base.Shortcut(0, mv.key) {
*cur = mv.to
}
}
enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace)
picked = enter || space
first := time.Date(y, m, 1, 0, 0, 0, 0, cur.Location())
first = first.AddDate(0, 0, -int(first.Weekday()))
// Enter and Space are taken, so clicks come from pointers.
if base.Clicked() {
px, py, _ := base.PointerPosition()
if col, row := int(px/k.D(cell)), int(py/k.D(cell)); px >= 0 && py >= 0 && col < 7 && row < 6 {
*cur, picked = onDay(*cur, first.AddDate(0, 0, row*7+col)), true
}
}
base.Description(cur.Format("2006-01-02"))
now, shown := today(c, k), cur.Month()
shell.Size(k.D(7*cell), k.D(6*cell)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range 42 {
day, x, y := first.AddDate(0, 0, i), i%7*cell, i/7*cell
lk, ink := mark(day), pixel.Ink
if day.Month() != shown {
ink = pixel.Faint
}
if lk.between {
g.Veil(x, y, cell, cell, pixel.Accent, 4)
}
if lk.chosen {
g.Frame(x, y, cell, cell, pixel.Sunken, pixel.Well)
}
n := strconv.Itoa(day.Day())
g.Text(x+(cell-pixel.Measure(n))/2, y, n, ink, 1)
if sameDay(day, now) {
g.HLine(x+3, y+cell-2, cell-6, pixel.Accent)
}
if st.Focus && sameDay(day, *cur) {
g.Rect(x, y, cell, 1, pixel.Accent)
g.Rect(x, y+cell-1, cell, 1, pixel.Accent)
g.Rect(x, y, 1, cell, pixel.Accent)
g.Rect(x+cell-1, y, 1, cell, pixel.Accent)
}
}
g.Finish(st)
})
})
return e, grid, picked
}
+53
View File
@@ -0,0 +1,53 @@
package datetime_test
import (
"bytes"
"log/slog"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime"
)
func TestPopupsAndPagesAreLogged(t *testing.T) {
var buf bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(old)
date, month, year := raid, raid, 2026
tt := view(600, 500, newClock(), false, func(c *ui.Context) {
ui.Row(c).Gap(8).Children(func() {
datetime.DateInput(c, "Due", &date, datetime.DateInputOptions{})
datetime.MonthPicker(c, "Month", &month, datetime.MonthPickerOptions{})
datetime.YearPicker(c, "Year", &year, datetime.YearPickerOptions{})
})
})
click(t, tt, "Due")
tt.Key(0, ui.KeyEscape)
for _, l := range []string{"Month", "Year"} {
r := find(t, tt, l)
tt.ClickAt(r.X+16, r.Y-20)
}
out := buf.String()
for _, want := range []string{"date input popup", "month picker turned", "year picker turned"} {
if !strings.Contains(out, want) {
t.Errorf("no %q in the logs:\n%s", want, out)
}
}
}
func TestInertTimerKeepsItsSetting(t *testing.T) {
set := time.Minute
tt := view(400, 200, newClock(), true, func(c *ui.Context) {
datetime.Timer(c, "Egg", &set, datetime.TimerOptions{})
})
r := find(t, tt, "MIN")
tt.Press(r.X+r.W+12, r.Y+8)
tt.Release(r.X+r.W+12, r.Y+8)
if set != time.Minute {
t.Fatalf("an inert timer was set to %v", set)
}
}
+88
View File
@@ -0,0 +1,88 @@
package datetime
import (
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MonthPickerOptions struct {
Disabled bool
}
type MonthPickerResult struct {
Element *ui.Element
Changed bool
}
type monthKey struct{}
// paging is the year or decade shown, and a cell.
type paging struct {
at, cur int
}
// MonthPicker lays out twelve months; ◀ ▶ turn years.
func MonthPicker(c *ui.Context, text string, month *time.Time, o MonthPickerOptions) MonthPickerResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(2))
st := ui.Local(col, monthKey{}, func() paging { return paging{at: month.Year(), cur: int(month.Month()) - 1} })
res := MonthPickerResult{Element: col}
// Cells fit the widest name in the frame's locale.
cw := 28
for m := range 12 {
cw = max(cw, pixel.Measure(monthName(k, time.Month(m+1)))+6)
}
col.Children(func() {
pager(c, strconv.Itoa(st.at), 4*cw, o.Disabled, func(d int) {
st.at += d
slog.Debug("8bit: month picker turned", "label", text, "year", st.at)
})
shell, _, ps, picked := pixel.Pad(c, text, 4, 12, cw, cell, &st.cur, o.Disabled)
if picked >= 0 {
*month = time.Date(st.at, time.Month(picked+1), 1, 0, 0, 0, 0, month.Location())
res.Changed = true
}
y, m := month.Year(), int(month.Month())-1
at, cur := st.at, st.cur
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range 12 {
x, yy := i%4*cw, i/4*cell
if ps.Focus && i == cur {
g.Cursor(x, yy, cw, cell)
}
if at == y && i == m {
g.Frame(x+1, yy, cw-2, cell, pixel.Sunken, pixel.Well)
}
name := monthName(k, time.Month(i+1))
g.Text(x+(cw-pixel.Measure(name))/2, yy, name, pixel.Ink, 1)
}
g.Finish(ps)
})
})
if res.Changed {
slog.Debug("8bit: month picker", "label", text, "month", month.Format("2006-01"))
}
return res
}
// pager heads a grid: ◀ title ▶ call turn.
func pager(c *ui.Context, title string, w int, disabled bool, turn func(d int)) {
k := pixel.K(c)
ui.Row(c).Width(k.D(w)).AlignItems(ui.Center).Children(func() {
if input.IconButton(c, icon.ChevronLeft, k.Msg("prev"), input.IconButtonOptions{Size: input.Small, Disabled: disabled}).Clicked {
turn(-1)
}
ui.Row(c).Grow(1).Justify(ui.Center).Children(func() { pixel.Text(c, title, pixel.TextOptions{}) })
if input.IconButton(c, icon.ChevronRight, k.Msg("next"), input.IconButtonOptions{Size: input.Small, Disabled: disabled}).Clicked {
turn(1)
}
})
}
+228
View File
@@ -0,0 +1,228 @@
package datetime_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestCalendarMovesByKeysAndPicksByClick(t *testing.T) {
date := time.Date(2026, 1, 31, 8, 15, 0, 0, time.UTC)
picks := 0
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
if datetime.Calendar(c, "Date", &date, datetime.CalendarOptions{}).Picked {
picks++
}
})
r := find(t, tt, "Date")
// The grid sits under the header and the day names.
grid := ui.Rect{X: r.X, Y: r.Y + r.H - 6*32, W: 7 * 32, H: 6 * 32}
tt.ClickAt(grid.X+3*32+4, grid.Y+4)
if day(date) != "2025-12-31" || picks != 1 {
t.Fatalf("a click on the first row's Wednesday chose %s", day(date))
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyPageDown)
if day(date) != "2026-02-01" {
t.Fatalf("Right, Page Down gave %s", day(date))
}
date = time.Date(2026, 1, 31, 8, 15, 0, 0, time.UTC)
tt.Frame()
tt.Key(0, ui.KeyPageDown)
if day(date) != "2026-02-28" {
t.Fatalf("Page Down from January 31 gave %s", day(date))
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyHome)
if day(date) != "2026-03-01" || date.Hour() != 8 {
t.Fatalf("Page Down, Down, Home gave %s %d:00", day(date), date.Hour())
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEnter)
if day(date) != "2026-03-31" || picks != 2 {
t.Fatalf("End, Enter gave %s, %d picks", day(date), picks)
}
keeps(t, tt.Image())
}
func TestCalendarMarksTodayAndTheChosenDay(t *testing.T) {
date := time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
datetime.Calendar(c, "Date", &date, datetime.CalendarOptions{})
})
r := find(t, tt, "Date")
top := r.Y + r.H - 6*32
// October 2026 starts Thursday: the 11th is row 2.
today := ui.Rect{X: r.X + 6, Y: top + 2*32 + 28, W: 20, H: 2}
if n := count(t, tt.Image(), today, pixel.Accent); n == 0 {
t.Fatal("today is not marked")
}
chosen := ui.Rect{X: r.X + 3*32, Y: top + 2*32, W: 32, H: 32}
if n := count(t, tt.Image(), chosen, pixel.Shade); n == 0 {
t.Fatal("the chosen day is not sunken")
}
}
func TestOffCalendarStaysPut(t *testing.T) {
for _, inert := range []bool{false, true} {
date := time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)
tt := view(400, 300, newClock(), inert, func(c *ui.Context) {
datetime.Calendar(c, "Date", &date, datetime.CalendarOptions{Disabled: !inert})
})
r := find(t, tt, "Date")
tt.ClickAt(r.X+4, r.Y+r.H-4)
tt.Key(0, ui.KeyRight)
click(t, tt, "NEXT")
if day(date) != "2026-10-14" {
t.Fatalf("inert %v: an off calendar moved to %s", inert, day(date))
}
}
}
func TestDateInputOpensACalendar(t *testing.T) {
date := time.Date(2026, 10, 11, 0, 0, 0, 0, time.UTC)
disabled, changes := false, 0
tt := view(400, 400, newClock(), false, func(c *ui.Context) {
if datetime.DateInput(c, "Birthday", &date, datetime.DateInputOptions{Disabled: disabled}).Changed {
changes++
}
})
click(t, tt, "Birthday")
if !shows(tt, "OCT 2026") {
t.Fatalf("no calendar: %q", tt.Texts())
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if day(date) != "2026-10-12" || shows(tt, "OCT 2026") || changes != 1 {
t.Fatalf("Right, Enter gave %s", day(date))
}
click(t, tt, "Birthday")
disabled = true
tt.Frame()
if shows(tt, "OCT 2026") {
t.Fatal("the calendar stayed open as its field turned off")
}
keeps(t, tt.Image())
}
func TestTimeInputTypesAndSteps(t *testing.T) {
tm := time.Date(2026, 10, 11, 0, 0, 0, 0, time.UTC)
tt := view(300, 100, newClock(), false, func(c *ui.Context) {
datetime.TimeInput(c, "Wake", &tm, datetime.TimeInputOptions{})
})
click(t, tt, "Wake")
tt.Key(0, ui.KeyLeft)
for _, k := range []ui.Key{ui.Key0, ui.Key7, ui.Key3, ui.Key0} {
tt.Key(0, k)
}
if got := tm.Format("15:04"); got != "07:30" {
t.Fatalf("0730 typed %s", got)
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyDown)
if got := tm.Format("15:04"); got != "06:31" || day(tm) != "2026-10-11" {
t.Fatalf("Up, Left, Down gave %s", got)
}
tt.Key(0, ui.Key2)
tt.Key(0, ui.Key9)
if got := tm.Format("15:04"); got != "09:31" {
t.Fatalf("29 on hours gave %s", got)
}
keeps(t, tt.Image())
}
func TestDateRangePicksAndDithersBetween(t *testing.T) {
start := time.Date(2026, 10, 11, 0, 0, 0, 0, time.UTC)
end := start
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
datetime.DateRange(c, "Trip", &start, &end, datetime.DateRangeOptions{})
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if day(start) != "2026-10-11" || day(end) != "2026-10-14" {
t.Fatalf("Enter, 3×Right, Enter gave %s to %s", day(start), day(end))
}
r := find(t, tt, "Trip")
top := r.Y + r.H - 6*32
between := ui.Rect{X: r.X + 32, Y: top + 2*32, W: 32, H: 32}
if n := count(t, tt.Image(), between, pixel.Accent); n == 0 {
t.Fatal("the days between are not dithered")
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyEnter)
if day(start) != "2026-10-07" || day(end) != "2026-10-14" {
t.Fatalf("a start, then an end before it, gave %s to %s", day(start), day(end))
}
}
func TestMonthAndYearPickers(t *testing.T) {
month, year := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), 2026
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
datetime.MonthPicker(c, "Month", &month, datetime.MonthPickerOptions{})
datetime.YearPicker(c, "Year", &year, datetime.YearPickerOptions{})
})
if !shows(tt, "2026") || !shows(tt, "2020–2029") {
t.Fatalf("texts %q", tt.Texts())
}
r := find(t, tt, "Month")
tt.ClickAt(r.X+16, r.Y-20)
tt.ClickAt(r.X+2*r.W/4+4, r.Y+4)
if month.Format("2006-01") != "2025-03" {
t.Fatalf("◀ then MAR gave %s", month.Format("2006-01"))
}
y := find(t, tt, "Year")
tt.ClickAt(y.X+3*56+4, y.Y+32+4)
if year != 2028 {
t.Fatalf("a click on the second row's fourth year gave %d", year)
}
keeps(t, tt.Image())
}
func TestDateInputParksFocusAsItTurnsOff(t *testing.T) {
date := raid
disabled := false
tt := view(400, 400, newClock(), false, func(c *ui.Context) {
datetime.DateInput(c, "Due", &date, datetime.DateInputOptions{Disabled: disabled})
pixel.Text(c, "elsewhere", pixel.TextOptions{})
})
click(t, tt, "Due")
disabled = true
tt.Frame()
// Focused counts containers: root focus would light "elsewhere" too.
if tt.Focused("Due") && !tt.Focused("elsewhere") {
t.Fatal("the focus came back to the field as it turned off")
}
}
func TestDateInputGetsItsFocusBackAfterAVeil(t *testing.T) {
date, inert := raid, false
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: newClock().now, Inert: inert})
datetime.DateInput(c, "Due", &date, datetime.DateInputOptions{})
pixel.Text(c, "elsewhere", pixel.TextOptions{})
}, 400, 400)
click(t, tt, "Due")
inert = true
tt.Frame()
inert = false
tt.Frame()
if !tt.Focused("Due") || tt.Focused("elsewhere") {
t.Fatal("the field did not get its focus back after the veil")
}
tt.Key(0, ui.KeyEnter)
if !shows(tt, "OCT 2026") {
t.Fatal("Enter did not reopen the calendar")
}
}
+56
View File
@@ -0,0 +1,56 @@
package datetime
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type RelativeTimeOptions struct {
// Slot inks the text; zero means Faint.
Slot pixel.Slot
}
// RelativeTime says how long since or until t, each minute.
func RelativeTime(c *ui.Context, t time.Time, o RelativeTimeOptions) *ui.Element {
k := pixel.K(c)
ink := o.Slot
if ink == 0 {
ink = pixel.Faint
}
// Minutes since t; ahead of it, rounded up and negative.
d, m := k.Now().Sub(t), 0
if d >= 0 {
m = int(d / time.Minute)
} else {
m = -int((-d + time.Minute - 1) / time.Minute)
}
k.At(c, t.Add(time.Duration(m+1)*time.Minute))
return pixel.Text(c, words(k, m), pixel.TextOptions{Slot: ink})
}
// words says m minutes: ago when positive, ahead when negative.
func words(k pixel.Kit, m int) string {
n := max(m, -m)
h, days := n/60, n/(24*60)
pick := func(past, future string) string {
if m > 0 {
return past
}
return future
}
switch {
case n == 0:
return k.Msg("just_now")
case h == 0:
return fmt.Sprintf(k.Msg(pick("min_ago", "in_min")), n)
case days == 0:
return fmt.Sprintf(k.Msg(pick("hours_ago", "in_hours")), h)
case days == 1:
return k.Msg(pick("yesterday", "tomorrow"))
}
return fmt.Sprintf(k.Msg(pick("days_ago", "in_days")), days)
}
+104
View File
@@ -0,0 +1,104 @@
package datetime
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SeasonWheelOptions struct {
Disabled bool
}
type SeasonWheelResult struct {
Element *ui.Element
Changed bool
}
// wheel is the season wheel's width in dots.
const wheel = 64
// seasonArt draws each season, clockwise from the top right.
var seasonArt = [4]*pixel.Sprite{icon.Flower, icon.Sun, icon.Leaf, icon.Snowflake}
// SeasonWheel chooses one of four seasons on a wheel.
func SeasonWheel(c *ui.Context, text string, season *pixel.Season, o SeasonWheelOptions) SeasonWheelResult {
k := pixel.K(c)
if *season > pixel.Winter {
panic(fmt.Sprintf("datetime: unknown Season %d", *season))
}
old := *season
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleRadioGroup).Label(text).Disabled(o.Disabled)
})
if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyDown) {
*season = (*season + 1) % 4
}
if base.Shortcut(0, ui.KeyLeft) || base.Shortcut(0, ui.KeyUp) {
*season = (*season + 3) % 4
}
// Enter and Space are taken, so clicks come from pointers.
base.Shortcut(0, ui.KeyEnter)
base.Shortcut(0, ui.KeySpace)
if base.Clicked() {
x, y, _ := base.PointerPosition()
*season = pixel.Season(quarter(int(x/k.Dot())-wheel/2, int(y/k.Dot())-wheel/2))
}
names := []string{k.Msg("spring"), k.Msg("summer"), k.Msg("autumn"), k.Msg("winter")}
base.Description(names[*season])
at := int(*season)
shell.Size(k.D(wheel), k.D(wheel)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
mid, rad := wheel/2, wheel/2-1
g.Disc(mid, mid, rad, pixel.Face)
// The chosen quarter lights dot by dot inside the rim.
for y := range wheel {
for x := range wheel {
dx, dy := x-mid, y-mid
if quarter(dx, dy) == at && dx*dx+dy*dy <= (rad-1)*(rad-1) {
g.Dot(x, y, pixel.AccentLit)
}
}
}
g.Circle(mid, mid, rad, pixel.Edge)
g.VLine(mid, 1, wheel-2, pixel.Edge)
g.HLine(1, mid, wheel-2, pixel.Edge)
for i, sp := range seasonArt {
x, y := mid+6, mid-6-sp.H
if i == 1 || i == 2 {
y = mid + 6
}
if i >= 2 {
x = mid - 6 - sp.W
}
ink := pixel.Faint
if i == at {
ink = pixel.Accent
}
g.Sprite(x, y, sp, 0, ink, 1)
}
g.Finish(st)
})
res := SeasonWheelResult{Element: shell, Changed: *season != old}
if res.Changed {
slog.Debug("8bit: season wheel", "label", text, "season", names[*season])
}
return res
}
// quarter is an offset's quarter, clockwise from the top right.
func quarter(dx, dy int) int {
switch {
case dx >= 0 && dy < 0:
return 0
case dx >= 0:
return 1
case dy >= 0:
return 2
}
return 3
}
+82
View File
@@ -0,0 +1,82 @@
package datetime
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 StopwatchOptions struct {
Disabled bool
}
type StopwatchResult struct {
Element *ui.Element
Started, Stopped, Lapped, Reset bool
}
type watchKey struct{}
// watch is time banked, since when it runs, and laps.
type watch struct {
banked time.Duration
since time.Time
laps []time.Duration
}
// tenths prints d as MM:SS.T.
func tenths(d time.Duration) string {
return fmt.Sprintf("%s.%d", clock(d), int(d/(100*time.Millisecond))%10)
}
// Stopwatch times with START, STOP, LAP, RESET; laps below.
func Stopwatch(c *ui.Context, text string, o StopwatchOptions) StopwatchResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(4)).Role(ui.RoleGroup).Label(text)
w := ui.Local(col, watchKey{}, func() watch { return watch{} })
now := k.Now()
running := !w.since.IsZero()
elapsed := w.banked
if running {
elapsed += now.Sub(w.since)
k.Tick(c, 10)
}
res := StopwatchResult{Element: col}
col.Children(func() {
s := tenths(elapsed)
ui.Box(c).Size(k.D(pixel.Measure(s)*2), k.D(pixel.LineHeight*2)).Role(ui.RoleText).Label(s).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Text(0, 0, s, pixel.Ink, 2)
})
ui.Row(c).Gap(k.D(4)).Children(func() {
toggle := k.Msg("start")
if running {
toggle = k.Msg("stop")
}
if input.Button(c, toggle, input.ButtonOptions{Size: input.Small, Variant: input.Primary, Disabled: o.Disabled}).Clicked {
if running {
w.banked, w.since, res.Stopped = elapsed, time.Time{}, true
} else {
w.since, res.Started = now, true
}
}
if input.Button(c, k.Msg("lap"), input.ButtonOptions{Size: input.Small, Disabled: o.Disabled || !running}).Clicked {
w.laps, res.Lapped = append(w.laps, elapsed), true
}
if input.Button(c, k.Msg("reset"), input.ButtonOptions{Size: input.Small, Disabled: o.Disabled || running || elapsed == 0}).Clicked {
*w, res.Reset = watch{}, true
}
})
for i := len(w.laps) - 1; i >= max(0, len(w.laps)-5); i-- {
pixel.Text(c, fmt.Sprintf("%s %d %s", k.Msg("lap"), i+1, tenths(w.laps[i])), pixel.TextOptions{Slot: pixel.Faint})
}
})
if res.Started || res.Stopped || res.Lapped || res.Reset {
slog.Debug("8bit: stopwatch", "label", text, "elapsed", elapsed, "laps", len(w.laps))
}
return res
}
+76
View File
@@ -0,0 +1,76 @@
package datetime
import (
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SunDialOptions struct {
Disabled bool
}
type SunDialResult struct {
Element *ui.Element
Changed bool
}
// Dial size in dots: the arc's middle, radius and box.
const dialW, dialH, dialR = 96, 56, 40
// SunDial arcs the sun or moon; a drag sets *hour.
func SunDial(c *ui.Context, text string, hour *int, o SunDialOptions) SunDialResult {
k := pixel.K(c)
if *hour < 0 || *hour > 23 {
panic(fmt.Sprintf("datetime: SunDial hour %d outside 0–23", *hour))
}
old := *hour
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(text).Disabled(o.Disabled)
})
if base.Shortcut(0, ui.KeyLeft) {
*hour = (*hour + 23) % 24
}
if base.Shortcut(0, ui.KeyRight) {
*hour = (*hour + 1) % 24
}
// The width spans the whole day, midnight to midnight.
if _, _, held := base.Dragged(); held && !st.Disabled && !pixel.Current(c).Inert {
x, _, _ := base.PointerPosition()
*hour = max(0, min(23, int(x/k.D(dialW)*24)))
}
h := *hour
label := fmt.Sprintf("%02d:00", h)
base.Description(label)
day := h >= 6 && h < 18
shell.Size(k.D(dialW), k.D(dialH)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
sky, body := pixel.Lit, icon.Sun
if !day {
sky, body = pixel.Shade, icon.Moon
}
g.Frame(0, 0, g.W, g.H, pixel.Sunken, sky)
cx, cy := dialW/2, dialH-6
g.HLine(2, cy, dialW-4, pixel.Edge)
for i := 0; i <= 48; i += 2 {
a := math.Pi * float64(i) / 48
g.Dot(cx+int(math.Round(dialR*math.Cos(a))), cy-int(math.Round(dialR*math.Sin(a))), pixel.Faint)
}
t := float64((h+18)%12) / 12
a := math.Pi * (1 - t)
x, y := cx+int(math.Round(dialR*math.Cos(a))), cy-int(math.Round(dialR*math.Sin(a)))
g.Sprite(x-body.W/2, y-body.H/2, body, 0, pixel.Ink, 1)
g.Micro(3, 3, label, pixel.Ink, 1)
g.Finish(st)
})
res := SunDialResult{Element: shell, Changed: *hour != old}
if res.Changed {
slog.Debug("8bit: sundial", "label", text, "hour", *hour)
}
return res
}
+121
View File
@@ -0,0 +1,121 @@
package datetime
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TimeInputOptions struct {
Disabled bool
}
type TimeInputResult struct {
Element *ui.Element
Changed bool
}
type timeKey struct{}
// timing is the segment edited, and a first digit typed.
type timing struct {
seg, first int
}
// TimeInput edits HH:MM; Up and Down step, digits type.
func TimeInput(c *ui.Context, text string, tm *time.Time, o TimeInputOptions) TimeInputResult {
k := pixel.K(c)
old := *tm
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleTextField).Label(text).Disabled(o.Disabled)
})
s := ui.Local(base, timeKey{}, func() timing { return timing{first: -1} })
h, m := tm.Hour(), tm.Minute()
set := func(hour, minute int) {
*tm = time.Date(tm.Year(), tm.Month(), tm.Day(), hour, minute, 0, 0, tm.Location())
h, m = hour, minute
}
step := func(d int) {
if s.seg == 0 {
set((h+d+24)%24, m)
} else {
set(h, (m+d+60)%60)
}
s.first = -1
}
if base.Shortcut(0, ui.KeyUp) {
step(1)
}
if base.Shortcut(0, ui.KeyDown) {
step(-1)
}
for _, mv := range []struct {
key ui.Key
seg int
}{{ui.KeyLeft, 0}, {ui.KeyRight, 1}} {
if base.Shortcut(0, mv.key) {
s.seg, s.first = mv.seg, -1
}
}
for d := range 10 {
if !base.Shortcut(0, ui.Key0+ui.Key(d)) {
continue
}
top := 23
if s.seg == 1 {
top = 59
}
v := d
if s.first >= 0 && s.first*10+d <= top {
v = s.first*10 + d
}
if s.seg == 0 {
set(v, m)
} else {
set(h, v)
}
// A second digit ends a segment; hours pass to minutes.
if s.first >= 0 {
s.first, s.seg = -1, 1
} else {
s.first = d
}
}
// Enter and Space are taken, so clicks come from pointers.
base.Shortcut(0, ui.KeyEnter)
base.Shortcut(0, ui.KeySpace)
if base.Clicked() {
x, _, _ := base.PointerPosition()
s.seg, s.first = 0, -1
if int(x/k.Dot()) >= 4+pixel.Measure("00:") {
s.seg = 1
}
}
shown := fmt.Sprintf("%02d:%02d", h, m)
base.Description(shown)
seg := s.seg
shell.Size(k.D(pixel.Measure("00:00")+10), k.D(pixel.LineHeight+6)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
hh, mm, at := shown[:2], shown[3:], 5
for i, part := range []string{hh, ":", mm} {
ink, w := pixel.Ink, pixel.Measure(part)
if st.Focus && (i == 0 && seg == 0 || i == 2 && seg == 1) {
g.Rect(at-1, 2, w+2, g.H-4, pixel.Accent)
ink = pixel.AccentInk
}
g.Text(at, 3, part, ink, 1)
at += w
}
g.Finish(st)
})
res := TimeInputResult{Element: shell, Changed: !tm.Equal(old)}
if res.Changed {
slog.Debug("8bit: time input", "label", text, "time", shown)
}
return res
}
+109
View File
@@ -0,0 +1,109 @@
package datetime
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 TimerOptions struct {
Disabled bool
}
type TimerResult struct {
Element *ui.Element
// Done is true in the frame the time runs out.
Done bool
}
type timerKey struct{}
// ticking is time left when paused, the end, the ring.
type ticking struct {
left time.Duration
ends, rang time.Time
}
// Timer counts *set down; it flashes at zero.
func Timer(c *ui.Context, text string, set *time.Duration, o TimerOptions) TimerResult {
k := pixel.K(c)
if *set < 0 || *set >= 100*time.Minute || *set%time.Second != 0 {
panic(fmt.Sprintf("datetime: Timer set %v is not whole seconds under 100 min", *set))
}
col := ui.Column(c).Gap(k.D(4)).Role(ui.RoleGroup).Label(text)
t := ui.Local(col, timerKey{}, func() ticking { return ticking{} })
now := k.Now()
running := !t.ends.IsZero()
left := t.left
res := TimerResult{Element: col}
if running {
left = max(0, t.ends.Sub(now))
k.Tick(c, 4)
if left == 0 {
t.ends, t.left, t.rang, res.Done, running = time.Time{}, 0, now, true, false
slog.Debug("8bit: timer done", "label", text)
}
}
// It flashes for three seconds after ringing.
flash := !t.rang.IsZero() && now.Sub(t.rang) < 3*time.Second
if flash {
k.Tick(c, 4)
}
if !running && !flash && t.left == 0 {
left = *set
}
col.Children(func() {
s := clock((left + time.Second - 1).Truncate(time.Second))
face := ui.Box(c).Size(k.D(pixel.Measure(s)*2), k.D(pixel.LineHeight*2)).Role(ui.RoleStatus).Label(s)
face.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
if !flash {
g.Text(0, 0, s, pixel.Ink, 2)
return
}
// Reduced, the ring holds steady in Bad.
if k.Reduce() || g.Busy(4)%2 == 0 {
g.Text(0, 0, s, pixel.Bad, 2)
}
})
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
locked := o.Disabled || running
mins, secs := int(*set/time.Minute), int(*set%time.Minute/time.Second)
for _, f := range []struct {
name string
v *int
top int
}{{k.Msg("minutes"), &mins, 99}, {k.Msg("seconds"), &secs, 59}} {
pixel.Text(c, f.name, pixel.TextOptions{Slot: pixel.Faint})
if input.Stepper(c, f.name, f.v, input.StepperOptions{Max: f.top, Disabled: locked}).Changed {
*set, t.left = time.Duration(mins)*time.Minute+time.Duration(secs)*time.Second, 0
// The face above, built already, reads the new setting.
s = clock(*set)
face.Label(s)
}
}
})
toggle := k.Msg("start")
if running {
toggle = k.Msg("pause")
}
if input.Button(c, toggle, input.ButtonOptions{Size: input.Small, Variant: input.Primary, Disabled: o.Disabled}).Clicked {
switch {
case running:
t.left, t.ends = left, time.Time{}
case t.left > 0:
t.ends = now.Add(t.left)
default:
t.ends = now.Add(*set)
}
t.rang = time.Time{}
slog.Debug("8bit: timer", "label", text, "running", !running)
}
})
return res
}
+287
View File
@@ -0,0 +1,287 @@
package datetime_test
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestSunDialDragsHoursAndShowsTheSky(t *testing.T) {
hour, changes := 9, 0
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
if datetime.SunDial(c, "Hour", &hour, datetime.SunDialOptions{}).Changed {
changes++
}
})
r := find(t, tt, "Hour")
tt.Press(r.X+r.W/2+2, r.Y+20)
tt.Release(r.X+r.W/2+2, r.Y+20)
if hour != 12 {
t.Fatalf("a press at the middle set %d", hour)
}
// Noon puts the sun at the top of the arc.
if n := count(t, tt.Image(), ui.Rect{X: r.X + r.W/2 - 12, Y: r.Y + 2*(56-6-40) - 12, W: 24, H: 24}, pixel.Warn); n == 0 {
t.Fatal("no sun at the top at noon")
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyLeft)
if hour != 10 || changes != 3 {
t.Fatalf("Left, Left gave %d, %d changes", hour, changes)
}
hour = 23
tt.Frame()
// At 23:00 the moon sits left of the top.
moon := ui.Rect{X: r.X + 76 - 12, Y: r.Y + 22 - 12, W: 24, H: 24}
if count(t, tt.Image(), moon, pixel.Warn) == 0 || count(t, tt.Image(), r, pixel.Shade) < 100 {
t.Fatal("no moon at eleven at night")
}
keeps(t, tt.Image())
}
func TestSeasonWheelPicksByClickAndKeys(t *testing.T) {
season := pixel.Spring
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
datetime.SeasonWheel(c, "Season", &season, datetime.SeasonWheelOptions{})
})
r := find(t, tt, "Season")
tt.ClickAt(r.X+20, r.Y+r.H-20)
if season != pixel.Autumn {
t.Fatalf("a click at the bottom left chose %v", season)
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if season != pixel.Spring {
t.Fatalf("Right twice from autumn chose %v", season)
}
keeps(t, tt.Image())
}
func TestWeekStripStepsAcrossWeeks(t *testing.T) {
date := time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC)
tt := view(400, 100, newClock(), false, func(c *ui.Context) {
datetime.WeekStrip(c, "Week", &date, datetime.WeekStripOptions{})
})
r := find(t, tt, "Week")
tt.ClickAt(r.X+4, r.Y+4)
if day(date) != "2026-10-11" {
t.Fatalf("a click on Sunday gave %s", day(date))
}
tt.Key(0, ui.KeyLeft)
if day(date) != "2026-10-10" {
t.Fatalf("Left from Sunday gave %s", day(date))
}
keeps(t, tt.Image())
}
var raid = time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC)
func TestAgendaGroupsByDay(t *testing.T) {
events := []datetime.Event{
{Start: raid.Add(26 * time.Hour), Title: "Shop"},
{Start: raid, Title: "Raid"},
{Start: raid.Add(3 * time.Hour), Title: "Lunch"},
}
tt := view(300, 300, newClock(), false, func(c *ui.Context) {
datetime.Agenda(c, events, datetime.AgendaOptions{})
})
a, b, s := find(t, tt, "SU 11 OCT"), find(t, tt, "09:00 Raid"), find(t, tt, "MO 12 OCT")
l, sh := find(t, tt, "12:00 Lunch"), find(t, tt, "11:00 Shop")
if !(a.Y < b.Y && b.Y < l.Y && l.Y < s.Y && s.Y < sh.Y) {
t.Fatalf("order: %q", tt.Texts())
}
keeps(t, tt.Image())
}
func TestAlarmLeverAndBadTimes(t *testing.T) {
alarms := []datetime.AlarmEntry{{Hour: 7, Minute: 30, Days: [7]bool{1: true, 2: true}}}
changes := 0
tt := view(400, 100, newClock(), false, func(c *ui.Context) {
if datetime.Alarm(c, "Alarms", alarms, datetime.AlarmOptions{}).Changed {
changes++
}
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !alarms[0].On || changes != 1 {
t.Fatalf("the lever left On %v", alarms[0].On)
}
defer func() {
if recover() == nil {
t.Fatal("25:00 did not panic")
}
}()
view(100, 100, newClock(), false, func(c *ui.Context) {
datetime.Alarm(c, "x", []datetime.AlarmEntry{{Hour: 25}}, datetime.AlarmOptions{})
})
}
func TestYearViewMarksToday(t *testing.T) {
tt := view(600, 400, newClock(), false, func(c *ui.Context) {
datetime.YearView(c, 2026, datetime.YearViewOptions{})
})
r := find(t, tt, "2026")
if n := count(t, tt.Image(), r, pixel.Accent); n == 0 {
t.Fatal("today is not marked")
}
keeps(t, tt.Image())
}
func TestDaySchedulePlacesAndSplitsOverlaps(t *testing.T) {
events := []datetime.Event{
{Start: raid, Length: 2 * time.Hour, Title: "Raid"},
{Start: raid.Add(time.Hour), Length: time.Hour, Title: "Call"},
{Start: raid.Add(4 * time.Hour), Length: 30 * time.Minute, Title: "Lunch"},
}
tt := view(400, 500, newClock(), false, func(c *ui.Context) {
datetime.DaySchedule(c, events, datetime.DayScheduleOptions{})
})
tt.Frame()
a, b, l := find(t, tt, "09:00 Raid"), find(t, tt, "10:00 Call"), find(t, tt, "13:00 Lunch")
// Hours are 40 pixels; nine o'clock is the second row.
if a.Y != 40 || b.Y != 80 || b.X <= a.X || l.X != a.X || a.H != 80 || l.W < 2*a.W {
t.Fatalf("raid %v, call %v, lunch %v", a, b, l)
}
keeps(t, tt.Image())
}
func TestEventChipReadsTimeAndTitle(t *testing.T) {
tt := view(300, 60, newClock(), false, func(c *ui.Context) {
datetime.EventChip(c, datetime.Event{Start: raid, Title: "Raid"}, datetime.EventChipOptions{})
})
if !shows(tt, "09:00 Raid") {
t.Fatalf("texts %q", tt.Texts())
}
keeps(t, tt.Image())
}
func TestOffDateTimeControlsStayPut(t *testing.T) {
for _, inert := range []bool{false, true} {
off := !inert
tm := raid
week := raid
month, year, hour, season := raid, 2026, 9, pixel.Spring
alarms := []datetime.AlarmEntry{{Hour: 7}}
started := 0
tt := view(800, 600, newClock(), inert, func(c *ui.Context) {
ui.Row(c).Gap(8).Children(func() {
ui.Column(c).Gap(8).Children(func() {
datetime.TimeInput(c, "Time", &tm, datetime.TimeInputOptions{Disabled: off})
datetime.WeekStrip(c, "Week", &week, datetime.WeekStripOptions{Disabled: off})
datetime.MonthPicker(c, "Month", &month, datetime.MonthPickerOptions{Disabled: off})
datetime.YearPicker(c, "Year", &year, datetime.YearPickerOptions{Disabled: off})
})
ui.Column(c).Gap(8).Children(func() {
datetime.SunDial(c, "Hour", &hour, datetime.SunDialOptions{Disabled: off})
datetime.SeasonWheel(c, "Season", &season, datetime.SeasonWheelOptions{Disabled: off})
datetime.Alarm(c, "Alarms", alarms, datetime.AlarmOptions{Disabled: off})
if datetime.Stopwatch(c, "Run", datetime.StopwatchOptions{Disabled: off}).Started {
started++
}
})
})
})
for _, l := range []string{"Time", "Week", "Month", "Year", "Season", "START"} {
r := find(t, tt, l)
tt.ClickAt(r.X+r.W-4, r.Y+r.H-4)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyEnter)
}
h := find(t, tt, "Hour")
tt.Press(h.X+4, h.Y+4)
tt.Release(h.X+4, h.Y+4)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !tm.Equal(raid) || !week.Equal(raid) || !month.Equal(raid) || year != 2026 || hour != 9 || season != pixel.Spring || alarms[0].On || started != 0 {
t.Fatalf("inert %v: off controls changed", inert)
}
}
}
func TestDayScheduleKeepsLateBlocksInside(t *testing.T) {
late := time.Date(2026, 10, 11, 17, 55, 0, 0, time.UTC)
tt := view(400, 500, newClock(), false, func(c *ui.Context) {
datetime.DaySchedule(c, []datetime.Event{{Start: late, Length: 5 * time.Minute, Title: "Bell"}}, datetime.DayScheduleOptions{})
})
tt.Frame()
// Five minutes take the three-dot least: six pixels.
if b := find(t, tt, "17:55 Bell"); b.Y+b.H > 400 || b.H != 6 {
t.Fatalf("a late five-minute block is %v", b)
}
}
func TestMonthPickerFitsChineseNames(t *testing.T) {
month := raid
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: newClock().now, Locale: pixel.Zh})
datetime.MonthPicker(c, "Month", &month, datetime.MonthPickerOptions{})
}, 600, 300)
if w := find(t, tt, "Month").W; w < 4*float32(pixel.Measure("十二月")+2)*2 {
t.Fatalf("cells of %v pixels for four 十二月", w/4)
}
keeps(t, tt.Image())
}
func TestTodayMarksMoveAtMidnight(t *testing.T) {
clk := newClock()
clk.t = time.Date(2026, 10, 11, 23, 59, 59, 0, time.UTC)
date := clk.t
for name, build := range map[string]func(c *ui.Context){
"calendar": func(c *ui.Context) { datetime.Calendar(c, "Date", &date, datetime.CalendarOptions{}) },
"week": func(c *ui.Context) { datetime.WeekStrip(c, "Week", &date, datetime.WeekStripOptions{}) },
"year": func(c *ui.Context) { datetime.YearView(c, 2026, datetime.YearViewOptions{}) },
} {
tt := view(600, 400, clk, false, build)
pixel.ResetDue()
before := time.Now()
tt.Frame()
if due := pixel.Due(); due.IsZero() || due.Sub(before) > 2*time.Second {
t.Fatalf("%s: no rebuild asked for midnight, due %v", name, due)
}
marks := accents(t, tt)
clk.t = clk.t.Add(2 * time.Second)
tt.Frame()
after := accents(t, tt)
clk.t = clk.t.Add(-2 * time.Second)
if len(marks) == 0 || len(after) == 0 || slices.Equal(marks, after) {
t.Fatalf("%s: the today mark did not move at midnight", name)
}
}
}
func TestRelativeTimeRoundsTheFutureUp(t *testing.T) {
clk := newClock()
for _, tc := range []struct {
ahead time.Duration
words string
wait time.Duration
}{{30 * time.Second, "IN 1 MIN", 30 * time.Second}, {90 * time.Second, "IN 2 MIN", 30 * time.Second}, {2 * time.Minute, "IN 2 MIN", time.Minute}} {
at := clk.t.Add(tc.ahead)
tt := view(300, 60, clk, false, func(c *ui.Context) { datetime.RelativeTime(c, at, datetime.RelativeTimeOptions{}) })
pixel.ResetDue()
before := time.Now()
tt.Frame()
if wait := pixel.Due().Sub(before); !shows(tt, tc.words) || wait < tc.wait-time.Second || wait > tc.wait+time.Second {
t.Fatalf("%v ahead: %q, next repaint in %v", tc.ahead, tt.Texts(), wait)
}
}
}
// accents lists where Accent dots are.
func accents(t *testing.T, tt *ui.Tester) []int {
t.Helper()
img, out := tt.Image(), []int{}
for y := 0; y < img.Bounds().Dy(); y += 2 {
for x := 0; x < img.Bounds().Dx(); x += 2 {
if slot(t, img, x, y) == pixel.Accent {
out = append(out, y*img.Bounds().Dx()+x)
}
}
}
return out
}
+71
View File
@@ -0,0 +1,71 @@
package datetime
import (
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type WeekStripOptions struct {
Disabled bool
}
type WeekStripResult struct {
Element *ui.Element
Changed bool
}
// day is a week strip cell in dots.
const dayW, dayH = 22, 2 * pixel.LineHeight
// WeekStrip shows *date's week; Left and Right step days.
func WeekStrip(c *ui.Context, text string, date *time.Time, o WeekStripOptions) WeekStripResult {
k := pixel.K(c)
old := *date
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
})
if base.Shortcut(0, ui.KeyLeft) {
*date = date.AddDate(0, 0, -1)
}
if base.Shortcut(0, ui.KeyRight) {
*date = date.AddDate(0, 0, 1)
}
first := date.AddDate(0, 0, -int(date.Weekday()))
// Enter and Space are taken, so clicks come from pointers.
base.Shortcut(0, ui.KeyEnter)
base.Shortcut(0, ui.KeySpace)
if base.Clicked() {
x, _, _ := base.PointerPosition()
if i := int(x / k.D(dayW)); x >= 0 && i < 7 {
*date = first.AddDate(0, 0, i)
}
}
base.Description(date.Format("2006-01-02"))
names, chosen, now := weekdays(k), *date, today(c, k)
shell.Size(k.D(7*dayW), k.D(dayH)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range 7 {
d, x := first.AddDate(0, 0, i), i*dayW
if sameDay(d, chosen) {
g.Frame(x, 0, dayW, dayH, pixel.Sunken, pixel.Well)
}
n := strconv.Itoa(d.Day())
g.Text(x+(dayW-pixel.Measure(names[i]))/2, 0, names[i], pixel.Faint, 1)
g.Text(x+(dayW-pixel.Measure(n))/2, pixel.LineHeight-2, n, pixel.Ink, 1)
if sameDay(d, now) {
g.HLine(x+4, dayH-2, dayW-8, pixel.Accent)
}
}
g.Finish(st)
})
res := WeekStripResult{Element: shell, Changed: !date.Equal(old)}
if res.Changed {
slog.Debug("8bit: week strip", "label", text, "date", date.Format("2006-01-02"))
}
return res
}
+60
View File
@@ -0,0 +1,60 @@
package datetime
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type YearPickerOptions struct {
Disabled bool
}
type YearPickerResult struct {
Element *ui.Element
Changed bool
}
type yearKey struct{}
// YearPicker lays out a decade's years; ◀ ▶ turn decades.
func YearPicker(c *ui.Context, text string, year *int, o YearPickerOptions) YearPickerResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(2))
st := ui.Local(col, yearKey{}, func() paging { return paging{at: *year - *year%10, cur: *year % 10} })
res := YearPickerResult{Element: col}
col.Children(func() {
pager(c, fmt.Sprintf("%d–%d", st.at, st.at+9), 5*28, o.Disabled, func(d int) {
st.at += 10 * d
slog.Debug("8bit: year picker turned", "label", text, "decade", st.at)
})
shell, _, ps, picked := pixel.Pad(c, text, 5, 10, 28, cell, &st.cur, o.Disabled)
if picked >= 0 {
*year, res.Changed = st.at+picked, true
}
at, cur, chosen := st.at, st.cur, *year
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range 10 {
x, y := i%5*28, i/5*cell
if ps.Focus && i == cur {
g.Cursor(x, y, 28, cell)
}
if at+i == chosen {
g.Frame(x+1, y, 26, cell, pixel.Sunken, pixel.Well)
}
n := strconv.Itoa(at + i)
g.Text(x+(28-pixel.Measure(n))/2, y, n, pixel.Ink, 1)
}
g.Finish(ps)
})
})
if res.Changed {
slog.Debug("8bit: year picker", "label", text, "year", *year)
}
return res
}
+41
View File
@@ -0,0 +1,41 @@
package datetime
import (
"strconv"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type YearViewOptions struct{}
// A small month's day cell and box, in dots.
const miniW, miniH, monthW, monthH = 10, 7, 7*miniW + 4, 6*miniH + pixel.LineHeight + 2
// YearView sets a year's twelve months small; today is marked.
func YearView(c *ui.Context, year int, o YearViewOptions) *ui.Element {
k := pixel.K(c)
now := today(c, k)
return ui.Box(c).Size(k.D(4*monthW), k.D(3*monthH)).Role(ui.RoleImage).Label(strconv.Itoa(year)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for m := range 12 {
ox, oy := m%4*monthW, m/4*monthH
first := time.Date(year, time.Month(m+1), 1, 0, 0, 0, 0, now.Location())
g.Text(ox, oy, monthName(k, first.Month()), pixel.Faint, 1)
skip := int(first.Weekday())
for d := first; d.Month() == first.Month(); d = d.AddDate(0, 0, 1) {
i := skip + d.Day() - 1
x, y := ox+i%7*miniW, oy+pixel.LineHeight+i/7*miniH
n := strconv.Itoa(d.Day())
ink := pixel.Ink
if sameDay(d, now) {
g.Rect(x-1, y-1, miniW, miniH, pixel.Accent)
ink = pixel.AccentInk
}
g.Micro(x+(miniW-1-pixel.MicroWidth(n)), y, n, ink, 1)
}
}
})
}
+126
View File
@@ -0,0 +1,126 @@
package gallery
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var datetimeEntries = []Entry{
{196, "Calendar", "日历", "datetime", "Arrows walk the days, Page Up and Down the months. Today wears a line.", demoCalendar},
{197, "DateInput", "日期框", "datetime", "YYYY-MM-DD. Click it and a calendar drops down.", demoDateInput},
{198, "TimeInput", "时间框", "datetime", "HH:MM. Type 0730, or nudge with Up and Down.", demoTimeInput},
{199, "DateRange", "日期区间", "datetime", "Pick a start, then an end. The days between dither.", demoDateRange},
{200, "MonthPicker", "月份选择", "datetime", "A year of months; ◀ ▶ turn the year.", demoMonthPicker},
{201, "YearPicker", "年份选择", "datetime", "A decade at a glance; ◀ ▶ turn decades.", demoYearPicker},
{202, "AnalogClock", "指针钟", "datetime", "Hands drawn dot by dot with Bresenham lines. It ticks.", demoAnalogClock},
{203, "DigitalClock", "数字钟", "datetime", "Seven-segment HH:MM:SS. The colons blink, like a VCR.", demoDigitalClock},
{204, "Countdown", "倒计时", "datetime", "Time left; it turns red for the last ten seconds.", demoCountdown},
{205, "Stopwatch", "秒表", "datetime", "START, STOP, LAP, RESET. Speedrunners, warm up.", demoStopwatch},
}
type timeDemo struct {
date, due, wake, from, to, month time.Time
year, hour int
season pixel.Season
deadline time.Time
week time.Time
alarms []datetime.AlarmEntry
egg time.Duration
}
func timeState(c *ui.Context) *timeDemo {
now := pixel.K(c).Now()
return ui.Local(c.Root(), demoKey("datetime"), func() timeDemo {
return timeDemo{date: now, due: now.AddDate(0, 0, 7), wake: time.Date(now.Year(), now.Month(), now.Day(), 7, 30, 0, 0, now.Location()),
from: now, to: now.AddDate(0, 0, 4), month: now, year: now.Year(), hour: now.Hour(), season: pixel.Autumn,
deadline: now.Add(30 * time.Second), week: now, egg: 3 * time.Minute,
alarms: []datetime.AlarmEntry{{Hour: 7, Minute: 30, Days: [7]bool{1: true, 2: true, 3: true, 4: true, 5: true}, On: true}, {Hour: 10, Days: [7]bool{0: true, 6: true}}}}
})
}
func demoCalendar(c *ui.Context, log func(string)) {
s := timeState(c)
r := datetime.Calendar(c, "Date", &s.date, datetime.CalendarOptions{})
if r.Changed {
log(s.date.Format("2006-01-02"))
}
if r.Picked {
log("picked " + s.date.Format("2006-01-02"))
}
}
func demoDateInput(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.DateInput(c, "Quest due", &s.due, datetime.DateInputOptions{}).Changed {
log("due " + s.due.Format("2006-01-02"))
}
}
func demoTimeInput(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.TimeInput(c, "Wake up", &s.wake, datetime.TimeInputOptions{}).Changed {
log("wake at " + s.wake.Format("15:04"))
}
}
func demoDateRange(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.DateRange(c, "Vacation", &s.from, &s.to, datetime.DateRangeOptions{}).Changed {
log(s.from.Format("01-02") + " to " + s.to.Format("01-02"))
}
}
func demoMonthPicker(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.MonthPicker(c, "Month", &s.month, datetime.MonthPickerOptions{}).Changed {
log(s.month.Format("2006-01"))
}
}
func demoYearPicker(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.YearPicker(c, "Year", &s.year, datetime.YearPickerOptions{}).Changed {
log(fmt.Sprint(s.year))
}
}
func demoAnalogClock(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(8)).Children(func() {
datetime.AnalogClock(c, datetime.AnalogClockOptions{Size: 64})
datetime.AnalogClock(c, datetime.AnalogClockOptions{Size: 32})
})
}
func demoDigitalClock(c *ui.Context, log func(string)) {
datetime.DigitalClock(c, datetime.DigitalClockOptions{})
}
func demoCountdown(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := timeState(c)
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
if datetime.Countdown(c, "Bomb", s.deadline, datetime.CountdownOptions{}).Done {
log("boom")
}
if input.Button(c, "DEFUSE", input.ButtonOptions{Size: input.Small}).Clicked {
s.deadline = k.Now().Add(30 * time.Second)
log("thirty more seconds")
}
})
}
func demoStopwatch(c *ui.Context, log func(string)) {
r := datetime.Stopwatch(c, "Speedrun", datetime.StopwatchOptions{})
for name, on := range map[string]bool{"start": r.Started, "stop": r.Stopped, "lap": r.Lapped, "reset": r.Reset} {
if on {
log(name)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
package gallery
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var timeEntries = []Entry{
{206, "Timer", "定时器", "datetime", "Set minutes and seconds, START, PAUSE. Zero flashes.", demoTimer},
{207, "SunDial", "日晷", "datetime", "Drag across the sky: the sun by day, the moon by night.", demoSunDial},
{208, "SeasonWheel", "四季轮", "datetime", "Four seasons on a wheel. Click one, or turn it with the arrows.", demoSeasonWheel},
{209, "WeekStrip", "周条", "datetime", "Seven days in a row. Left and Right walk into the next week.", demoWeekStrip},
{210, "Agenda", "日程", "datetime", "Events under the day they fall on.", demoAgenda},
{211, "EventChip", "事件块", "datetime", "A small chip: time, title, and a colored edge.", demoEventChip},
{212, "RelativeTime", "相对时间", "datetime", "3 MIN AGO, IN 2 H, TOMORROW. It keeps itself up to date.", demoRelativeTime},
{213, "Alarm", "闹钟", "datetime", "Alarm rows: time, days, and a lever to arm each one.", demoAlarm},
{214, "YearView", "年视图", "datetime", "Twelve tiny months; today is lit.", demoYearView},
{215, "DaySchedule", "日程表", "datetime", "Hour rows with blocks placed by start and length. Overlaps share.", demoDaySchedule},
}
func demoTimer(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.Timer(c, "Egg timer", &s.egg, datetime.TimerOptions{}).Done {
log("ding! the egg is ready")
}
}
func demoSunDial(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.SunDial(c, "Hour", &s.hour, datetime.SunDialOptions{}).Changed {
log(fmt.Sprintf("%02d:00", s.hour))
}
}
func demoSeasonWheel(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.SeasonWheel(c, "Season", &s.season, datetime.SeasonWheelOptions{}).Changed {
log([]string{"spring", "summer", "autumn", "winter"}[s.season])
}
}
func demoWeekStrip(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.WeekStrip(c, "Week", &s.week, datetime.WeekStripOptions{}).Changed {
log(s.week.Format("Mon 2006-01-02"))
}
}
// today returns now's date at h:m.
func today(c *ui.Context, h, m int) time.Time {
n := pixel.K(c).Now()
return time.Date(n.Year(), n.Month(), n.Day(), h, m, 0, 0, n.Location())
}
func demoAgenda(c *ui.Context, log func(string)) {
datetime.Agenda(c, []datetime.Event{
{Start: today(c, 9, 0), Title: "Slime patrol"}, {Start: today(c, 13, 30), Title: "Lunch at the inn"},
{Start: today(c, 10, 0).AddDate(0, 0, 1), Title: "Boss raid"}, {Start: today(c, 18, 0).AddDate(0, 0, 2), Title: "Fireworks"},
}, datetime.AgendaOptions{})
}
func demoEventChip(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
datetime.EventChip(c, datetime.Event{Start: today(c, 9, 0), Title: "Slime patrol"}, datetime.EventChipOptions{})
datetime.EventChip(c, datetime.Event{Start: today(c, 20, 0), Title: "Boss raid"}, datetime.EventChipOptions{Slot: pixel.Bad})
datetime.EventChip(c, datetime.Event{Start: today(c, 12, 0), Title: "Picnic"}, datetime.EventChipOptions{Slot: pixel.Good})
})
}
func demoRelativeTime(c *ui.Context, log func(string)) {
k := pixel.K(c)
now := k.Now()
ui.Column(c).Gap(k.D(2)).Children(func() {
for _, r := range []struct {
what string
at time.Time
}{{"Saved", now.Add(-3 * time.Minute)}, {"Boss respawns", now.Add(2 * time.Hour)}, {"Last login", now.Add(-30 * time.Hour)}, {"Festival", now.Add(52 * time.Hour)}} {
ui.Row(c).Gap(k.D(6)).Children(func() {
pixel.Text(c, r.what, pixel.TextOptions{})
datetime.RelativeTime(c, r.at, datetime.RelativeTimeOptions{})
})
}
})
}
func demoAlarm(c *ui.Context, log func(string)) {
s := timeState(c)
if datetime.Alarm(c, "Alarms", s.alarms, datetime.AlarmOptions{}).Changed {
log(fmt.Sprintf("7:30 %v, 10:00 %v", s.alarms[0].On, s.alarms[1].On))
}
}
func demoYearView(c *ui.Context, log func(string)) {
datetime.YearView(c, pixel.K(c).Now().Year(), datetime.YearViewOptions{})
}
func demoDaySchedule(c *ui.Context, log func(string)) {
ui.Box(c).Width(pixel.K(c).D(220)).Children(func() {
datetime.DaySchedule(c, []datetime.Event{
{Start: today(c, 9, 0), Length: 2 * time.Hour, Title: "Slime patrol"},
{Start: today(c, 10, 0), Length: time.Hour, Title: "Call mom"},
{Start: today(c, 13, 0), Length: 90 * time.Minute, Title: "Inn lunch"},
{Start: today(c, 16, 30), Length: time.Hour, Title: "Training"},
}, datetime.DayScheduleOptions{})
})
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries)
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries)
func Entries() []Entry { return entries }
+19
View File
@@ -169,3 +169,22 @@ func TestDisabledWhileFocusedTakesNoInput(t *testing.T) {
})
}
}
func TestInertSteppersTakeNoPress(t *testing.T) {
n, v := 5, 5.0
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: true})
ui.Row(c).Children(func() {
input.Stepper(c, "Lives", &n, input.StepperOptions{Max: 9})
input.NumberInput(c, "Speed", &v, input.NumberInputOptions{Max: 10})
})
}, 600, 100)
s, f := find(t, tt, "Lives"), find(t, tt, "Speed")
// Arrows sit left of a stepper, right of a field.
tt.Press(s.X-12, s.Y+8)
tt.Release(s.X-12, s.Y+8)
tt.ClickAt(f.X+f.W+10, f.Y+4)
if n != 5 || v != 5 {
t.Fatalf("inert arrows set %d and %v", n, v)
}
}
+2 -1
View File
@@ -86,8 +86,9 @@ func NumberInput(c *ui.Context, label string, value *float64, o NumberInputOptio
// arrowBox is a half-height step button the pointer presses.
func arrowBox(c *ui.Context, k pixel.Kit, sp *pixel.Sprite, disabled bool) bool {
disabled = disabled || pixel.Current(c).Inert
b := ui.Box(c).Size(k.D(10), k.D(pixel.LineHeight/2)).Role(ui.RoleNone)
down := b.Pressed()
down := b.Pressed() && !disabled
b.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
dy := 0
+1 -1
View File
@@ -73,7 +73,7 @@ type hold struct {
// held counts an arrow's steps: one, then ten a second.
func held(c *ui.Context, k pixel.Kit, sp *pixel.Sprite, disabled bool) int {
b := ui.Box(c).Size(k.D(12), k.D(pixel.LineHeight+4)).Role(ui.RoleNone)
down := b.Pressed() && !disabled
down := b.Pressed() && !disabled && !pixel.Current(c).Inert
h := ui.Local(b, holdKey{}, func() hold { return hold{} })
b.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
+17
View File
@@ -194,3 +194,20 @@ func TestPopoverRefusesNegativeWidth(t *testing.T) {
overlay.Popover(c, "X", b.Element, &open, overlay.PopoverOptions{Width: -1}, func() {})
})
}
func TestPopconfirmParksFocusAsItTurnsOff(t *testing.T) {
disabled := false
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
ui.Column(c).Padding(40).Children(func() {
overlay.Popconfirm(c, "SELL", "Sell?", overlay.PopconfirmOptions{Disabled: disabled})
pixel.Text(c, "elsewhere", pixel.TextOptions{})
})
})
click(t, tt, "SELL")
tt.Key(0, ui.KeyTab)
disabled = true
tt.Frame()
if tt.Focused("SELL") && !tt.Focused("elsewhere") {
t.Fatal("the focus came back to the button as it turned off")
}
}
+4 -1
View File
@@ -31,8 +31,11 @@ func Popconfirm(c *ui.Context, text, question string, o PopconfirmOptions) Popco
if b.Clicked {
*open = true
}
// A popup above a veil closes with its control.
// A popup above a veil closes with it, focus parked.
if o.Disabled || pixel.Current(c).Inert {
if *open {
c.Root().Focus()
}
*open = false
}
was := *open
+7
View File
@@ -55,6 +55,13 @@ func (k Kit) Tick(c *ui.Context, fps int) int {
return n
}
// At builds the view again at t.
func (k Kit) At(c *ui.Context, t time.Time) {
d := max(0, t.Sub(k.now))
c.After(d)
ask(c.Now().Add(d).UnixNano())
}
// Since counts steps from t at fps, up to done.
func (k Kit) Since(c *ui.Context, t time.Time, fps, done int) int {
if t.IsZero() {
+10
View File
@@ -19,6 +19,11 @@ var messages = map[Locale]map[string]string{
"on": "ON", "off": "OFF", "clear": "CLEAR", "per_page": "PER PAGE", "range": "%d–%d OF %d", "found": "FOUND: %d",
"unknown": "???", "sure": "ARE YOU SURE?", "paused": "PAUSED", "resume": "RESUME", "options": "OPTIONS", "quit": "QUIT",
"next_step": "NEXT ▶",
"months": "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC", "weekdays": "SU MO TU WE TH FR SA",
"start": "START", "stop": "STOP", "lap": "LAP", "reset": "RESET", "pause": "PAUSE",
"just_now": "JUST NOW", "min_ago": "%d MIN AGO", "hours_ago": "%d H AGO", "yesterday": "YESTERDAY", "days_ago": "%d DAYS AGO",
"in_min": "IN %d MIN", "in_hours": "IN %d H", "tomorrow": "TOMORROW", "in_days": "IN %d DAYS",
"spring": "SPRING", "summer": "SUMMER", "autumn": "AUTUMN", "winter": "WINTER", "minutes": "MIN", "seconds": "SEC",
},
Zh: {
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
@@ -36,6 +41,11 @@ var messages = map[Locale]map[string]string{
"on": "开", "off": "关", "clear": "清除", "per_page": "每页", "range": "%d–%d / 共 %d", "found": "找到:%d",
"unknown": "???", "sure": "确定吗?", "paused": "暂停", "resume": "继续游戏", "options": "设置", "quit": "退出",
"next_step": "下一步 ▶",
"months": "一月 二月 三月 四月 五月 六月 七月 八月 九月 十月 十一月 十二月", "weekdays": "日 一 二 三 四 五 六",
"start": "开始", "stop": "停止", "lap": "计次", "reset": "复位", "pause": "暂停",
"just_now": "刚刚", "min_ago": "%d 分钟前", "hours_ago": "%d 小时前", "yesterday": "昨天", "days_ago": "%d 天前",
"in_min": "%d 分钟后", "in_hours": "%d 小时后", "tomorrow": "明天", "in_days": "%d 天后",
"spring": "春", "summer": "夏", "autumn": "秋", "winter": "冬", "minutes": "分", "seconds": "秒",
},
}