diff --git a/AGENTS.md b/AGENTS.md index e58fc2a..8872b08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/datetime/agenda.go b/datetime/agenda.go new file mode 100644 index 0000000..4ee9a90 --- /dev/null +++ b/datetime/agenda.go @@ -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{}) + } + }) +} diff --git a/datetime/alarm.go b/datetime/alarm.go new file mode 100644 index 0000000..8b44272 --- /dev/null +++ b/datetime/alarm.go @@ -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 +} diff --git a/datetime/analogclock.go b/datetime/analogclock.go new file mode 100644 index 0000000..5476942 --- /dev/null +++ b/datetime/analogclock.go @@ -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) + }) +} diff --git a/datetime/calendar.go b/datetime/calendar.go new file mode 100644 index 0000000..e73e09f --- /dev/null +++ b/datetime/calendar.go @@ -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 +} diff --git a/datetime/clocks_test.go b/datetime/clocks_test.go new file mode 100644 index 0000000..30efc30 --- /dev/null +++ b/datetime/clocks_test.go @@ -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) +} diff --git a/datetime/countdown.go b/datetime/countdown.go new file mode 100644 index 0000000..fc74e8e --- /dev/null +++ b/datetime/countdown.go @@ -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 +} diff --git a/datetime/dateinput.go b/datetime/dateinput.go new file mode 100644 index 0000000..6558236 --- /dev/null +++ b/datetime/dateinput.go @@ -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 +} diff --git a/datetime/daterange.go b/datetime/daterange.go new file mode 100644 index 0000000..228290d --- /dev/null +++ b/datetime/daterange.go @@ -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 +} diff --git a/datetime/datetime.go b/datetime/datetime.go new file mode 100644 index 0000000..8d98393 --- /dev/null +++ b/datetime/datetime.go @@ -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 +} diff --git a/datetime/datetime_test.go b/datetime/datetime_test.go new file mode 100644 index 0000000..058eeb3 --- /dev/null +++ b/datetime/datetime_test.go @@ -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") } diff --git a/datetime/dayschedule.go b/datetime/dayschedule.go new file mode 100644 index 0000000..88b236f --- /dev/null +++ b/datetime/dayschedule.go @@ -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) }) + }) + } + }) +} diff --git a/datetime/digitalclock.go b/datetime/digitalclock.go new file mode 100644 index 0000000..e84fea3 --- /dev/null +++ b/datetime/digitalclock.go @@ -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}) + }) +} diff --git a/datetime/eventchip.go b/datetime/eventchip.go new file mode 100644 index 0000000..03dfb3d --- /dev/null +++ b/datetime/eventchip.go @@ -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) + }) +} diff --git a/datetime/grid.go b/datetime/grid.go new file mode 100644 index 0000000..c04b6f1 --- /dev/null +++ b/datetime/grid.go @@ -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 +} diff --git a/datetime/log_test.go b/datetime/log_test.go new file mode 100644 index 0000000..fe802b1 --- /dev/null +++ b/datetime/log_test.go @@ -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) + } +} diff --git a/datetime/monthpicker.go b/datetime/monthpicker.go new file mode 100644 index 0000000..66bc232 --- /dev/null +++ b/datetime/monthpicker.go @@ -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) + } + }) +} diff --git a/datetime/pickers_test.go b/datetime/pickers_test.go new file mode 100644 index 0000000..669ccd7 --- /dev/null +++ b/datetime/pickers_test.go @@ -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") + } +} diff --git a/datetime/relativetime.go b/datetime/relativetime.go new file mode 100644 index 0000000..37f034a --- /dev/null +++ b/datetime/relativetime.go @@ -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) +} diff --git a/datetime/seasonwheel.go b/datetime/seasonwheel.go new file mode 100644 index 0000000..806eb89 --- /dev/null +++ b/datetime/seasonwheel.go @@ -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 +} diff --git a/datetime/stopwatch.go b/datetime/stopwatch.go new file mode 100644 index 0000000..bd441ff --- /dev/null +++ b/datetime/stopwatch.go @@ -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 +} diff --git a/datetime/sundial.go b/datetime/sundial.go new file mode 100644 index 0000000..9bd49e9 --- /dev/null +++ b/datetime/sundial.go @@ -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 +} diff --git a/datetime/timeinput.go b/datetime/timeinput.go new file mode 100644 index 0000000..9d73dca --- /dev/null +++ b/datetime/timeinput.go @@ -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 +} diff --git a/datetime/timer.go b/datetime/timer.go new file mode 100644 index 0000000..d58ef97 --- /dev/null +++ b/datetime/timer.go @@ -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 +} diff --git a/datetime/views_test.go b/datetime/views_test.go new file mode 100644 index 0000000..ad20f1d --- /dev/null +++ b/datetime/views_test.go @@ -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 +} diff --git a/datetime/weekstrip.go b/datetime/weekstrip.go new file mode 100644 index 0000000..fee70f3 --- /dev/null +++ b/datetime/weekstrip.go @@ -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 +} diff --git a/datetime/yearpicker.go b/datetime/yearpicker.go new file mode 100644 index 0000000..4c8f802 --- /dev/null +++ b/datetime/yearpicker.go @@ -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 +} diff --git a/datetime/yearview.go b/datetime/yearview.go new file mode 100644 index 0000000..ba53335 --- /dev/null +++ b/datetime/yearview.go @@ -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) + } + } + }) +} diff --git a/gallery/demos_datetime.go b/gallery/demos_datetime.go new file mode 100644 index 0000000..f32ef99 --- /dev/null +++ b/gallery/demos_datetime.go @@ -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) + } + } +} diff --git a/gallery/demos_time.go b/gallery/demos_time.go new file mode 100644 index 0000000..267867c --- /dev/null +++ b/gallery/demos_time.go @@ -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{}) + }) +} diff --git a/gallery/registry.go b/gallery/registry.go index 6adb0cc..e6843d0 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) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries) func Entries() []Entry { return entries } diff --git a/input/disabled_test.go b/input/disabled_test.go index b3da53c..1d2ba99 100644 --- a/input/disabled_test.go +++ b/input/disabled_test.go @@ -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) + } +} diff --git a/input/numberinput.go b/input/numberinput.go index 9a3f0ad..efcb0ab 100644 --- a/input/numberinput.go +++ b/input/numberinput.go @@ -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 diff --git a/input/stepper.go b/input/stepper.go index ba9821d..4c478d1 100644 --- a/input/stepper.go +++ b/input/stepper.go @@ -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) diff --git a/overlay/bubbles_test.go b/overlay/bubbles_test.go index 150f2d7..28b4972 100644 --- a/overlay/bubbles_test.go +++ b/overlay/bubbles_test.go @@ -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") + } +} diff --git a/overlay/popconfirm.go b/overlay/popconfirm.go index c7bb641..044b388 100644 --- a/overlay/popconfirm.go +++ b/overlay/popconfirm.go @@ -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 diff --git a/pixel/clock.go b/pixel/clock.go index 1cd9814..d568134 100644 --- a/pixel/clock.go +++ b/pixel/clock.go @@ -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() { diff --git a/pixel/messages.go b/pixel/messages.go index 41ead22..afa1e65 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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": "秒", }, }