Date & Time: components 051-058 (wip)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:22:05 -04:00
1 parent cfc7ab023d
commit e61e38b656
45 files changed
+3943 -1

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
package mujica
import (
"fmt"
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// WeekStart is the first column of a month. The zero value follows the locale:
// Monday for zh-CN, Sunday for en-US.
type WeekStart int
const (
WeekStartLocale WeekStart = iota
WeekStartSunday
WeekStartMonday
WeekStartSaturday
)
// CalendarOptions configure a Calendar.
type CalendarOptions struct {
// Min and Max bound the days that can be chosen; zero leaves a side open.
Min, Max Date
// DisableDate marks single days that cannot be chosen.
DisableDate func(Date) bool
WeekStart WeekStart
// Today is marked; zero uses the system clock.
Today Date
Disabled bool
// Label names the calendar for assistive technology.
Label string
}
// Calendar shows one month and chooses a day into *value.
// Arrows move by day and week, PageUp/PageDown by month, Shift with them by year.
func Calendar(c *ui.Context, value *Date, opts CalendarOptions) DateTimeResult {
datetimeCheckDate("Calendar value", *value)
datetimeCheckBounds("Calendar", opts.Min, opts.Max)
box := ui.Column(c)
cursor := ui.Local(box, "calendar-cursor", func() Date { return Date{} })
seen := ui.Local(box, "calendar-seen", func() Date { return Date{} })
today := datetimeToday(c, opts.Today)
if *seen != *value || cursor.IsZero() {
*seen = *value
*cursor = *value
if cursor.IsZero() {
*cursor = today
}
}
changed := false
box.Disabled(opts.Disabled).Children(func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(d Date) bool { return d == *value },
enabled: calendarEnabled(opts.Min, opts.Max, opts.DisableDate),
today: today,
weekStart: opts.WeekStart,
label: opts.Label,
})
if !picked.IsZero() && picked != *value {
slog.Debug("mujica: calendar change", "from", *value, "to", picked)
*value, *seen = picked, picked
changed = true
}
})
return DateTimeResult{Element: box, changed: changed}
}
// calendarConfig is how calendarMonth marks and limits its days.
type calendarConfig struct {
chosen func(Date) bool
inRange func(Date) bool
enabled func(Date) bool
today Date
weekStart WeekStart
autoFocus bool
label string
}
// calendarEnabled combines bounds and a day filter.
func calendarEnabled(lo, hi Date, disable func(Date) bool) func(Date) bool {
return func(d Date) bool { return d.within(lo, hi) && (disable == nil || !disable(d)) }
}
// calendarFirstWeekday resolves the first column for the window's locale.
func calendarFirstWeekday(c *ui.Context, w WeekStart) time.Weekday {
switch w {
case WeekStartSunday:
return time.Sunday
case WeekStartMonday:
return time.Monday
case WeekStartSaturday:
return time.Saturday
case WeekStartLocale:
if CurrentLocale(c) == ZhCN {
return time.Monday
}
return time.Sunday
}
panic(fmt.Sprintf("mujica: unknown WeekStart %d", w))
}
// calendarSeek returns the first enabled day from d stepping by dir, or d.
func calendarSeek(d Date, dir int, enabled func(Date) bool) Date {
for i, x := 0, d; i < 31; i, x = i+1, x.AddDays(dir) {
if enabled(x) {
return x
}
}
return d
}
// calendarMonth draws the month of *cursor and returns the day picked and the day hovered.
func calendarMonth(c *ui.Context, cursor *Date, cfg calendarConfig) (picked, hovered Date) {
k := Tokens(c)
d := Density(c)
cur := *cursor
title := datetimeMonthTitle(c, cur.Year, cur.Month)
label := cfg.label
if label == "" {
label = msg(c, "calendar.label")
}
cell := d.ControlHeight()
root := ui.Column(c).Gap(d.Unit() * 2).Shrink(0).Width(cell * 7).Role(ui.RoleGroup).Label(label)
keyed := ui.Local(root, "calendar-keys", func() bool { return false })
move := func(to Date, dir int, keys bool) {
*cursor = calendarSeek(to, dir, cfg.enabled)
*keyed = keys
}
root.Children(func() {
ui.Row(c).AlignItems(ui.Center).Gap(d.Unit()).Children(func() {
if datetimeIconButton(c, "chevrons-left", msg(c, "calendar.prev_year")).Clicked() {
move(cur.AddMonths(-12), -1, false)
}
if datetimeIconButton(c, "chevron-left", msg(c, "calendar.prev_month")).Clicked() {
move(cur.AddMonths(-1), -1, false)
}
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.BodySize + 2).Bold().
TextColor(k.Text).Grow(1).TextAlign(ui.Center).SingleLine()
if datetimeIconButton(c, "chevron-right", msg(c, "calendar.next_month")).Clicked() {
move(cur.AddMonths(1), 1, false)
}
if datetimeIconButton(c, "chevrons-right", msg(c, "calendar.next_year")).Clicked() {
move(cur.AddMonths(12), 1, false)
}
})
first := calendarFirstWeekday(c, cfg.weekStart)
ui.Row(c).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).PaddingY(d.Unit()).Children(func() {
for i := range 7 {
wd := (int(first) + i) % 7
ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Width(cell).TextAlign(ui.Center).
FontSize(theme.CaptionSize).TextColor(k.TextMuted).Role(ui.RoleColumnHeader).
Label(msg(c, "calendar.weekday_long_"+strconv.Itoa(wd)))
}
})
days := ui.Column(c).Role(ui.RoleTable).Label(title).FocusGroup(ui.Horizontal | ui.Vertical)
keysMoved := true
switch {
case days.Shortcut(0, ui.KeyLeft):
move(cur.AddDays(-1), -1, true)
case days.Shortcut(0, ui.KeyRight):
move(cur.AddDays(1), 1, true)
case days.Shortcut(0, ui.KeyUp):
move(cur.AddDays(-7), -1, true)
case days.Shortcut(0, ui.KeyDown):
move(cur.AddDays(7), 1, true)
case days.Shortcut(0, ui.KeyPageUp):
move(cur.AddMonths(-1), -1, true)
case days.Shortcut(0, ui.KeyPageDown):
move(cur.AddMonths(1), 1, true)
case days.Shortcut(ui.Shift, ui.KeyPageUp):
move(cur.AddMonths(-12), -1, true)
case days.Shortcut(ui.Shift, ui.KeyPageDown):
move(cur.AddMonths(12), 1, true)
case days.Shortcut(0, ui.KeyHome):
move(Date{cur.Year, cur.Month, 1}, 1, true)
case days.Shortcut(0, ui.KeyEnd):
move(Date{cur.Year, cur.Month, 1}.AddMonths(1).AddDays(-1), -1, true)
default:
keysMoved = false
}
cur = *cursor
start := Date{cur.Year, cur.Month, 1}
start = start.AddDays(-((int(start.Weekday()) - int(first) + 7) % 7))
days.Children(func() {
for w := range 6 {
ui.Row(c).Role(ui.RoleRow).Children(func() {
for i := range 7 {
day := start.AddDays(w*7 + i)
clicked, hover := calendarDay(c, day, cur, cfg, cell, keysMoved, *keyed)
if clicked {
picked = day
move(day, 1, false)
}
if hover {
hovered = day
}
}
})
}
})
})
return picked, hovered
}
// calendarDay draws one day cell and reports whether it was picked or hovered.
func calendarDay(c *ui.Context, day, cur Date, cfg calendarConfig, size float32, focus, keyed bool) (picked, hovered bool) {
k := Tokens(c)
enabled := cfg.enabled(day)
chosen := cfg.chosen(day)
sel := Date{}
if chosen {
sel = day
}
cell := ui.RadioBase(c, &sel, day).Size(size, size-4).Center().Radius(theme.ControlRadius).
Label(datetimeDayLabel(c, day)).Disabled(!enabled).FocusRing(false)
if day == cur && enabled {
if focus {
cell.Focus()
} else if cfg.autoFocus {
cell.AutoFocus()
}
}
band := cfg.inRange != nil && cfg.inRange(day)
fg := k.Text
if day.Month != cur.Month {
fg = k.TextMuted
}
switch {
case chosen:
cell.Background(k.Accent)
fg = k.OnAccent
if cell.Hovered() {
cell.Background(k.AccentHover)
}
case band:
cell.Background(k.Selection).Radius(0)
case cell.Pressed():
cell.Background(k.Selection)
case cell.Hovered():
cell.Background(k.SurfaceHover)
}
ring := cell.FocusVisible() || cell.Focused() && keyed
cell.DrawOver(func(p *ui.Painter, r ui.Rect) {
if day == cfg.today {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H-5, 5), fg)
}
if ring {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
cell.Children(func() {
t := ui.Text(c, strconv.Itoa(day.Day)).TextColor(fg).FontFeatures("tnum")
if day == cfg.today {
t.Bold()
}
if !enabled {
t.Strikethrough()
}
})
return cell.Clicked() && enabled, cell.Hovered()
}
+165
View File
@@ -0,0 +1,165 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
var calendarToday = Date{2026, time.October, 5}
// datetimeTestLabel is the en-US spoken name of a day cell.
func datetimeTestLabel(d Date) string {
return fmt.Sprintf("%v, %v %d, %d", d.Weekday(), d.Month, d.Day, d.Year)
}
func calendarTester(value *Date, opts CalendarOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
if Calendar(c, value, opts).Changed() {
*changes++
}
}, 400, 400)
}
func TestCalendarClickChoosesDay(t *testing.T) {
v, n := Date{}, 0
tt := calendarTester(&v, CalendarOptions{}, &n)
if !tt.HasText("October 2026") {
t.Fatalf("empty value should show today's month, got %v", tt.Texts())
}
if err := tt.Click("Monday, October 12, 2026"); err != nil {
t.Fatal(err)
}
if v != (Date{2026, time.October, 12}) || n != 1 {
t.Fatalf("value %v changes %d", v, n)
}
if err := tt.Click("Monday, October 12, 2026"); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("same day reported change again: %d", n)
}
}
func TestCalendarKeyboard(t *testing.T) {
v, n := Date{2026, time.October, 15}, 0
tt := calendarTester(&v, CalendarOptions{}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Thursday, October 15, 2026") {
t.Fatal("Tab should land on the chosen day")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
if !tt.Focused("Friday, October 23, 2026") {
t.Fatal("arrows should move the focus by day and week")
}
tt.Key(0, ui.KeyPageDown)
if !tt.HasText("November 2026") || !tt.Focused("Monday, November 23, 2026") {
t.Fatalf("PageDown should move a month: %v", tt.Texts()[:3])
}
tt.Key(ui.Shift, ui.KeyPageUp)
if !tt.HasText("November 2025") {
t.Fatal("Shift+PageUp should move a year")
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEnter)
if v != (Date{2025, time.November, 30}) || n != 1 {
t.Fatalf("Enter chose %v (%d changes)", v, n)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeySpace)
if v != (Date{2025, time.November, 1}) || n != 2 {
t.Fatalf("Space chose %v", v)
}
}
func TestCalendarLimits(t *testing.T) {
v, n := Date{2026, time.October, 10}, 0
opts := CalendarOptions{
Min: Date{2026, time.October, 5},
Max: Date{2026, time.October, 20},
DisableDate: func(d Date) bool { return d.Weekday() == time.Sunday },
}
tt := calendarTester(&v, opts, &n)
for _, label := range []string{"Thursday, October 1, 2026", "Sunday, October 11, 2026", "Wednesday, October 21, 2026"} {
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
if v != (Date{2026, time.October, 10}) || n != 0 {
t.Fatalf("disabled days changed the value to %v", v)
}
if err := tt.Click("Saturday, October 10, 2026"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyRight)
if !tt.Focused("Monday, October 12, 2026") {
t.Fatal("arrows should skip the disabled Sunday")
}
}
func TestCalendarWeekStart(t *testing.T) {
for _, tc := range []struct {
ws WeekStart
loc Locale
first string
}{{WeekStartLocale, EnUS, "Sunday"}, {WeekStartLocale, ZhCN, "星期一"}, {WeekStartMonday, EnUS, "Monday"}, {WeekStartSaturday, EnUS, "Saturday"}} {
v := Date{2026, time.October, 5}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: tc.loc})
Calendar(c, &v, CalendarOptions{WeekStart: tc.ws, Today: calendarToday})
}, 400, 400)
texts := tt.Texts()
var first string
for i, s := range texts {
if s == "2026年10月" || s == "October 2026" {
first = texts[i+3]
break
}
}
if first != tc.first {
t.Errorf("%v %v: first column %q, want %q", tc.ws, tc.loc, first, tc.first)
}
}
}
func TestCalendarDisabled(t *testing.T) {
v, n := Date{2026, time.October, 10}, 0
tt := calendarTester(&v, CalendarOptions{Disabled: true}, &n)
_ = tt.Click("Monday, October 12, 2026")
_ = tt.Click("Next month")
if v != (Date{2026, time.October, 10}) || n != 0 || !tt.HasText("October 2026") {
t.Fatalf("disabled calendar changed: %v", v)
}
}
func TestCalendarRejectsInvalidValue(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic for February 30")
}
}()
v := Date{2026, time.February, 30}
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Calendar(c, &v, CalendarOptions{})
}, 400, 400)
}
func TestCalendarSnapshot(t *testing.T) {
v := Date{2026, time.October, 14}
snapshot(t, "calendar", 320, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
Calendar(c, &v, CalendarOptions{Today: calendarToday, Max: Date{2026, time.October, 28},
DisableDate: func(d Date) bool { return d.Day == 20 }})
})
})
}
+155
View File
@@ -0,0 +1,155 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
)
// DatePickerOptions configure a DatePicker.
type DatePickerOptions struct {
// Layout is the Go time layout typed and shown; empty uses 2006-01-02.
Layout string
// Min and Max bound the dates accepted; zero leaves a side open.
Min, Max Date
DisableDate func(Date) bool
WeekStart WeekStart
// Today is marked in the calendar; zero uses the system clock.
Today Date
// Clearable shows a clear button and accepts empty text as no date.
Clearable bool
// Placeholder shows in the empty field; empty uses the built-in copy.
Placeholder string
Disabled bool
// Label names the field for assistive technology.
Label string
}
// DatePicker is a typed date field with a calendar popup editing *value.
// Typed text commits on Enter or when the field loses focus; invalid text stays and is reported.
func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResult {
datetimeCheckDate("DatePicker value", *value)
datetimeCheckBounds("DatePicker", opts.Min, opts.Max)
layout := opts.Layout
if layout == "" {
layout = "2006-01-02"
}
format := func(d Date) string {
if d.IsZero() {
return ""
}
return d.Format(layout)
}
d := Density(c)
today := datetimeToday(c, opts.Today)
enabled := calendarEnabled(opts.Min, opts.Max, opts.DisableDate)
col := ui.Column(c).Width(d.ControlHeight() * 7).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
text := ui.Local(col, "date-picker-text", func() string { return format(*value) })
seen := ui.Local(col, "date-picker-seen", func() Date { return *value })
problem := ui.Local(col, "date-picker-error", func() string { return "" })
open := ui.Local(col, "date-picker-open", func() bool { return false })
cursor := ui.Local(col, "date-picker-cursor", func() Date { return Date{} })
wasFocused := ui.Local(col, "date-picker-focused", func() bool { return false })
if *seen != *value {
*seen, *text, *problem = *value, format(*value), ""
}
changed := false
commit := func(to Date, how string) {
if to != *value {
slog.Debug("mujica: date_picker change", "from", *value, "to", to, "by", how)
*value = to
changed = true
}
*seen, *text, *problem = to, format(to), ""
}
submit := func() {
s := strings.TrimSpace(*text)
if s == "" && opts.Clearable {
commit(Date{}, "text")
return
}
t, err := time.Parse(layout, s)
if err != nil {
*problem = fmt.Sprintf(msg(c, "date_picker.invalid"), format(today))
slog.Debug("mujica: date_picker invalid", "text", s)
return
}
if p := datePickerProblem(c, DateOf(t), opts.Min, opts.Max, enabled, format); p != "" {
*problem = p
return
}
commit(DateOf(t), "text")
}
openAt := func() {
*open = true
*cursor = *value
if cursor.IsZero() {
*cursor = today
}
}
placeholder := opts.Placeholder
if placeholder == "" {
placeholder = msg(c, "date_picker.placeholder")
}
var field *ui.Element
col.Children(func() {
field = datetimeField(c, ui.Row(c), *problem != "")
field.Children(func() {
in := ui.TextInputBase(c, text).Grow(1).Shrink(1).Placeholder(placeholder).FontFeatures("tnum")
label := opts.Label
if label == "" {
label = placeholder
}
in.Label(label).Error(*problem)
focused := in.Focused()
if in.Submitted() || *wasFocused && !focused && *text != format(*value) {
submit()
}
*wasFocused = focused
if opts.Clearable && !value.IsZero() && datetimeIconButton(c, "x", msg(c, "calendar.clear")).Clicked() {
commit(Date{}, "clear")
}
if datetimeIconButton(c, "calendar", msg(c, "date_picker.open")).Clicked() {
if *open {
*open = false
} else {
openAt()
}
}
})
if !*open && (field.Shortcut(0, ui.KeyDown) || field.Shortcut(ui.Alt, ui.KeyDown)) {
openAt()
}
datetimePopup(c, field, open, func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(x Date) bool { return x == *value },
enabled: enabled,
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if !picked.IsZero() {
commit(picked, "calendar")
*open = false
}
})
datetimeError(c, *problem)
})
return DateTimeResult{Element: col, changed: changed}
}
// datePickerProblem explains why an existing day d cannot be chosen, or returns "".
func datePickerProblem(c *ui.Context, d Date, lo, hi Date, enabled func(Date) bool, format func(Date) string) string {
switch {
case !lo.IsZero() && d.Compare(lo) < 0:
return fmt.Sprintf(msg(c, "calendar.before_min"), format(lo))
case !hi.IsZero() && d.Compare(hi) > 0:
return fmt.Sprintf(msg(c, "calendar.after_max"), format(hi))
case !enabled(d):
return fmt.Sprintf(msg(c, "calendar.unavailable"), format(d))
}
return ""
}
+152
View File
@@ -0,0 +1,152 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func datePickerTester(value *Date, opts DatePickerOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Gap(8).Children(func() {
if DatePicker(c, value, opts).Changed() {
*changes++
}
Button(c, "Elsewhere", ButtonOptions{})
})
}, 420, 460)
}
func datePickerType(tt *ui.Tester, label, text string) {
_ = tt.Click(label)
tt.Command("selectAll")
tt.Type(text)
}
func TestDatePickerTyping(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due"}, &n)
datePickerType(tt, "Due", "2026-11-03")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.November, 3}) || n != 1 {
t.Fatalf("Enter committed %v (%d)", v, n)
}
datePickerType(tt, "Due", "2026-12-24")
if err := tt.Click("Elsewhere"); err != nil {
t.Fatal(err)
}
if v != (Date{2026, time.December, 24}) || n != 2 {
t.Fatalf("blur committed %v (%d)", v, n)
}
}
func TestDatePickerInvalidInputKept(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Max: Date{2026, time.December, 31}}, &n)
datePickerType(tt, "Due", "2026-02-30")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.October, 5}) || n != 0 {
t.Fatalf("invalid day was corrected into %v", v)
}
if !tt.HasText("Enter a date like 2026-10-05.") {
t.Fatalf("expected an error: %v", tt.Texts())
}
// The typed text stays: fixing its day makes it valid.
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace)
tt.Type("28")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.February, 28}) || n != 1 || tt.HasText("Enter a date like") {
t.Fatalf("edited text committed %v (%d)", v, n)
}
datePickerType(tt, "Due", "2027-01-02")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.February, 28}) || !tt.HasText("Choose 2026-12-31 or earlier.") {
t.Fatalf("out of range accepted: %v %v", v, tt.Texts())
}
datePickerType(tt, "Due", "")
tt.Key(0, ui.KeyEnter)
if v.IsZero() {
t.Fatal("empty text cleared a non-clearable picker")
}
}
func TestDatePickerCalendarPopup(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due"}, &n)
if err := tt.Click("Open calendar"); err != nil {
t.Fatal(err)
}
if !tt.Focused("Monday, October 5, 2026") {
t.Fatal("the popup should focus the chosen day")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.October, 6}) || n != 1 || tt.HasText("October 2026") {
t.Fatalf("keyboard pick %v, popup open %v", v, tt.HasText("October 2026"))
}
_ = tt.Click("Due")
tt.Key(0, ui.KeyDown)
if !tt.HasText("October 2026") {
t.Fatal("Down should open the calendar")
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("October 2026") || v != (Date{2026, time.October, 6}) {
t.Fatal("Escape should close without change")
}
_ = tt.Click("Open calendar")
if err := tt.Click("Thursday, October 22, 2026"); err != nil {
t.Fatal(err)
}
if v != (Date{2026, time.October, 22}) || n != 2 {
t.Fatalf("click pick %v", v)
}
}
func TestDatePickerClearAndLayout(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Clearable: true, Layout: "02/01/2006"}, &n)
_ = tt.Click("Due")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.October, 5}) || n != 0 || tt.HasText("Enter a date like") {
t.Fatalf("shown text did not parse back with the layout: %v", tt.Texts())
}
if err := tt.Click("Clear"); err != nil {
t.Fatal(err)
}
if !v.IsZero() || n != 1 {
t.Fatalf("clear left %v", v)
}
datePickerType(tt, "Due", "31/12/2026")
tt.Key(0, ui.KeyEnter)
if v != (Date{2026, time.December, 31}) {
t.Fatalf("custom layout parsed %v", v)
}
}
func TestDatePickerDisabled(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Disabled: true}, &n)
_ = tt.Click("Open calendar")
if tt.HasText("October 2026") {
t.Fatal("disabled picker opened")
}
}
func TestDatePickerSnapshot(t *testing.T) {
a, b, e := Date{2026, time.October, 14}, Date{}, Date{2026, time.October, 5}
snapshot(t, "date_picker", 640, 130, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).AlignItems(ui.Start).Children(func() {
ui.Column(c).Gap(12).Width(260).Children(func() {
DatePicker(c, &b, DatePickerOptions{Today: calendarToday})
DatePicker(c, &e, DatePickerOptions{Today: calendarToday, Disabled: true})
})
ui.Column(c).Width(300).Children(func() {
DatePicker(c, &a, DatePickerOptions{Today: calendarToday, Clearable: true, Label: "Due"})
})
})
})
}
+174
View File
@@ -0,0 +1,174 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// DateRange is an inclusive span of days. The zero value means no range.
type DateRange struct {
Start, End Date
}
// IsZero reports whether r is the empty range.
func (r DateRange) IsZero() bool { return r == DateRange{} }
// Days returns how many days r covers, counting both ends.
func (r DateRange) Days() int {
if r.Start.IsZero() || r.End.IsZero() {
return 0
}
return int(r.End.civil().Sub(r.Start.civil()).Hours()/24) + 1
}
// DateRangePickerOptions configure a DateRangePicker.
type DateRangePickerOptions struct {
// Layout is the Go time layout shown; empty uses 2006-01-02.
Layout string
// Min and Max bound both ends; zero leaves a side open.
Min, Max Date
// DisableDate marks days that neither end nor the span may include.
DisableDate func(Date) bool
WeekStart WeekStart
// Today is marked in the calendar; zero uses the system clock.
Today Date
Disabled bool
// Label names the field for assistive technology.
Label string
}
// DateRangePicker edits *value in a popup: pick a start, preview the span, pick an end, then confirm.
// Clear empties the value; Escape, Cancel or a click outside discards the draft.
func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOptions) DateTimeResult {
datetimeCheckDate("DateRangePicker Start", value.Start)
datetimeCheckDate("DateRangePicker End", value.End)
if value.Start.IsZero() != value.End.IsZero() || !value.IsZero() && value.End.Compare(value.Start) < 0 {
panic(fmt.Sprintf("mujica: DateRangePicker value %v..%v is not a range", value.Start, value.End))
}
datetimeCheckBounds("DateRangePicker", opts.Min, opts.Max)
layout := opts.Layout
if layout == "" {
layout = "2006-01-02"
}
d := Density(c)
k := Tokens(c)
today := datetimeToday(c, opts.Today)
enabled := calendarEnabled(opts.Min, opts.Max, opts.DisableDate)
col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
open := ui.Local(col, "date-range-open", func() bool { return false })
wasOpen := ui.Local(col, "date-range-was-open", func() bool { return false })
draft := ui.Local(col, "date-range-draft", func() DateRange { return DateRange{} })
cursor := ui.Local(col, "date-range-cursor", func() Date { return Date{} })
hover := ui.Local(col, "date-range-hover", func() Date { return Date{} })
problem := ui.Local(col, "date-range-error", func() string { return "" })
changed, cancelled, done := false, false, false
finish := func(to DateRange, how string) {
if to != *value {
slog.Debug("mujica: date_range_picker change", "from", *value, "to", to, "by", how)
*value = to
changed = true
}
*open, done = false, true
}
label := opts.Label
if label == "" {
label = msg(c, "date_range_picker.label")
}
text := ""
if !value.IsZero() {
text = value.Start.Format(layout) + " – " + value.End.Format(layout)
}
var field *ui.Element
col.Children(func() {
field = datetimeField(c, ui.Row(c), false)
field.Children(func() {
t := datetimeTrigger(c, text, msg(c, "date_range_picker.placeholder"), "calendar").Label(label).Description(text)
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
*open = !*open
if *open {
*draft, *problem, *hover = *value, "", Date{}
*cursor = value.Start
if cursor.IsZero() {
*cursor = today
}
slog.Debug("mujica: date_range_picker open")
}
}
})
datetimePopup(c, field, open, func() {
ui.Text(c, dateRangeSummary(c, *draft, layout)).TextColor(k.TextMuted).
FontSize(theme.CaptionSize).FontFeatures("tnum").Role(ui.RoleStatus)
end := draft.End
if end.IsZero() && !draft.Start.IsZero() {
end = *hover
if end.IsZero() {
end = *cursor
}
}
picked, hovered := calendarMonth(c, cursor, calendarConfig{
chosen: func(x Date) bool { return x == draft.Start || x == draft.End },
inRange: func(x Date) bool { return !draft.Start.IsZero() && x.within(draft.Start, end) },
enabled: enabled,
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if hovered != *hover {
*hover = hovered
c.Invalidate()
}
if !picked.IsZero() {
*problem = dateRangePick(c, draft, picked, enabled, layout)
}
datetimeError(c, *problem)
datetimeFooter(c, func() {
if Button(c, msg(c, "calendar.clear"), ButtonOptions{Variant: Ghost, Disabled: value.IsZero() && draft.IsZero()}).Clicked() {
finish(DateRange{}, "clear")
}
if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
*open = false
}
ready := !draft.Start.IsZero() && !draft.End.IsZero()
if Button(c, msg(c, "calendar.confirm"), ButtonOptions{Disabled: !ready}).Clicked() {
finish(*draft, "confirm")
}
})
})
})
if *wasOpen && !*open && !done {
slog.Debug("mujica: date_range_picker cancel")
cancelled = true
}
*wasOpen = *open
return DateTimeResult{Element: col, changed: changed, cancelled: cancelled}
}
// dateRangePick applies a picked day to the draft and returns any problem.
func dateRangePick(c *ui.Context, draft *DateRange, day Date, enabled func(Date) bool, layout string) string {
if draft.Start.IsZero() || !draft.End.IsZero() || day.Compare(draft.Start) < 0 {
*draft = DateRange{Start: day}
return ""
}
for x := draft.Start; x.Compare(day) <= 0; x = x.AddDays(1) {
if !enabled(x) {
return fmt.Sprintf(msg(c, "date_range_picker.blocked"), x.Format(layout))
}
}
draft.End = day
return ""
}
// dateRangeSummary describes the draft: what to pick next, or the span chosen.
func dateRangeSummary(c *ui.Context, r DateRange, layout string) string {
switch {
case r.Start.IsZero():
return msg(c, "date_range_picker.pick_start")
case r.End.IsZero():
return fmt.Sprintf(msg(c, "date_range_picker.pick_end"), r.Start.Format(layout))
}
return fmt.Sprintf(msg(c, "date_range_picker.summary"), r.Start.Format(layout), r.End.Format(layout), r.Days())
}
+138
View File
@@ -0,0 +1,138 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func dateRangeTester(value *DateRange, opts DateRangePickerOptions, changes, cancels *int) *ui.Tester {
opts.Today = calendarToday
opts.Label = "Stay"
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Children(func() {
r := DateRangePicker(c, value, opts)
if r.Changed() {
*changes++
}
if r.Cancelled() {
*cancels++
}
})
}, 420, 520)
}
func oct(day int) Date { return Date{2026, time.October, day} }
func TestDateRangePickerPickAndConfirm(t *testing.T) {
v, n, x := DateRange{}, 0, 0
tt := dateRangeTester(&v, DateRangePickerOptions{}, &n, &x)
if err := tt.Click("Stay"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Choose the start date.") {
t.Fatalf("popup missing: %v", tt.Texts())
}
_ = tt.Click(datetimeTestLabel(oct(12)))
if !tt.HasText("From 2026-10-12, choose the end date.") {
t.Fatal("start not taken")
}
_ = tt.Click(datetimeTestLabel(oct(8)))
if !tt.HasText("From 2026-10-08, choose the end date.") {
t.Fatal("a day before the start should restart the range")
}
_ = tt.Click(datetimeTestLabel(oct(14)))
if !tt.HasText("2026-10-08 – 2026-10-14 · 7 days") || n != 0 {
t.Fatalf("draft should wait for Confirm: %v %d", v, n)
}
if err := tt.Click("Confirm"); err != nil {
t.Fatal(err)
}
if v != (DateRange{oct(8), oct(14)}) || n != 1 || x != 0 || tt.HasText("Confirm") {
t.Fatalf("confirm gave %v, n=%d x=%d", v, n, x)
}
}
func TestDateRangePickerCancelKeepsValue(t *testing.T) {
v, n, x := DateRange{oct(1), oct(3)}, 0, 0
tt := dateRangeTester(&v, DateRangePickerOptions{}, &n, &x)
_ = tt.Click("Stay")
_ = tt.Click(datetimeTestLabel(oct(20)))
tt.Key(0, ui.KeyEscape)
if v != (DateRange{oct(1), oct(3)}) || n != 0 || x != 1 {
t.Fatalf("Escape: %v n=%d x=%d", v, n, x)
}
_ = tt.Click("Stay")
_ = tt.Click("Cancel")
if x != 2 || tt.HasText("Confirm") {
t.Fatalf("Cancel: x=%d", x)
}
_ = tt.Click("Stay")
_ = tt.Click("Clear")
if !v.IsZero() || n != 1 || x != 2 {
t.Fatalf("Clear: %v n=%d x=%d", v, n, x)
}
}
func TestDateRangePickerLimits(t *testing.T) {
v, n, x := DateRange{}, 0, 0
opts := DateRangePickerOptions{Min: oct(5), DisableDate: func(d Date) bool { return d == oct(16) }}
tt := dateRangeTester(&v, opts, &n, &x)
_ = tt.Click("Stay")
_ = tt.Click(datetimeTestLabel(oct(2)))
if !tt.HasText("Choose the start date.") {
t.Fatal("a day before Min was taken")
}
_ = tt.Click(datetimeTestLabel(oct(14)))
_ = tt.Click(datetimeTestLabel(oct(18)))
if !tt.HasText("The range cannot include 2026-10-16.") {
t.Fatalf("span over a disabled day accepted: %v", tt.Texts())
}
_ = tt.Click("Confirm")
if n != 0 || !v.IsZero() {
t.Fatal("confirm without an end changed the value")
}
_ = tt.Click(datetimeTestLabel(oct(15)))
_ = tt.Click("Confirm")
if v != (DateRange{oct(14), oct(15)}) {
t.Fatalf("got %v", v)
}
}
func TestDateRangePickerKeyboard(t *testing.T) {
v, n, x := DateRange{}, 0, 0
tt := dateRangeTester(&v, DateRangePickerOptions{}, &n, &x)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused(datetimeTestLabel(calendarToday)) {
t.Fatal("the popup should focus today")
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
if !tt.HasText("From 2026-10-05, choose the end date.") {
t.Fatal("Enter should pick the start")
}
tt.Key(0, ui.KeySpace)
if !tt.HasText("2026-10-05 – 2026-10-12 · 8 days") {
t.Fatalf("Space should pick the end: %v", tt.Texts())
}
}
func TestDateRangeDays(t *testing.T) {
r := DateRange{Date{2026, time.March, 28}, Date{2026, time.April, 2}}
if r.Days() != 6 || (DateRange{}).Days() != 0 {
t.Fatalf("days %d", r.Days())
}
}
func TestDateRangePickerSnapshot(t *testing.T) {
a, b := DateRange{oct(8), oct(14)}, DateRange{}
snapshot(t, "date_range_picker", 640, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
DateRangePicker(c, &b, DateRangePickerOptions{Today: calendarToday})
DateRangePicker(c, &a, DateRangePickerOptions{Today: calendarToday})
})
})
}
+181
View File
@@ -0,0 +1,181 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// DateTimePickerOptions configure a DateTimePicker.
type DateTimePickerOptions struct {
// Location is the time zone the user edits in. Required.
Location *time.Location
// Min and Max bound the instant; zero leaves a side open.
Min, Max time.Time
Seconds bool
Hour12 bool
MinuteStep int
WeekStart WeekStart
// Today is marked in the calendar; zero uses the system clock.
Today Date
Disabled bool
// Label names the field for assistive technology.
Label string
}
// DateTimePicker edits *value as a date and a wall-clock time in opts.Location, confirmed together.
// Times a DST change skips or repeats are reported, never shifted.
func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) DateTimeResult {
loc := opts.Location
if loc == nil {
panic("mujica: DateTimePicker needs a Location")
}
if !opts.Min.IsZero() && !opts.Max.IsZero() && opts.Min.After(opts.Max) {
panic(fmt.Sprintf("mujica: DateTimePicker Min %v is after Max %v", opts.Min, opts.Max))
}
cfg := timePickerConfig("DateTimePicker", TimePickerOptions{Seconds: opts.Seconds, Hour12: opts.Hour12, MinuteStep: opts.MinuteStep})
cfg.label = msg(c, "date_time_picker.time")
k := Tokens(c)
d := Density(c)
today := datetimeToday(c, opts.Today)
var lo, hi Date
if !opts.Min.IsZero() {
lo = DateOf(opts.Min.In(loc))
}
if !opts.Max.IsZero() {
hi = DateOf(opts.Max.In(loc))
}
col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
open := ui.Local(col, "date-time-open", func() bool { return false })
wasOpen := ui.Local(col, "date-time-was-open", func() bool { return false })
day := ui.Local(col, "date-time-day", func() Date { return Date{} })
clock := ui.Local(col, "date-time-clock", func() TimeOfDay { return TimeOfDay{} })
cursor := ui.Local(col, "date-time-cursor", func() Date { return Date{} })
problem := ui.Local(col, "date-time-error", func() string { return "" })
format := func(t time.Time) string { return dateTimeText(c, t.In(loc), cfg) }
changed, cancelled, done := false, false, false
label := opts.Label
if label == "" {
label = msg(c, "date_time_picker.placeholder")
}
text := ""
if !value.IsZero() {
text = format(*value)
}
col.Children(func() {
field := datetimeField(c, ui.Row(c), false)
field.Children(func() {
t := datetimeTrigger(c, text, msg(c, "date_time_picker.placeholder"), "calendar").Label(label).Description(text)
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
*open = !*open
if *open {
*day, *clock, *problem = today, TimeOfDay{}, ""
if !value.IsZero() {
w := value.In(loc)
*day, *clock = DateOf(w), TimeOfDay{w.Hour(), w.Minute(), w.Second()}
}
*cursor = *day
slog.Debug("mujica: date_time_picker open")
}
}
})
datetimePopup(c, field, open, func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(x Date) bool { return x == *day },
enabled: calendarEnabled(lo, hi, nil),
today: today,
weekStart: opts.WeekStart,
autoFocus: true,
})
if !picked.IsZero() {
*day, *problem = picked, ""
}
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
if _, edited := timeEditor(c, clock, cfg); edited {
*problem = ""
}
ui.Text(c, loc.String()).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
})
datetimeError(c, *problem)
datetimeFooter(c, func() {
if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
*open = false
}
if Button(c, msg(c, "calendar.confirm"), ButtonOptions{}).Clicked() {
at, p := dateTimeConfirm(c, *day, *clock, loc, opts, cfg)
*problem = p
if p == "" {
if !at.Equal(*value) {
slog.Debug("mujica: date_time_picker change", "from", *value, "to", at)
*value, changed = at, true
}
*open, done = false, true
}
}
})
})
})
if *wasOpen && !*open && !done {
slog.Debug("mujica: date_time_picker cancel")
cancelled = true
}
*wasOpen = *open
return DateTimeResult{Element: col, changed: changed, cancelled: cancelled}
}
// dateTimeConfirm turns the draft into an instant, or explains why it cannot.
func dateTimeConfirm(c *ui.Context, d Date, t TimeOfDay, loc *time.Location, opts DateTimePickerOptions, cfg timeConfig) (time.Time, string) {
if p := timePickerProblem(c, t, cfg); p != "" {
return time.Time{}, p
}
wall := fmt.Sprintf("%v %s", d, timeText(c, t, cfg))
at, n := dateTimeResolve(d, t, loc)
switch {
case n == 0:
slog.Debug("mujica: date_time_picker gap", "wall", wall, "zone", loc.String())
return at, fmt.Sprintf(msg(c, "date_time_picker.gap"), wall, loc.String())
case n > 1:
slog.Debug("mujica: date_time_picker ambiguous", "wall", wall, "zone", loc.String())
return at, fmt.Sprintf(msg(c, "date_time_picker.ambiguous"), wall, loc.String())
case !opts.Min.IsZero() && at.Before(opts.Min):
return at, fmt.Sprintf(msg(c, "calendar.before_min"), dateTimeText(c, opts.Min.In(loc), cfg))
case !opts.Max.IsZero() && at.After(opts.Max):
return at, fmt.Sprintf(msg(c, "calendar.after_max"), dateTimeText(c, opts.Max.In(loc), cfg))
}
return at, ""
}
// dateTimeResolve finds the instants showing wall time d t in loc: none in a DST gap, two in an overlap.
func dateTimeResolve(d Date, t TimeOfDay, loc *time.Location) (time.Time, int) {
wall := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, time.UTC)
guess := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, loc)
var hits []time.Time
for _, probe := range []time.Time{guess.Add(-24 * time.Hour), guess, guess.Add(24 * time.Hour)} {
_, off := probe.Zone()
u := wall.Add(-time.Duration(off) * time.Second).In(loc)
y, m, dd := u.Date()
if (Date{y, m, dd}) != d || u.Hour() != t.Hour || u.Minute() != t.Minute || u.Second() != t.Second {
continue
}
seen := false
for _, h := range hits {
seen = seen || h.Equal(u)
}
if !seen {
hits = append(hits, u)
}
}
if len(hits) == 0 {
return time.Time{}, 0
}
return hits[0], len(hits)
}
// dateTimeText formats a wall time with the picker's clock style.
func dateTimeText(c *ui.Context, w time.Time, cfg timeConfig) string {
return DateOf(w).String() + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), w.Second()}, cfg) + " " + w.Format("MST")
}
+152
View File
@@ -0,0 +1,152 @@
package mujica
import (
"testing"
"time"
_ "time/tzdata"
"github.com/egoist/mygo/ui"
)
func dateTimeTester(t *testing.T, value *time.Time, opts DateTimePickerOptions, changes, cancels *int) *ui.Tester {
t.Helper()
opts.Today = calendarToday
opts.Label = "Meeting"
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Children(func() {
r := DateTimePicker(c, value, opts)
if r.Changed() {
*changes++
}
if r.Cancelled() {
*cancels++
}
})
}, 480, 640)
}
func newYork(t *testing.T) *time.Location {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
return loc
}
func TestDateTimeResolve(t *testing.T) {
ny := newYork(t)
if _, n := dateTimeResolve(Date{2026, time.March, 8}, TimeOfDay{2, 30, 0}, ny); n != 0 {
t.Fatalf("spring-forward gap gave %d instants", n)
}
if _, n := dateTimeResolve(Date{2026, time.November, 1}, TimeOfDay{1, 30, 0}, ny); n != 2 {
t.Fatalf("fall-back overlap gave %d instants", n)
}
at, n := dateTimeResolve(Date{2026, time.July, 4}, TimeOfDay{12, 0, 0}, ny)
if n != 1 || !at.Equal(time.Date(2026, time.July, 4, 16, 0, 0, 0, time.UTC)) {
t.Fatalf("plain time %v (%d)", at, n)
}
}
func TestDateTimePickerConfirmInLocation(t *testing.T) {
tokyo, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
t.Fatal(err)
}
v := time.Date(2026, time.October, 5, 23, 30, 0, 0, time.UTC) // 08:30 on Oct 6 in Tokyo
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: tokyo}, &n, &x)
if !tt.HasText("2026-10-06 08:30 JST") {
t.Fatalf("value should show in the caller's zone: %v", tt.Texts())
}
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(9)))
_ = tt.Click("Time: Hours")
tt.Key(0, ui.KeyUp)
if n != 0 {
t.Fatal("the draft should wait for Confirm")
}
_ = tt.Click("Confirm")
want := time.Date(2026, time.October, 9, 9, 30, 0, 0, tokyo)
if !v.Equal(want) || n != 1 || x != 0 || v.Location() != tokyo {
t.Fatalf("confirmed %v, want %v", v, want)
}
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(20)))
_ = tt.Click("Cancel")
if !v.Equal(want) || x != 1 {
t.Fatalf("cancel changed %v", v)
}
}
func TestDateTimePickerReportsDSTGap(t *testing.T) {
ny := newYork(t)
v := time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: ny, Today: Date{2026, time.March, 1}}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click("Sunday, March 8, 2026")
_ = tt.Click("Confirm")
if n != 0 || !v.Equal(time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)) {
t.Fatalf("gap time was shifted into %v", v)
}
if !tt.HasText("2026-03-08 02:30 does not exist in America/New_York: the clocks skip it.") {
t.Fatalf("missing gap error: %v", tt.Texts())
}
_ = tt.Click("Time: Hours")
tt.Key(0, ui.KeyUp)
_ = tt.Click("Confirm")
if n != 1 || !v.Equal(time.Date(2026, time.March, 8, 3, 30, 0, 0, ny)) {
t.Fatalf("after fixing: %v", v)
}
}
func TestDateTimePickerReportsAmbiguous(t *testing.T) {
ny := newYork(t)
v := time.Date(2026, time.October, 31, 1, 30, 0, 0, ny)
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: ny}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click("Sunday, November 1, 2026")
_ = tt.Click("Confirm")
if n != 0 || !tt.HasText("2026-11-01 01:30 happens twice in America/New_York: the clocks repeat it.") {
t.Fatalf("ambiguous time accepted: %v %v", v, tt.Texts())
}
}
func TestDateTimePickerRange(t *testing.T) {
v := time.Time{}
n, x := 0, 0
maxT := time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC)
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: time.UTC, Max: maxT}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(11)))
_ = tt.Click(datetimeTestLabel(oct(10)))
_ = tt.Click("Time: Hours")
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key3)
_ = tt.Click("Confirm")
if n != 0 || !v.IsZero() || !tt.HasText("Choose 2026-10-10 12:00 UTC or earlier.") {
t.Fatalf("past Max accepted: %v %v", v, tt.Texts())
}
}
func TestDateTimePickerNeedsLocation(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic without Location")
}
}()
v := time.Time{}
dateTimeTester(t, &v, DateTimePickerOptions{}, new(int), new(int))
}
func TestDateTimePickerSnapshot(t *testing.T) {
a, b := time.Date(2026, time.October, 14, 18, 45, 0, 0, time.UTC), time.Time{}
snapshot(t, "date_time_picker", 720, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
DateTimePicker(c, &b, DateTimePickerOptions{Location: time.UTC, Today: calendarToday})
DateTimePicker(c, &a, DateTimePickerOptions{Location: time.UTC, Today: calendarToday, Hour12: true})
})
})
}
+201
View File
@@ -0,0 +1,201 @@
package mujica
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Date is a calendar day with no time of day and no location.
// The zero value means no date.
type Date struct {
Year int
Month time.Month
Day int
}
// DateOf returns the day t falls on in t's own location, without converting it.
func DateOf(t time.Time) Date {
y, m, d := t.Date()
return Date{y, m, d}
}
// IsZero reports whether d is the empty date.
func (d Date) IsZero() bool { return d == Date{} }
// Valid reports whether d names a real day.
func (d Date) Valid() bool { return !d.IsZero() && DateOf(d.civil()) == d }
// String formats d as 2006-01-02, or "" when empty.
func (d Date) String() string {
if d.IsZero() {
return ""
}
return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day)
}
// Format formats d with a Go time layout.
func (d Date) Format(layout string) string { return d.civil().Format(layout) }
// Compare returns -1, 0 or 1 as d is before, equal to or after o.
func (d Date) Compare(o Date) int { return d.civil().Compare(o.civil()) }
// AddDays returns d moved by n days.
func (d Date) AddDays(n int) Date { return DateOf(d.civil().AddDate(0, 0, n)) }
// AddMonths returns d moved by n months, keeping the day within the month.
func (d Date) AddMonths(n int) Date {
first := time.Date(d.Year, d.Month+time.Month(n), 1, 0, 0, 0, 0, time.UTC)
last := first.AddDate(0, 1, -1).Day()
return Date{first.Year(), first.Month(), min(d.Day, last)}
}
// Weekday returns the day of the week of d.
func (d Date) Weekday() time.Weekday { return d.civil().Weekday() }
// civil is d at midnight on a fixed clock, only for arithmetic.
func (d Date) civil() time.Time { return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, time.UTC) }
// within reports whether d lies inside the optional bounds lo and hi.
func (d Date) within(lo, hi Date) bool {
return (lo.IsZero() || d.Compare(lo) >= 0) && (hi.IsZero() || d.Compare(hi) <= 0)
}
// DateTimeResult is a date or time component and what happened to it this frame.
type DateTimeResult struct {
*ui.Element
changed bool
cancelled bool
}
// Changed reports whether the component wrote a new value this frame.
func (r DateTimeResult) Changed() bool { return r.changed }
// Cancelled reports whether the user discarded a pending edit this frame.
func (r DateTimeResult) Cancelled() bool { return r.cancelled }
// datetimeCheckDate panics when a non-empty date is not a real day.
func datetimeCheckDate(name string, d Date) {
if !d.IsZero() && !d.Valid() {
panic(fmt.Sprintf("mujica: %s %v is not a calendar date", name, d))
}
}
// datetimeCheckBounds panics on invalid or inverted Min and Max.
func datetimeCheckBounds(name string, lo, hi Date) {
datetimeCheckDate(name+" Min", lo)
datetimeCheckDate(name+" Max", hi)
if !lo.IsZero() && !hi.IsZero() && lo.Compare(hi) > 0 {
panic(fmt.Sprintf("mujica: %s Min %v is after Max %v", name, lo, hi))
}
}
// datetimeToday returns today, or the caller's fixed day when set.
func datetimeToday(c *ui.Context, fixed Date) Date {
if !fixed.IsZero() {
datetimeCheckDate("Today", fixed)
return fixed
}
return DateOf(c.Now())
}
// datetimeMonthName returns the localized name of m.
func datetimeMonthName(c *ui.Context, m time.Month) string {
return msg(c, fmt.Sprintf("calendar.month_%d", int(m)))
}
// datetimeMonthTitle returns the localized title of a month, as "October 2026".
func datetimeMonthTitle(c *ui.Context, y int, m time.Month) string {
return fmt.Sprintf(msg(c, "calendar.title"), datetimeMonthName(c, m), y)
}
// datetimeDayLabel returns the localized spoken name of d.
func datetimeDayLabel(c *ui.Context, d Date) string {
wd := msg(c, fmt.Sprintf("calendar.weekday_long_%d", int(d.Weekday())))
return fmt.Sprintf(msg(c, "calendar.day_label"), wd, datetimeMonthName(c, d.Month), d.Day, d.Year)
}
// datetimeIconButton is a square, borderless button showing icon, named label.
func datetimeIconButton(c *ui.Context, icon, label string) *ui.Element {
k := Tokens(c)
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case b.Pressed():
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover)
}
b.TextColor(k.TextMuted).Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) })
return b
}
// datetimeField styles row as a single-line field: solid fill and fine border.
func datetimeField(c *ui.Context, row *ui.Element, invalid bool) *ui.Element {
k := Tokens(c)
d := Density(c)
border := k.ControlBorder
switch {
case invalid:
border = k.Danger
case row.FocusWithin():
border = k.Focus
case row.Hovered():
border = k.Text
}
return row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()).PaddingX(d.Unit()*3).Gap(d.Unit()).
AlignItems(ui.Center).Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, border)
}
// datetimeTrigger is the button inside a field that shows text and opens a popup.
func datetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element {
k := Tokens(c)
b := ui.ButtonBase(c).Grow(1).Shrink(1).Justify(ui.Start).Gap(Density(c).Unit() * 2).
MinHeight(theme.MinHitSize).Radius(theme.ControlRadius)
internal.Ring(b, k.Focus, theme.ControlRadius)
b.Children(func() {
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(k.TextMuted)
if text == "" {
ui.Text(c, placeholder).TextColor(k.TextMuted).SingleLine()
} else {
ui.Text(c, text).TextColor(k.Text).SingleLine().FontFeatures("tnum")
}
})
return b
}
// datetimePopup shows fn in a popup panel below anchor while *open is set.
func datetimePopup(c *ui.Context, anchor *ui.Element, open *bool, fn func()) *ui.Element {
k := Tokens(c)
d := Density(c)
return ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
born := ui.Local(panel, "datetime-born", func() bool { return true })
if *born {
panel.Animate("datetime-fade", 0, 0)
*born = false
}
fade := panel.Animate("datetime-fade", 1, Motion(c, theme.PopupDuration))
panel.MaxWidth(d.ControlHeight()*7+d.Unit()*6+2*theme.BorderWidth).Margin(d.Unit(), 0, 0, 0).Padding(d.Unit()*3).Gap(d.Unit()*3).Radius(theme.PopupRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border).
Shadow(0, 6, 18, 0, ui.RGBA(0, 0, 0, 0.16)).Opacity(fade)
fn()
})
}
// datetimeError shows a field error in Danger beneath a control.
func datetimeError(c *ui.Context, text string) {
if text == "" {
return
}
ui.Text(c, text).FontSize(theme.CaptionSize).TextColor(Tokens(c).Danger).Role(ui.RoleStatus)
}
// datetimeFooter lays out popup actions at the end of a row.
func datetimeFooter(c *ui.Context, fn func()) {
ui.Row(c).Gap(Density(c).Unit() * 2).Justify(ui.End).AlignItems(ui.Center).FillWidth().Children(fn)
}
+182
View File
@@ -0,0 +1,182 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// datetimeGridConfig describes a page of month or year cells, addressed by ordinal.
type datetimeGridConfig struct {
// pageOf returns the first ordinal and count of the page holding o.
pageOf func(o int) (start, n int)
cols int
page int
title func(start int) string
prev string
next string
text func(o int) string
label func(o int) string
chosen func(o int) bool
current func(o int) bool
enabled func(o int) bool
// muted greys cells of the page that belong to a neighbor page.
muted func(o, start int) bool
autoFocus bool
}
// datetimeGrid draws the page of *cursor and returns the ordinal picked, if any.
func datetimeGrid(c *ui.Context, cursor *int, cfg datetimeGridConfig) (picked int, ok bool) {
k := Tokens(c)
d := Density(c)
root := ui.Column(c).Gap(d.Unit() * 2).Shrink(0).Width(d.ControlHeight() * 7)
keyed := ui.Local(root, "datetime-grid-keys", func() bool { return false })
root.Children(func() {
start, _ := cfg.pageOf(*cursor)
ui.Row(c).AlignItems(ui.Center).Gap(d.Unit()).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).
PaddingY(d.Unit()).Children(func() {
if datetimeIconButton(c, "chevron-left", cfg.prev).Clicked() {
*cursor, *keyed = *cursor-cfg.page, false
}
ui.Text(c, cfg.title(start)).Font(theme.SerifFont).FontSize(theme.BodySize + 2).Bold().
TextColor(k.Text).Grow(1).TextAlign(ui.Center).SingleLine()
if datetimeIconButton(c, "chevron-right", cfg.next).Clicked() {
*cursor, *keyed = *cursor+cfg.page, false
}
})
cells := ui.Column(c).Gap(d.Unit()).FocusGroup(ui.Horizontal | ui.Vertical)
moved := true
switch {
case cells.Shortcut(0, ui.KeyLeft):
*cursor--
case cells.Shortcut(0, ui.KeyRight):
*cursor++
case cells.Shortcut(0, ui.KeyUp):
*cursor -= cfg.cols
case cells.Shortcut(0, ui.KeyDown):
*cursor += cfg.cols
case cells.Shortcut(0, ui.KeyPageUp):
*cursor -= cfg.page
case cells.Shortcut(0, ui.KeyPageDown):
*cursor += cfg.page
case cells.Shortcut(0, ui.KeyHome):
*cursor, _ = cfg.pageOf(*cursor)
case cells.Shortcut(0, ui.KeyEnd):
s, n := cfg.pageOf(*cursor)
*cursor = s + n - 1
default:
moved = false
}
if moved {
*keyed = true
}
start, n := cfg.pageOf(*cursor)
w := d.ControlHeight() * 7 / float32(cfg.cols)
cells.Children(func() {
for r := 0; r < n; r += cfg.cols {
ui.Row(c).Children(func() {
for o := start + r; o < start+min(r+cfg.cols, n); o++ {
if datetimeGridCell(c, o, start, *cursor, cfg, w, moved, *keyed) {
picked, ok = o, true
*cursor, *keyed = o, false
}
}
})
}
})
})
return picked, ok
}
// datetimeGridCell draws one month or year cell and reports whether it was picked.
func datetimeGridCell(c *ui.Context, o, start, cursor int, cfg datetimeGridConfig, w float32, focus, keyed bool) bool {
k := Tokens(c)
d := Density(c)
enabled, chosen := cfg.enabled(o), cfg.chosen(o)
sel := o - 1
if chosen {
sel = o
}
cell := ui.RadioBase(c, &sel, o).Size(w, d.ControlHeight()).Center().Radius(theme.ControlRadius).
Label(cfg.label(o)).Disabled(!enabled).FocusRing(false)
if o == cursor && enabled {
if focus {
cell.Focus()
} else if cfg.autoFocus {
cell.AutoFocus()
}
}
fg := k.Text
if cfg.muted(o, start) {
fg = k.TextMuted
}
switch {
case chosen:
cell.Background(k.Accent)
fg = k.OnAccent
if cell.Hovered() {
cell.Background(k.AccentHover)
}
case cell.Pressed():
cell.Background(k.Selection)
case cell.Hovered():
cell.Background(k.SurfaceHover)
}
current := cfg.current(o)
ring := cell.FocusVisible() || cell.Focused() && keyed
cell.DrawOver(func(p *ui.Painter, r ui.Rect) {
if current {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H-6, 5), fg)
}
if ring {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
cell.Children(func() {
t := ui.Text(c, cfg.text(o)).TextColor(fg).FontFeatures("tnum").SingleLine()
if current {
t.Bold()
}
if !enabled {
t.Strikethrough()
}
})
return cell.Clicked() && enabled
}
// datetimeGridField is the field and popup shared by MonthPicker and YearPicker.
type datetimeGridField struct {
text, placeholder, label string
clearable, hasValue bool
disabled bool
// home is the ordinal the popup opens on.
home int
}
// datetimeGridPicker draws f and its popup grid; it returns the column, a picked ordinal and whether Clear was pressed.
func datetimeGridPicker(c *ui.Context, f datetimeGridField, cfg datetimeGridConfig) (col *ui.Element, picked int, ok, cleared bool) {
d := Density(c)
col = ui.Column(c).Width(d.ControlHeight() * 6).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(f.disabled)
open := ui.Local(col, "datetime-grid-open", func() bool { return false })
cursor := ui.Local(col, "datetime-grid-cursor", func() int { return 0 })
col.Children(func() {
field := datetimeField(c, ui.Row(c), false)
field.Children(func() {
t := datetimeTrigger(c, f.text, f.placeholder, "calendar").Label(f.label).Description(f.text)
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
*open, *cursor = !*open, f.home
}
if f.clearable && f.hasValue && datetimeIconButton(c, "x", msg(c, "calendar.clear")).Clicked() {
cleared = true
}
})
datetimePopup(c, field, open, func() {
cfg.autoFocus = true
if picked, ok = datetimeGrid(c, cursor, cfg); ok {
*open = false
}
})
})
return col, picked, ok, cleared
}
+55
View File
@@ -0,0 +1,55 @@
# 051 Calendar · 日历
## 用途
在页面中直接显示一个月份,用于选择单个日期。支持今日标记、不可选日期与周起始日配置。
## 最小示例
```go
day := mujica.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.Calendar(c, &day, mujica.CalendarOptions{WeekStart: mujica.WeekStartMonday}).Changed() {
fmt.Println("chosen", day)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Calendar(c *ui.Context, value *Date, opts CalendarOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Min` | `Date` | 零值 | 最早可选日期;零值不限制。 |
| `Max` | `Date` | 零值 | 最晚可选日期;零值不限制。 |
| `DisableDate` | `func(Date) bool` | nil | 返回 true 的日期不可选,显示删除线。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 首列星期:跟随语言(zh-CN 周一、en-US 周日),或 `WeekStartSunday`/`WeekStartMonday`/`WeekStartSaturday`。 |
| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期:加粗并在下方绘制菱形。 |
| `Disabled` | `bool` | false | 禁用整个日历:不能翻页、不能选择,退出 Tab 序列。 |
| `Label` | `string` | 内置“日历” | 辅助技术读出的名称。 |
## 状态
默认、悬停、按下、聚焦(键盘焦点环)、选中(酒红填充 + 单选语义)、今日(加粗 + 菱形)、不可选(删除线并由 MyGo 置灰)、非本月(弱化文字)、禁用。
## 事件
`Calendar(...).Changed()` 在用户选择了与原值不同的日期的那一帧返回 true,`*value` 已更新。翻月不触发事件。
## 键盘操作
- Tab:进入日期格,落在已选日期或上次聚焦的日期。
- ←/→:前后一天;↑/↓:前后一周(自动跳过不可选日期,最多 31 天)。
- PageUp/PageDown:上/下一月;Shift+PageUp/PageDown:上/下一年。
- Home/End:本月第一天/最后一天。
- Enter/Space:选择当前日期。
## 限制
- 不可选日期不能获得焦点;若方向键 31 天内找不到可选日期,视图会翻页但焦点停在原处。
- MyGo 的 `ui.Calendar` 固定周一起始、只有英文且不支持禁用日期(ui/date.go:85-160),因此本组件基于 `RadioBase` 自绘网格,不复用 `ui.Calendar`。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+59
View File
@@ -0,0 +1,59 @@
# 052 DatePicker · 日期选择器
## 用途
可键入的日期输入框,带日历弹层。用于表单中录入单个日期。
## 最小示例
```go
due := mujica.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.DatePickerOptions{Label: "Due date", Clearable: true,
Min: mujica.Date{Year: 2026, Month: time.January, Day: 1}}
if mujica.DatePicker(c, &due, opts).Changed() {
fmt.Println("due", due)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Layout` | `string` | `2006-01-02` | Go 时间布局,用于显示与解析输入。 |
| `Min` | `Date` | 零值 | 最早可接受日期。 |
| `Max` | `Date` | 零值 | 最晚可接受日期。 |
| `DisableDate` | `func(Date) bool` | nil | 不可选日期;键入此类日期会报告错误。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 弹层日历的周起始日。 |
| `Today` | `Date` | 零值(系统时钟) | 弹层日历中的今日标记;空值时弹层打开到此月份。 |
| `Clearable` | `bool` | false | 显示清空按钮;清空输入后提交表示“无日期”。 |
| `Placeholder` | `string` | 内置“选择日期” | 空输入框的占位文字。 |
| `Disabled` | `bool` | false | 禁用输入与弹层。 |
| `Label` | `string` | 占位文字 | 辅助技术名称。 |
## 状态
默认、悬停(边框加深)、聚焦(焦点色边框)、错误(危险色边框 + 下方说明,输入保留)、禁用。弹层内日历状态同 Calendar。
## 事件
`Changed()`:键入后按 Enter 或离开输入框并解析成功、在日历中选择、或点击清空导致值变化时为 true。
## 键盘操作
- 键入日期后 Enter 提交;Tab 离开时也会提交。
- ↓ 或 Alt+↓:打开日历;日历内按键同 Calendar,Enter 选择后关闭并写入。
- Escape:关闭日历,不改值。
## 限制
- 无效或越界的输入(如 2026-02-30)显示具体错误并保留原文,不会被修正为相邻日期。
- MyGo 的 `ui.DateInput` 固定格式且不可键入(ui/date.go:13),因此本组件组合 `TextInputBase` 与自绘日历弹层(`PopoverBase`)。
- 暂不提供只读模式:MyGo v0.2.10 未公开 TextInputBase 的只读开关。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+57
View File
@@ -0,0 +1,57 @@
# 053 DateRangePicker · 日期范围选择器
## 用途
在弹层中先选开始日期、再选结束日期,悬停时预览范围,确认后写入。
## 最小示例
```go
var stay mujica.DateRange
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.DateRangePicker(c, &stay, mujica.DateRangePickerOptions{Label: "Stay"})
if r.Changed() {
fmt.Println("stay", stay.Start, stay.End, stay.Days())
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Layout` | `string` | `2006-01-02` | 触发器与摘要中的日期格式。 |
| `Min` | `Date` | 零值 | 两端最早日期。 |
| `Max` | `Date` | 零值 | 两端最晚日期。 |
| `DisableDate` | `func(Date) bool` | nil | 不可选日期;范围内包含此类日期时拒绝并提示。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日。 |
| `Today` | `Date` | 零值(系统时钟) | 今日标记与空值时的起始月份。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“日期范围” | 辅助技术名称。 |
## 状态
触发器:默认、悬停、聚焦、禁用。弹层:起止日期酒红填充,中间日期浅色带;选择结束前随指针或焦点预览范围;错误说明;“确定”在两端选齐前禁用。
## 事件
`Changed()`:点击“确定”或“清空”使值改变时为 true。`Cancelled()`:按 Escape、点击“取消”或点击弹层外关闭、草稿被丢弃时为 true。
## 键盘操作
- Enter/Space/↓:打开弹层。
- 日历内方向键移动,Enter 依次选择开始与结束。
- 早于开始日期的选择会重新开始范围。
- Tab 到“清空”“取消”“确定”。
- Escape:放弃草稿。
## 限制
- 弹层一次显示一个月;跨月范围通过翻页完成。
- `DateRange` 为闭区间,`Days()` 计入两端。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+59
View File
@@ -0,0 +1,59 @@
# 054 TimePicker · 时间选择器
## 用途
分段编辑时、分、秒的时间输入,可在弹层列表中选择;支持 12/24 小时制、步长和范围。
## 最小示例
```go
alarm := mujica.TimeOfDay{Hour: 7, Minute: 30}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.TimePickerOptions{Label: "Alarm", Hour12: true, MinuteStep: 5}
if mujica.TimePicker(c, &alarm, opts).Changed() {
fmt.Println("alarm", alarm)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Seconds` | `bool` | false | 显示并编辑秒。 |
| `Hour12` | `bool` | false | 12 小时制,附加上午/下午段。 |
| `MinuteStep` | `int` | 0(即 1) | 分钟步长,必须整除 60。 |
| `SecondStep` | `int` | 0(即 1) | 秒步长,必须整除 60。 |
| `Min` | `TimeOfDay` | 00:00:00 | 最早时间。 |
| `Max` | `TimeOfDay` | 零值(即 23:59:59) | 最晚时间。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 空 | 组名;各段名称为“Label: 时/分/秒”。 |
## 状态
默认、悬停、聚焦段(酒红填充)、错误(危险色边框 + 说明,草稿保留)、禁用。弹层列表中超出范围的项禁用。
## 事件
`Changed()`:编辑后的时间合法(在范围内且符合步长)并与原值不同时为 true。不合法时不写入,显示错误。
## 键盘操作
- Tab:进入时间段(整组一个 Tab 停靠点)。
- ←/→:切换时、分、秒、上午/下午。
- ↑/↓:按步长增减,整圈循环。
- 数字键:直接键入,时完成后自动跳到分。
- A/P:设置上午/下午。
- Alt+↓ 或时钟按钮:打开列表弹层;Escape 关闭。
## 限制
- 超出范围或不符合步长的时间会提示,不会被自动夹取或取整。
- 段顺序固定为“时:分:秒 上午/下午”,zh-CN 下上午/下午也位于末尾。
- MyGo 的 `ui.TimeInput` 只有时分且无 12 小时制、步长和范围(ui/timeinput.go:18),因此本组件自绘分段。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+55
View File
@@ -0,0 +1,55 @@
# 055 TimeRangePicker · 时间范围选择器
## 用途
编辑开始与结束时间,校验顺序;跨午夜必须显式开启。
## 最小示例
```go
shift := mujica.TimeRange{Start: mujica.TimeOfDay{Hour: 22}, End: mujica.TimeOfDay{Hour: 6}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.TimeRangePickerOptions{Label: "Night shift", AllowOvernight: true}
if mujica.TimeRangePicker(c, &shift, opts).Changed() {
fmt.Println("shift", shift.Start, shift.End, shift.Overnight())
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Seconds` | `bool` | false | 显示秒。 |
| `Hour12` | `bool` | false | 12 小时制。 |
| `MinuteStep` | `int` | 0(即 1) | 分钟步长。 |
| `SecondStep` | `int` | 0(即 1) | 秒步长。 |
| `Min` | `TimeOfDay` | 00:00:00 | 两端最早时间。 |
| `Max` | `TimeOfDay` | 零值(即 23:59:59) | 两端最晚时间。 |
| `AllowOvernight` | `bool` | false | 允许结束早于开始,表示次日结束,并显示“次日”。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 空 | 整组名称。 |
## 状态
同 TimePicker;范围不合法时两端都显示错误边框;跨午夜时显示“+1 day / 次日”。
## 事件
`Changed()`:两端都合法且顺序正确、与原值不同时为 true。
## 键盘操作
- 两端各自按 TimePicker 操作;Tab 从开始移到结束。
## 限制
- 未开启 `AllowOvernight` 时传入跨午夜的值会 panic(编程错误)。
- 开始等于结束视为错误。
- `TimeRange.Overnight()` 报告是否跨午夜。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+62
View File
@@ -0,0 +1,62 @@
# 056 DateTimePicker · 日期时间选择器
## 用途
在调用方指定的时区中编辑日期和时间,确认后写入 `time.Time`。
## 最小示例
```go
tokyo, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
panic(err)
}
var meeting time.Time
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.DateTimePickerOptions{Label: "Meeting", Location: tokyo, MinuteStep: 15}
if mujica.DateTimePicker(c, &meeting, opts).Changed() {
fmt.Println("meeting", meeting)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 用户编辑所用时区;为 nil 时 panic。 |
| `Min` | `time.Time` | 零值 | 最早时刻。 |
| `Max` | `time.Time` | 零值 | 最晚时刻。 |
| `Seconds` | `bool` | false | 显示秒。 |
| `Hour12` | `bool` | false | 12 小时制。 |
| `MinuteStep` | `int` | 0(即 1) | 分钟步长。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日。 |
| `Today` | `Date` | 零值(系统时钟) | 今日标记与空值时的默认日期。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置占位文字 | 辅助技术名称。 |
## 状态
触发器显示该时区下的时间与时区缩写。弹层:日历、时间段、时区名、错误说明、“取消/确定”。
## 事件
`Changed()`:确认且时刻改变时为 true,写入的值位于 `Location`。`Cancelled()`:取消、Escape 或点击外部关闭时为 true。
## 键盘操作
- Enter/Space/↓:打开。
- 日历与时间段按键同 DatePicker、TimePicker。
- Tab 到“取消”“确定”。
- Escape:取消。
## 限制
- 夏令时跳过的时间(如纽约 2026-03-08 02:30)和重复的时间(如 2026-11-01 01:30)都会提示,不会自动移到相邻时刻或选择其中之一。
- 空值(零 `time.Time`)打开时默认今天 00:00。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+55
View File
@@ -0,0 +1,55 @@
# 057 MonthPicker · 月份选择器
## 用途
按年份分页选择月份。
## 最小示例
```go
var month mujica.YearMonth
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.MonthPickerOptions{Label: "Billing month", Clearable: true}
if mujica.MonthPicker(c, &month, opts).Changed() {
fmt.Println("month", month)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Min` | `YearMonth` | 零值 | 最早月份。 |
| `Max` | `YearMonth` | 零值 | 最晚月份。 |
| `DisableMonth` | `func(YearMonth) bool` | nil | 不可选月份。 |
| `Clearable` | `bool` | false | 有值时显示清空按钮。 |
| `Today` | `Date` | 零值(系统时钟) | 标记当前月份。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“选择月份” | 辅助技术名称。 |
## 状态
默认、悬停、聚焦、选中(酒红填充)、当前月(加粗 + 菱形)、不可选(删除线)、禁用。
## 事件
`Changed()`:选择或清空导致值变化时为 true。
## 键盘操作
- Enter/Space/↓:打开。
- ←/→:前后一月;↑/↓:前后三月(一行)。
- PageUp/PageDown:上/下一年。
- Home/End:一月/十二月。
- Enter:选择并关闭;Escape:关闭。
## 限制
- 键盘移到不可选月份时焦点保持在原处,可继续移动。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+55
View File
@@ -0,0 +1,55 @@
# 058 YearPicker · 年份选择器
## 用途
按十年分页浏览并选择年份。
## 最小示例
```go
year := 2026
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.YearPickerOptions{Label: "Vintage", Min: 1990, Max: 2030}
if mujica.YearPicker(c, &year, opts).Changed() {
fmt.Println("year", year)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func YearPicker(c *ui.Context, value *int, opts YearPickerOptions) DateTimeResult`,返回值嵌入 `*ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Min` | `int` | 0 | 最早年份;0 不限制。 |
| `Max` | `int` | 0 | 最晚年份;0 不限制。 |
| `DisableYear` | `func(int) bool` | nil | 不可选年份。 |
| `Clearable` | `bool` | false | 有值时显示清空按钮。 |
| `Today` | `Date` | 零值(系统时钟) | 标记当前年份。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“选择年份” | 辅助技术名称。 |
## 状态
默认、悬停、聚焦、选中、当前年、不可选、相邻十年(弱化)、禁用。
## 事件
`Changed()`:选择或清空导致值变化时为 true。`*value` 为 0 表示未选择。
## 键盘操作
- Enter/Space/↓:打开。
- ←/→:前后一年;↑/↓:前后三年。
- PageUp/PageDown:上/下一个十年。
- Home/End:页首/页尾。
- Enter:选择并关闭;Escape:关闭。
## 限制
- 只支持公元 1 年及以后;值为负数时 panic。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
+110
View File
@@ -0,0 +1,110 @@
package mujica_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExampleCalendar() {
day := mujica.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.Calendar(c, &day, mujica.CalendarOptions{WeekStart: mujica.WeekStartMonday}).Changed() {
fmt.Println("chosen", day)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDatePicker() {
due := mujica.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.DatePickerOptions{Label: "Due date", Clearable: true,
Min: mujica.Date{Year: 2026, Month: time.January, Day: 1}}
if mujica.DatePicker(c, &due, opts).Changed() {
fmt.Println("due", due)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDateRangePicker() {
var stay mujica.DateRange
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.DateRangePicker(c, &stay, mujica.DateRangePickerOptions{Label: "Stay"})
if r.Changed() {
fmt.Println("stay", stay.Start, stay.End, stay.Days())
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleTimePicker() {
alarm := mujica.TimeOfDay{Hour: 7, Minute: 30}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.TimePickerOptions{Label: "Alarm", Hour12: true, MinuteStep: 5}
if mujica.TimePicker(c, &alarm, opts).Changed() {
fmt.Println("alarm", alarm)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleTimeRangePicker() {
shift := mujica.TimeRange{Start: mujica.TimeOfDay{Hour: 22}, End: mujica.TimeOfDay{Hour: 6}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.TimeRangePickerOptions{Label: "Night shift", AllowOvernight: true}
if mujica.TimeRangePicker(c, &shift, opts).Changed() {
fmt.Println("shift", shift.Start, shift.End, shift.Overnight())
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDateTimePicker() {
tokyo, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
panic(err)
}
var meeting time.Time
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.DateTimePickerOptions{Label: "Meeting", Location: tokyo, MinuteStep: 15}
if mujica.DateTimePicker(c, &meeting, opts).Changed() {
fmt.Println("meeting", meeting)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMonthPicker() {
var month mujica.YearMonth
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.MonthPickerOptions{Label: "Billing month", Clearable: true}
if mujica.MonthPicker(c, &month, opts).Changed() {
fmt.Println("month", month)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleYearPicker() {
year := 2026
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.YearPickerOptions{Label: "Vintage", Min: 1990, Max: 2030}
if mujica.YearPicker(c, &year, opts).Changed() {
fmt.Println("year", year)
}
}
ui.Render(view, 400, 300, 1)
}
+397 -1
View File
@@ -1,3 +1,399 @@
package main
var datetimePages = []page{}
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/theme"
)
const dtCategory = "Date & Time"
var datetimePages = []page{
{ID: "051", Name: "Calendar", ZhName: "日历", Category: dtCategory, Demo: dtCalendarDemo,
Code: dtCalendarCode, Keys: "Tab enters the days; arrows move by day and week; PageUp/PageDown by month, with Shift by year; Home/End go to the first and last day; Enter or Space chooses."},
{ID: "052", Name: "DatePicker", ZhName: "日期选择器", Category: dtCategory, Demo: dtDatePickerDemo,
Code: dtDatePickerCode, Keys: "Type a date and press Enter, or leave the field, to commit; Down opens the calendar; in it arrows, PageUp/PageDown, Enter choose and Escape closes."},
{ID: "053", Name: "DateRangePicker", ZhName: "日期范围选择器", Category: dtCategory, Demo: dtDateRangeDemo,
Code: dtDateRangeCode, Keys: "Enter, Space or Down opens; Enter picks the start, then the end; Tab reaches Clear, Cancel and Confirm; Escape discards the draft."},
{ID: "054", Name: "TimePicker", ZhName: "时间选择器", Category: dtCategory, Demo: dtTimePickerDemo,
Code: dtTimePickerCode, Keys: "Tab enters the segments; Left/Right move between them; Up/Down step; digits type; A/P set the period; Alt+Down or the clock button opens the lists."},
{ID: "055", Name: "TimeRangePicker", ZhName: "时间范围选择器", Category: dtCategory, Demo: dtTimeRangeDemo,
Code: dtTimeRangeCode, Keys: "Each end works as a TimePicker; Tab moves from the start to the end."},
{ID: "056", Name: "DateTimePicker", ZhName: "日期时间选择器", Category: dtCategory, Demo: dtDateTimeDemo,
Code: dtDateTimeCode, Keys: "Enter, Space or Down opens; the calendar and time segments work as in DatePicker and TimePicker; Tab reaches Cancel and Confirm; Escape cancels."},
{ID: "057", Name: "MonthPicker", ZhName: "月份选择器", Category: dtCategory, Demo: dtMonthDemo,
Code: dtMonthCode, Keys: "Enter, Space or Down opens; arrows move by month and row; PageUp/PageDown by year; Enter chooses; Escape closes."},
{ID: "058", Name: "YearPicker", ZhName: "年份选择器", Category: dtCategory, Demo: dtYearDemo,
Code: dtYearCode, Keys: "Enter, Space or Down opens; arrows move by year and row; PageUp/PageDown by decade; Enter chooses; Escape closes."},
}
// dtSection lays out a captioned block of a demo.
func dtSection(c *ui.Context, title string, fn func()) {
k := mujica.Tokens(c)
ui.Column(c).Gap(8).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
fn()
})
}
// dtStates lays labeled variants side by side.
func dtStates(c *ui.Context, fn func(state func(name string, body func()))) {
k := mujica.Tokens(c)
ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(func() {
fn(func(name string, body func()) {
ui.Column(c).Gap(6).AlignItems(ui.Start).Children(func() {
ui.Text(c, name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
body()
})
})
})
}
func dtControls(c *ui.Context, fn func()) {
ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(fn)
}
var dtWeekStarts = []string{"Locale", "Sunday", "Monday", "Saturday"}
func dtWeekStart(s string) mujica.WeekStart {
for i, n := range dtWeekStarts {
if n == s {
return mujica.WeekStart(i)
}
}
panic("gallery: unknown week start " + s)
}
func dtCalendarDemo(c *ui.Context, log func(string)) {
type state struct {
day mujica.Date
weekStart string
weekends, bound, frozen bool
}
s := ui.Local(c.Root(), "051", func() state { return state{day: mujica.DateOf(time.Now()), weekStart: "Locale"} })
dtSection(c, "Calendar", func() {
dtControls(c, func() {
ui.Select(c, &s.weekStart, dtWeekStarts)
ui.Checkbox(c, &s.weekends, "Disable weekends")
ui.Checkbox(c, &s.bound, "Only this month")
ui.Checkbox(c, &s.frozen, "Disabled")
})
opts := mujica.CalendarOptions{WeekStart: dtWeekStart(s.weekStart), Disabled: s.frozen, Label: "Calendar demo"}
if s.weekends {
opts.DisableDate = func(d mujica.Date) bool { return d.Weekday() == time.Saturday || d.Weekday() == time.Sunday }
}
if s.bound {
now := mujica.DateOf(time.Now())
opts.Min = mujica.Date{Year: now.Year, Month: now.Month, Day: 1}
opts.Max = opts.Min.AddMonths(1).AddDays(-1)
}
if mujica.Calendar(c, &s.day, opts).Changed() {
log("Calendar changed: " + s.day.String())
}
})
}
func dtDatePickerDemo(c *ui.Context, log func(string)) {
type state struct {
a, b, d mujica.Date
clearable, slash bool
}
s := ui.Local(c.Root(), "052", func() state {
t := mujica.DateOf(time.Now())
return state{a: t, d: t, clearable: true}
})
dtSection(c, "DatePicker", func() {
dtControls(c, func() {
ui.Checkbox(c, &s.clearable, "Clearable")
ui.Checkbox(c, &s.slash, "Layout 02/01/2006")
})
layout := ""
if s.slash {
layout = "02/01/2006"
}
now := mujica.DateOf(time.Now())
dtStates(c, func(state func(string, func())) {
state("Default", func() {
if mujica.DatePicker(c, &s.a, mujica.DatePickerOptions{Label: "Due date", Layout: layout, Clearable: s.clearable}).Changed() {
log("DatePicker changed: " + s.a.String())
}
})
state("Next 30 days only", func() {
opts := mujica.DatePickerOptions{Label: "Delivery", Layout: layout, Clearable: s.clearable, Min: now, Max: now.AddDays(30)}
if mujica.DatePicker(c, &s.b, opts).Changed() {
log("DatePicker (bounded) changed: " + s.b.String())
}
})
state("Disabled", func() {
mujica.DatePicker(c, &s.d, mujica.DatePickerOptions{Label: "Locked", Layout: layout, Disabled: true})
})
})
})
}
func dtDateRangeDemo(c *ui.Context, log func(string)) {
type state struct {
a, b mujica.DateRange
noTuesdays bool
frozen bool
fromThisWeek bool
}
s := ui.Local(c.Root(), "053", func() state { return state{} })
dtSection(c, "DateRangePicker", func() {
dtControls(c, func() {
ui.Checkbox(c, &s.noTuesdays, "Tuesdays unavailable")
ui.Checkbox(c, &s.fromThisWeek, "Start from today")
ui.Checkbox(c, &s.frozen, "Disabled")
})
opts := mujica.DateRangePickerOptions{Label: "Stay", Disabled: s.frozen}
if s.noTuesdays {
opts.DisableDate = func(d mujica.Date) bool { return d.Weekday() == time.Tuesday }
}
if s.fromThisWeek {
opts.Min = mujica.DateOf(time.Now())
}
r := mujica.DateRangePicker(c, &s.a, opts)
if r.Changed() {
log(fmt.Sprintf("DateRangePicker changed: %v – %v (%d days)", s.a.Start, s.a.End, s.a.Days()))
}
if r.Cancelled() {
log("DateRangePicker cancelled")
}
})
}
func dtTimePickerDemo(c *ui.Context, log func(string)) {
type state struct {
a, b mujica.TimeOfDay
seconds, hour12 bool
step string
}
s := ui.Local(c.Root(), "054", func() state {
return state{a: mujica.TimeOfDay{Hour: 9, Minute: 30}, b: mujica.TimeOfDay{Hour: 10}, step: "1"}
})
dtSection(c, "TimePicker", func() {
dtControls(c, func() {
ui.Checkbox(c, &s.seconds, "Seconds")
ui.Checkbox(c, &s.hour12, "12-hour")
ui.Text(c, "Minute step")
ui.Select(c, &s.step, []string{"1", "5", "15", "30"})
})
step := 0
if _, err := fmt.Sscan(s.step, &step); err != nil {
panic(err)
}
base := mujica.TimePickerOptions{Seconds: s.seconds, Hour12: s.hour12, MinuteStep: step}
dtStates(c, func(state func(string, func())) {
state("Any time", func() {
o := base
o.Label = "Alarm"
if mujica.TimePicker(c, &s.a, o).Changed() {
log("TimePicker changed: " + s.a.String())
}
})
state("Office hours 09:00–18:00", func() {
o := base
o.Label, o.Min, o.Max = "Meeting", mujica.TimeOfDay{Hour: 9}, mujica.TimeOfDay{Hour: 18}
if mujica.TimePicker(c, &s.b, o).Changed() {
log("TimePicker (bounded) changed: " + s.b.String())
}
})
state("Disabled", func() {
o := base
o.Label, o.Disabled = "Locked", true
mujica.TimePicker(c, &s.a, o)
})
})
})
}
func dtTimeRangeDemo(c *ui.Context, log func(string)) {
type state struct {
v mujica.TimeRange
overnight bool
}
s := ui.Local(c.Root(), "055", func() state {
return state{v: mujica.TimeRange{Start: mujica.TimeOfDay{Hour: 9}, End: mujica.TimeOfDay{Hour: 17}}}
})
dtSection(c, "TimeRangePicker", func() {
dtControls(c, func() {
if ui.Checkbox(c, &s.overnight, "Allow overnight").Changed() && !s.overnight && s.v.Overnight() {
s.v = mujica.TimeRange{Start: s.v.End, End: s.v.Start}
log("TimeRangePicker swapped ends: overnight turned off")
}
})
opts := mujica.TimeRangePickerOptions{Label: "Opening hours", AllowOvernight: s.overnight, MinuteStep: 15}
if mujica.TimeRangePicker(c, &s.v, opts).Changed() {
log(fmt.Sprintf("TimeRangePicker changed: %v – %v overnight=%v", s.v.Start, s.v.End, s.v.Overnight()))
}
})
}
var dtZones = []string{"Local", "UTC", "Asia/Shanghai", "America/New_York", "Europe/London"}
func dtDateTimeDemo(c *ui.Context, log func(string)) {
type state struct {
v time.Time
zone string
hour12 bool
}
s := ui.Local(c.Root(), "056", func() state { return state{zone: "Local"} })
dtSection(c, "DateTimePicker", func() {
dtControls(c, func() {
ui.Text(c, "Time zone")
ui.Select(c, &s.zone, dtZones)
ui.Checkbox(c, &s.hour12, "12-hour")
})
loc, err := time.LoadLocation(s.zone)
if err != nil {
panic(err)
}
r := mujica.DateTimePicker(c, &s.v, mujica.DateTimePickerOptions{Label: "Meeting", Location: loc, Hour12: s.hour12})
if r.Changed() {
log("DateTimePicker changed: " + s.v.Format(time.RFC3339))
}
if r.Cancelled() {
log("DateTimePicker cancelled")
}
k := mujica.Tokens(c)
ui.Text(c, "Try 2026-03-08 02:30 in America/New_York: the picker reports the DST gap.").
FontSize(theme.CaptionSize).TextColor(k.TextMuted)
})
}
func dtMonthDemo(c *ui.Context, log func(string)) {
type state struct {
v mujica.YearMonth
clearable, past bool
}
s := ui.Local(c.Root(), "057", func() state { return state{clearable: true} })
dtSection(c, "MonthPicker", func() {
dtControls(c, func() {
ui.Checkbox(c, &s.clearable, "Clearable")
ui.Checkbox(c, &s.past, "Past months only")
})
opts := mujica.MonthPickerOptions{Label: "Billing month", Clearable: s.clearable,
DisableMonth: func(m mujica.YearMonth) bool { return m.Month == time.February }}
if s.past {
now := time.Now()
opts.Max = mujica.YearMonth{Year: now.Year(), Month: now.Month()}
}
dtStates(c, func(state func(string, func())) {
state("February unavailable", func() {
if mujica.MonthPicker(c, &s.v, opts).Changed() {
log("MonthPicker changed: " + s.v.String())
}
})
state("Disabled", func() {
mujica.MonthPicker(c, &s.v, mujica.MonthPickerOptions{Label: "Locked", Disabled: true})
})
})
})
}
func dtYearDemo(c *ui.Context, log func(string)) {
type state struct {
v int
clearable bool
}
s := ui.Local(c.Root(), "058", func() state { return state{v: time.Now().Year(), clearable: true} })
dtSection(c, "YearPicker", func() {
dtControls(c, func() { ui.Checkbox(c, &s.clearable, "Clearable") })
opts := mujica.YearPickerOptions{Label: "Vintage", Min: 1990, Max: time.Now().Year(), Clearable: s.clearable,
DisableYear: func(y int) bool { return y == 2020 }}
dtStates(c, func(state func(string, func())) {
state("1990 to now, 2020 unavailable", func() {
if mujica.YearPicker(c, &s.v, opts).Changed() {
log(fmt.Sprintf("YearPicker changed: %d", s.v))
}
})
state("Disabled", func() {
mujica.YearPicker(c, &s.v, mujica.YearPickerOptions{Label: "Locked", Disabled: true})
})
})
})
}
// Code snippets mirror example_datetime_test.go.
const (
dtCalendarCode = `day := mujica.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.Calendar(c, &day, mujica.CalendarOptions{WeekStart: mujica.WeekStartMonday}).Changed() {
fmt.Println("chosen", day)
}
}
ui.Render(view, 400, 300, 1)`
dtDatePickerCode = `due := mujica.Date{Year: 2026, Month: time.October, Day: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.DatePickerOptions{Label: "Due date", Clearable: true,
Min: mujica.Date{Year: 2026, Month: time.January, Day: 1}}
if mujica.DatePicker(c, &due, opts).Changed() {
fmt.Println("due", due)
}
}
ui.Render(view, 400, 300, 1)`
dtDateRangeCode = `var stay mujica.DateRange
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.DateRangePicker(c, &stay, mujica.DateRangePickerOptions{Label: "Stay"})
if r.Changed() {
fmt.Println("stay", stay.Start, stay.End, stay.Days())
}
}
ui.Render(view, 400, 300, 1)`
dtTimePickerCode = `alarm := mujica.TimeOfDay{Hour: 7, Minute: 30}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.TimePickerOptions{Label: "Alarm", Hour12: true, MinuteStep: 5}
if mujica.TimePicker(c, &alarm, opts).Changed() {
fmt.Println("alarm", alarm)
}
}
ui.Render(view, 400, 300, 1)`
dtTimeRangeCode = `shift := mujica.TimeRange{Start: mujica.TimeOfDay{Hour: 22}, End: mujica.TimeOfDay{Hour: 6}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.TimeRangePickerOptions{Label: "Night shift", AllowOvernight: true}
if mujica.TimeRangePicker(c, &shift, opts).Changed() {
fmt.Println("shift", shift.Start, shift.End, shift.Overnight())
}
}
ui.Render(view, 400, 300, 1)`
dtDateTimeCode = `tokyo, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
panic(err)
}
var meeting time.Time
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.DateTimePickerOptions{Label: "Meeting", Location: tokyo, MinuteStep: 15}
if mujica.DateTimePicker(c, &meeting, opts).Changed() {
fmt.Println("meeting", meeting)
}
}
ui.Render(view, 400, 300, 1)`
dtMonthCode = `var month mujica.YearMonth
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.MonthPickerOptions{Label: "Billing month", Clearable: true}
if mujica.MonthPicker(c, &month, opts).Changed() {
fmt.Println("month", month)
}
}
ui.Render(view, 400, 300, 1)`
dtYearCode = `year := 2026
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.YearPickerOptions{Label: "Vintage", Min: 1990, Max: 2030}
if mujica.YearPicker(c, &year, opts).Changed() {
fmt.Println("year", year)
}
}
ui.Render(view, 400, 300, 1)`
)
+86
View File
@@ -0,0 +1,86 @@
package mujica
func init() {
addMessages(map[string][2]string{
"calendar.label": {"Calendar", "日历"},
"calendar.title": {"%[1]s %[2]d", "%[2]d年%[1]s"},
"calendar.day_label": {"%[1]s, %[2]s %[3]d, %[4]d", "%[4]d年%[2]s%[3]d日 %[1]s"},
"calendar.prev_month": {"Previous month", "上个月"},
"calendar.next_month": {"Next month", "下个月"},
"calendar.prev_year": {"Previous year", "上一年"},
"calendar.next_year": {"Next year", "下一年"},
"calendar.month_1": {"January", "1月"},
"calendar.month_2": {"February", "2月"},
"calendar.month_3": {"March", "3月"},
"calendar.month_4": {"April", "4月"},
"calendar.month_5": {"May", "5月"},
"calendar.month_6": {"June", "6月"},
"calendar.month_7": {"July", "7月"},
"calendar.month_8": {"August", "8月"},
"calendar.month_9": {"September", "9月"},
"calendar.month_10": {"October", "10月"},
"calendar.month_11": {"November", "11月"},
"calendar.month_12": {"December", "12月"},
"calendar.weekday_0": {"Su", "日"},
"calendar.weekday_1": {"Mo", "一"},
"calendar.weekday_2": {"Tu", "二"},
"calendar.weekday_3": {"We", "三"},
"calendar.weekday_4": {"Th", "四"},
"calendar.weekday_5": {"Fr", "五"},
"calendar.weekday_6": {"Sa", "六"},
"calendar.weekday_long_0": {"Sunday", "星期日"},
"calendar.weekday_long_1": {"Monday", "星期一"},
"calendar.weekday_long_2": {"Tuesday", "星期二"},
"calendar.weekday_long_3": {"Wednesday", "星期三"},
"calendar.weekday_long_4": {"Thursday", "星期四"},
"calendar.weekday_long_5": {"Friday", "星期五"},
"calendar.weekday_long_6": {"Saturday", "星期六"},
"calendar.before_min": {"Choose %s or later.", "请选择 %s 或之后。"},
"calendar.after_max": {"Choose %s or earlier.", "请选择 %s 或之前。"},
"calendar.unavailable": {"%s is not available.", "%s 不可选。"},
"calendar.clear": {"Clear", "清空"},
"calendar.confirm": {"Confirm", "确定"},
"calendar.cancel": {"Cancel", "取消"},
"date_picker.placeholder": {"Select date", "选择日期"},
"date_picker.open": {"Open calendar", "打开日历"},
"date_picker.invalid": {"Enter a date like %s.", "请按 %s 的格式输入日期。"},
"date_range_picker.label": {"Date range", "日期范围"},
"date_range_picker.placeholder": {"Start date – End date", "开始日期 – 结束日期"},
"date_range_picker.pick_start": {"Choose the start date.", "请选择开始日期。"},
"date_range_picker.pick_end": {"From %s, choose the end date.", "自 %s 起,请选择结束日期。"},
"date_range_picker.summary": {"%s – %s · %d days", "%s – %s · 共 %d 天"},
"date_range_picker.blocked": {"The range cannot include %s.", "范围不能包含 %s。"},
"time_picker.hours": {"Hours", "时"},
"time_picker.minutes": {"Minutes", "分"},
"time_picker.seconds": {"Seconds", "秒"},
"time_picker.period": {"AM/PM", "上午/下午"},
"time_picker.am": {"AM", "上午"},
"time_picker.pm": {"PM", "下午"},
"time_picker.with_period": {"%[1]s %[2]s", "%[2]s %[1]s"},
"time_picker.open": {"Choose time", "选择时间"},
"time_picker.minute_step": {"Minutes must be a multiple of %d.", "分钟须为 %d 的倍数。"},
"time_picker.second_step": {"Seconds must be a multiple of %d.", "秒须为 %d 的倍数。"},
"time_picker.out_of_range": {"Choose a time from %s to %s.", "请选择 %s 至 %s 之间的时间。"},
"time_range_picker.start": {"Start time", "开始时间"},
"time_range_picker.end": {"End time", "结束时间"},
"time_range_picker.next_day": {"+1 day", "次日"},
"time_range_picker.same": {"Start and end must differ.", "开始与结束时间不能相同。"},
"time_range_picker.order": {"End must be after start; overnight ranges are off.", "结束须晚于开始;未开启跨午夜。"},
"date_time_picker.placeholder": {"Select date and time", "选择日期和时间"},
"date_time_picker.time": {"Time", "时间"},
"date_time_picker.gap": {"%s does not exist in %s: the clocks skip it.", "%s 在 %s 不存在:该时段因夏令时被跳过。"},
"date_time_picker.ambiguous": {"%s happens twice in %s: the clocks repeat it.", "%s 在 %s 出现两次:该时段因夏令时重复。"},
"month_picker.placeholder": {"Select month", "选择月份"},
"month_picker.year": {"%d", "%d年"},
"year_picker.placeholder": {"Select year", "选择年份"},
"year_picker.prev": {"Previous decade", "上一个十年"},
"year_picker.next": {"Next decade", "下一个十年"},
})
}
+110
View File
@@ -0,0 +1,110 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
)
// YearMonth is a month of a year. The zero value means no month.
type YearMonth struct {
Year int
Month time.Month
}
// IsZero reports whether m is the empty month.
func (m YearMonth) IsZero() bool { return m == YearMonth{} }
// String formats m as 2006-01, or "" when empty.
func (m YearMonth) String() string {
if m.IsZero() {
return ""
}
return fmt.Sprintf("%04d-%02d", m.Year, m.Month)
}
func (m YearMonth) ordinal() int { return m.Year*12 + int(m.Month) - 1 }
func yearMonthOf(o int) YearMonth {
y := o / 12
if o < 0 && o%12 != 0 {
y--
}
return YearMonth{y, time.Month(o-y*12) + 1}
}
// MonthPickerOptions configure a MonthPicker.
type MonthPickerOptions struct {
// Min and Max bound the months chosen; zero leaves a side open.
Min, Max YearMonth
DisableMonth func(YearMonth) bool
Clearable bool
// Today marks the current month; zero uses the system clock.
Today Date
Disabled bool
// Label names the field for assistive technology.
Label string
}
// MonthPicker chooses a month into *value from a year page; PageUp and PageDown change the year.
func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateTimeResult {
for _, m := range []YearMonth{*value, opts.Min, opts.Max} {
if !m.IsZero() && (m.Month < time.January || m.Month > time.December) {
panic(fmt.Sprintf("mujica: MonthPicker month %v is not valid", m))
}
}
if !opts.Min.IsZero() && !opts.Max.IsZero() && opts.Min.ordinal() > opts.Max.ordinal() {
panic(fmt.Sprintf("mujica: MonthPicker Min %v is after Max %v", opts.Min, opts.Max))
}
today := datetimeToday(c, opts.Today)
now := YearMonth{today.Year, today.Month}
title := func(m YearMonth) string { return datetimeMonthTitle(c, m.Year, m.Month) }
home := *value
if home.IsZero() {
home = now
}
text := ""
if !value.IsZero() {
text = title(*value)
}
label := opts.Label
if label == "" {
label = msg(c, "month_picker.placeholder")
}
col, picked, ok, cleared := datetimeGridPicker(c, datetimeGridField{
text: text, placeholder: msg(c, "month_picker.placeholder"), label: label,
clearable: opts.Clearable, hasValue: !value.IsZero(), disabled: opts.Disabled, home: home.ordinal(),
}, datetimeGridConfig{
pageOf: func(o int) (int, int) { return yearMonthOf(o).Year * 12, 12 },
cols: 3,
page: 12,
title: func(start int) string { return fmt.Sprintf(msg(c, "month_picker.year"), yearMonthOf(start).Year) },
prev: msg(c, "calendar.prev_year"),
next: msg(c, "calendar.next_year"),
text: func(o int) string { return datetimeMonthName(c, yearMonthOf(o).Month) },
label: func(o int) string { return title(yearMonthOf(o)) },
chosen: func(o int) bool { return !value.IsZero() && o == value.ordinal() },
current: func(o int) bool { return o == now.ordinal() },
enabled: func(o int) bool {
m := yearMonthOf(o)
return (opts.Min.IsZero() || o >= opts.Min.ordinal()) && (opts.Max.IsZero() || o <= opts.Max.ordinal()) &&
(opts.DisableMonth == nil || !opts.DisableMonth(m))
},
muted: func(int, int) bool { return false },
})
to := *value
switch {
case cleared:
to = YearMonth{}
case ok:
to = yearMonthOf(picked)
}
changed := to != *value
if changed {
slog.Debug("mujica: month_picker change", "from", *value, "to", to)
*value = to
}
return DateTimeResult{Element: col, changed: changed}
}
+96
View File
@@ -0,0 +1,96 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func monthPickerTester(value *YearMonth, opts MonthPickerOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
opts.Label = "Billing month"
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Children(func() {
if MonthPicker(c, value, opts).Changed() {
*changes++
}
})
}, 420, 420)
}
func TestMonthPickerClickAndYear(t *testing.T) {
v, n := YearMonth{}, 0
tt := monthPickerTester(&v, MonthPickerOptions{}, &n)
_ = tt.Click("Billing month")
if !tt.HasText("2026") {
t.Fatalf("popup should open on the current year: %v", tt.Texts())
}
_ = tt.Click("Next year")
if err := tt.Click("March 2027"); err != nil {
t.Fatal(err)
}
if v != (YearMonth{2027, time.March}) || n != 1 || tt.HasText("April 2027") {
t.Fatalf("picked %v, popup closed %v", v, !tt.HasText("April 2027"))
}
}
func TestMonthPickerKeyboard(t *testing.T) {
v, n := YearMonth{2026, time.May}, 0
tt := monthPickerTester(&v, MonthPickerOptions{}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused("May 2026") {
t.Fatal("popup should focus the chosen month")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyPageUp)
if !tt.Focused("September 2025") {
t.Fatal("arrows and PageUp should move the cursor")
}
tt.Key(0, ui.KeyEnter)
if v != (YearMonth{2025, time.September}) || n != 1 {
t.Fatalf("Enter chose %v", v)
}
}
func TestMonthPickerLimitsAndClear(t *testing.T) {
v, n := YearMonth{2026, time.June}, 0
opts := MonthPickerOptions{Min: YearMonth{2026, time.March}, Max: YearMonth{2026, time.October}, Clearable: true,
DisableMonth: func(m YearMonth) bool { return m.Month == time.August }}
tt := monthPickerTester(&v, opts, &n)
_ = tt.Click("Billing month")
for _, m := range []string{"February 2026", "August 2026", "November 2026"} {
_ = tt.Click(m)
}
if v != (YearMonth{2026, time.June}) || n != 0 {
t.Fatalf("disabled month chosen: %v", v)
}
tt.Key(0, ui.KeyEscape)
if err := tt.Click("Clear"); err != nil {
t.Fatal(err)
}
if !v.IsZero() || n != 1 {
t.Fatalf("clear left %v", v)
}
}
func TestYearMonthOrdinal(t *testing.T) {
for _, m := range []YearMonth{{2026, time.January}, {2026, time.December}, {1, time.March}} {
if got := yearMonthOf(m.ordinal()); got != m {
t.Fatalf("%v round-tripped to %v", m, got)
}
}
}
func TestMonthPickerSnapshot(t *testing.T) {
a, b := YearMonth{2026, time.October}, YearMonth{}
snapshot(t, "month_picker", 520, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
MonthPicker(c, &b, MonthPickerOptions{Today: calendarToday})
MonthPicker(c, &a, MonthPickerOptions{Today: calendarToday, Clearable: true})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+426
View File
@@ -0,0 +1,426 @@
package mujica
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// TimeOfDay is a wall-clock time with no date and no location.
type TimeOfDay struct {
Hour, Minute, Second int
}
// Valid reports whether t is a time between 00:00:00 and 23:59:59.
func (t TimeOfDay) Valid() bool {
return t.Hour >= 0 && t.Hour < 24 && t.Minute >= 0 && t.Minute < 60 && t.Second >= 0 && t.Second < 60
}
// String formats t as 15:04:05.
func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d:%02d", t.Hour, t.Minute, t.Second) }
// Compare returns -1, 0 or 1 as t is before, equal to or after o.
func (t TimeOfDay) Compare(o TimeOfDay) int {
a, b := t.seconds(), o.seconds()
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
func (t TimeOfDay) seconds() int { return t.Hour*3600 + t.Minute*60 + t.Second }
// TimePickerOptions configure a TimePicker.
type TimePickerOptions struct {
// Seconds shows and edits the seconds.
Seconds bool
// Hour12 shows a 12-hour clock with AM and PM.
Hour12 bool
// MinuteStep and SecondStep must divide 60; zero means 1.
MinuteStep, SecondStep int
// Min and Max bound the time; a zero Max means 23:59:59.
Min, Max TimeOfDay
Disabled bool
// Label names the field for assistive technology.
Label string
}
// TimePicker edits *value by typing into hour, minute and second segments or by choosing in a popup.
// Times outside the range or off the step are reported and not written.
func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTimeResult {
cfg := timePickerConfig("TimePicker", opts)
if !value.Valid() {
panic(fmt.Sprintf("mujica: TimePicker value %v is not a time of day", *value))
}
col := ui.Column(c).Gap(Density(c).Unit()).AlignItems(ui.Start).Disabled(opts.Disabled)
draft := ui.Local(col, "time-picker-draft", func() TimeOfDay { return *value })
seen := ui.Local(col, "time-picker-seen", func() TimeOfDay { return *value })
problem := ui.Local(col, "time-picker-error", func() string { return "" })
if *seen != *value {
*draft, *seen, *problem = *value, *value, ""
}
changed := false
col.Children(func() {
cfg.invalid = *problem != ""
if _, edited := timeEditor(c, draft, cfg); edited {
*problem = timePickerProblem(c, *draft, cfg)
if *problem == "" && *draft != *value {
slog.Debug("mujica: time_picker change", "from", *value, "to", *draft)
*value, *seen, changed = *draft, *draft, true
}
}
datetimeError(c, *problem)
})
return DateTimeResult{Element: col, changed: changed}
}
// timeConfig is the resolved behavior of a time editor.
type timeConfig struct {
seconds, hour12 bool
minuteStep, secondStep int
min, max TimeOfDay
label string
invalid bool
}
// timePickerConfig checks opts and fills in defaults.
func timePickerConfig(name string, opts TimePickerOptions) timeConfig {
cfg := timeConfig{seconds: opts.Seconds, hour12: opts.Hour12, minuteStep: max(opts.MinuteStep, 1),
secondStep: max(opts.SecondStep, 1), min: opts.Min, max: opts.Max, label: opts.Label}
for _, s := range []int{opts.MinuteStep, opts.SecondStep} {
if s < 0 || s > 0 && 60%s != 0 {
panic(fmt.Sprintf("mujica: %s step %d does not divide 60", name, s))
}
}
if cfg.max == (TimeOfDay{}) {
cfg.max = TimeOfDay{23, 59, 59}
}
if !cfg.min.Valid() || !cfg.max.Valid() || cfg.min.Compare(cfg.max) > 0 {
panic(fmt.Sprintf("mujica: %s range %v..%v is not valid", name, cfg.min, cfg.max))
}
return cfg
}
// timePickerProblem explains why t cannot be written, or returns "".
func timePickerProblem(c *ui.Context, t TimeOfDay, cfg timeConfig) string {
switch {
case t.Minute%cfg.minuteStep != 0:
return fmt.Sprintf(msg(c, "time_picker.minute_step"), cfg.minuteStep)
case cfg.seconds && t.Second%cfg.secondStep != 0:
return fmt.Sprintf(msg(c, "time_picker.second_step"), cfg.secondStep)
case t.Compare(cfg.min) < 0 || t.Compare(cfg.max) > 0:
return fmt.Sprintf(msg(c, "time_picker.out_of_range"), timeText(c, cfg.min, cfg), timeText(c, cfg.max, cfg))
}
return ""
}
// timeText formats t for messages, honoring the 12-hour clock and seconds.
func timeText(c *ui.Context, t TimeOfDay, cfg timeConfig) string {
s := fmt.Sprintf("%02d:%02d", t.Hour, t.Minute)
if cfg.hour12 {
s = fmt.Sprintf("%d:%02d", timeHour12(t.Hour), t.Minute)
}
if cfg.seconds {
s += fmt.Sprintf(":%02d", t.Second)
}
if cfg.hour12 {
s = fmt.Sprintf(msg(c, "time_picker.with_period"), s, timePeriod(c, t.Hour))
}
return s
}
func timeHour12(h int) int {
if h%12 == 0 {
return 12
}
return h % 12
}
func timePeriod(c *ui.Context, h int) string {
if h < 12 {
return msg(c, "time_picker.am")
}
return msg(c, "time_picker.pm")
}
// timePart is one segment of a time editor.
type timePart int
const (
timeHourPart timePart = iota
timeMinutePart
timeSecondPart
timePeriodPart
)
var timePartNames = [...]string{"time_picker.hours", "time_picker.minutes", "time_picker.seconds", "time_picker.period"}
func timeParts(cfg timeConfig) []timePart {
parts := []timePart{timeHourPart, timeMinutePart}
if cfg.seconds {
parts = append(parts, timeSecondPart)
}
if cfg.hour12 {
parts = append(parts, timePeriodPart)
}
return parts
}
// timeEditor draws a segmented time field with a popup and reports whether the user edited *draft.
func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Element, edited bool) {
k := Tokens(c)
d := Density(c)
field = datetimeField(c, ui.Row(c), cfg.invalid).Gap(d.Unit() * 2)
open := ui.Local(field, "time-open", func() bool { return false })
fresh := ui.Local(field, "time-fresh", func() bool { return false })
field.Children(func() {
segs := ui.Row(c).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(cfg.label)
segs.Children(func() {
focusNext := false
for i, part := range timeParts(cfg) {
if i > 0 && part != timePeriodPart {
ui.Text(c, ":").TextColor(k.TextMuted).Role(ui.RoleNone)
}
if part == timePeriodPart {
ui.Box(c).Width(d.Unit())
}
seg, done := timeSegment(c, draft, part, cfg, &edited)
if focusNext {
seg.Focus()
focusNext = false
}
focusNext = done
}
})
b := datetimeIconButton(c, "clock", msg(c, "time_picker.open"))
if b.Clicked() {
*open, *fresh = !*open, true
}
})
if !*open && field.Shortcut(ui.Alt, ui.KeyDown) {
*open, *fresh = true, true
}
datetimePopup(c, field, open, func() {
if timeColumns(c, draft, cfg, *fresh) {
edited = true
}
*fresh = false
})
return field, edited
}
// timeSegment draws one focusable segment; done reports a fully typed value.
func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, edited *bool) (seg *ui.Element, done bool) {
k := Tokens(c)
d := Density(c)
name := msg(c, timePartNames[part])
if cfg.label != "" {
name = cfg.label + ": " + name
}
seg = ui.Box(c).Focusable().FocusRing(false).Role(ui.RoleStepper).Label(name).
Padding(d.Unit()/2, d.Unit()).Radius(theme.ControlRadius).MinWidth(theme.BodySize * 1.6).Center()
typed := ui.Local(seg, "time-typed", func() string { return "" })
if !seg.Focused() {
*typed = ""
}
set := func(t TimeOfDay) {
if t != *draft {
*draft = t
*edited = true
}
}
t := *draft
step := func(dir int) {
switch part {
case timeHourPart:
t.Hour = (t.Hour + dir + 24) % 24
case timeMinutePart:
t.Minute = timeStep(t.Minute, cfg.minuteStep, dir)
case timeSecondPart:
t.Second = timeStep(t.Second, cfg.secondStep, dir)
case timePeriodPart:
t.Hour = (t.Hour + 12) % 24
}
set(t)
}
switch {
case seg.Shortcut(0, ui.KeyUp):
step(1)
case seg.Shortcut(0, ui.KeyDown):
step(-1)
}
if part == timePeriodPart {
if seg.Shortcut(0, ui.KeyA) && t.Hour >= 12 || seg.Shortcut(0, ui.KeyP) && t.Hour < 12 {
t.Hour = (t.Hour + 12) % 24
set(t)
}
} else {
for n := range 10 {
if seg.Shortcut(0, ui.Key0+ui.Key(n)) {
done = timeType(typed, n, part, cfg, &t)
set(t)
}
}
}
text := ""
switch part {
case timeHourPart:
text = fmt.Sprintf("%02d", t.Hour)
if cfg.hour12 {
text = fmt.Sprintf("%2d", timeHour12(t.Hour))
}
case timeMinutePart:
text = fmt.Sprintf("%02d", t.Minute)
case timeSecondPart:
text = fmt.Sprintf("%02d", t.Second)
case timePeriodPart:
text = timePeriod(c, t.Hour)
}
fg := k.Text
switch {
case seg.Focused():
seg.Background(k.Accent)
fg = k.OnAccent
case seg.Hovered():
seg.Background(k.SurfaceHover)
}
seg.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum").SingleLine() })
return seg, done
}
// timeStep moves v to the next multiple of step in dir, wrapping within an hour.
func timeStep(v, step, dir int) int {
if dir > 0 {
return (v/step*step + step) % 60
}
if v%step != 0 {
return v / step * step
}
return (v - step + 60) % 60
}
// timeType adds digit n to a segment's typed digits and reports when the segment is complete.
func timeType(typed *string, n int, part timePart, cfg timeConfig, t *TimeOfDay) bool {
lo, hi := 0, 59
if part == timeHourPart {
hi = 23
if cfg.hour12 {
lo, hi = 1, 12
}
}
s := *typed + fmt.Sprint(n)
v, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
if len(s) > 2 || v > hi {
s, v = fmt.Sprint(n), n
}
*typed = s
if v >= lo {
switch part {
case timeHourPart:
if cfg.hour12 {
v = v%12 + t.Hour/12*12
}
t.Hour = v
case timeMinutePart:
t.Minute = v
case timeSecondPart:
t.Second = v
}
}
if len(s) == 2 || n*10 > hi && len(s) == 1 {
*typed = ""
return true
}
return false
}
// timeColumns lists choosable values per segment and reports whether a choice edited *draft.
func timeColumns(c *ui.Context, draft *TimeOfDay, cfg timeConfig, fresh bool) (edited bool) {
k := Tokens(c)
d := Density(c)
h := d.ControlHeight() - 4
within := func(lo, hi TimeOfDay) bool { return hi.Compare(cfg.min) >= 0 && lo.Compare(cfg.max) <= 0 }
ui.Row(c).Gap(d.Unit()).Children(func() {
for _, part := range timeParts(cfg) {
t := *draft
var options []TimeOfDay
switch part {
case timeHourPart:
for v := range 24 {
if !cfg.hour12 || v/12 == t.Hour/12 {
options = append(options, TimeOfDay{v, t.Minute, t.Second})
}
}
case timeMinutePart:
for v := 0; v < 60; v += cfg.minuteStep {
options = append(options, TimeOfDay{t.Hour, v, t.Second})
}
case timeSecondPart:
for v := 0; v < 60; v += cfg.secondStep {
options = append(options, TimeOfDay{t.Hour, t.Minute, v})
}
case timePeriodPart:
options = []TimeOfDay{{t.Hour % 12, t.Minute, t.Second}, {t.Hour%12 + 12, t.Minute, t.Second}}
}
name := msg(c, timePartNames[part])
ui.Scroll(c).Height(h * 6).Width(d.ControlHeight() * 1.5).Children(func() {
ui.Column(c).FocusGroup(ui.Vertical).Role(ui.RoleRadioGroup).Label(name).Children(func() {
for _, o := range options {
var lo, hi TimeOfDay
text := ""
switch part {
case timeHourPart:
lo, hi = TimeOfDay{o.Hour, 0, 0}, TimeOfDay{o.Hour, 59, 59}
text = fmt.Sprintf("%02d", o.Hour)
if cfg.hour12 {
text = fmt.Sprintf("%d", timeHour12(o.Hour))
}
case timeMinutePart:
lo, hi = TimeOfDay{o.Hour, o.Minute, 0}, TimeOfDay{o.Hour, o.Minute, 59}
text = fmt.Sprintf("%02d", o.Minute)
case timeSecondPart:
lo, hi = o, o
text = fmt.Sprintf("%02d", o.Second)
case timePeriodPart:
lo, hi = TimeOfDay{o.Hour / 12 * 12, 0, 0}, TimeOfDay{o.Hour/12*12 + 11, 59, 59}
text = timePeriod(c, o.Hour)
}
sel := t
item := ui.RadioBase(c, &sel, o).Height(h).FillWidth().Center().Radius(theme.ControlRadius).
Disabled(!within(lo, hi)).Label(text)
fg := k.Text
switch {
case o == t:
item.Background(k.Accent)
fg = k.OnAccent
if fresh {
item.ScrollIntoView()
}
case item.Pressed():
item.Background(k.Selection)
case item.Hovered():
item.Background(k.SurfaceHover)
}
internal.Ring(item, k.Focus, theme.ControlRadius)
item.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum") })
if item.Clicked() && o != *draft {
*draft, edited = o, true
}
}
})
})
}
})
return edited
}
+153
View File
@@ -0,0 +1,153 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func timePickerTester(value *TimeOfDay, opts TimePickerOptions, changes *int) *ui.Tester {
opts.Label = "Alarm"
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Children(func() {
if TimePicker(c, value, opts).Changed() {
*changes++
}
})
}, 420, 420)
}
func TestTimePickerArrowsAndTyping(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{}, &n)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Alarm: Hours") {
t.Fatal("Tab should focus the hours")
}
tt.Key(0, ui.KeyUp)
if v != (TimeOfDay{10, 30, 0}) || n != 1 {
t.Fatalf("Up gave %v", v)
}
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key7)
if v != (TimeOfDay{17, 30, 0}) || !tt.Focused("Alarm: Minutes") {
t.Fatalf("typed hours %v, focus moved %v", v, tt.Focused("Alarm: Minutes"))
}
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key5)
if v != (TimeOfDay{17, 5, 0}) {
t.Fatalf("typed minutes %v", v)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyDown)
if v != (TimeOfDay{16, 5, 0}) {
t.Fatalf("Left then Down gave %v", v)
}
tt.Key(0, ui.Key0)
tt.Key(0, ui.KeyDown)
if v != (TimeOfDay{23, 5, 0}) {
t.Fatalf("Down should wrap round the clock: %v", v)
}
}
func TestTimePickerHour12AndSeconds(t *testing.T) {
v, n := TimeOfDay{9, 0, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{Hour12: true, Seconds: true}, &n)
if !tt.HasText("AM") || !tt.HasText(" 9") {
t.Fatalf("12-hour display: %v", tt.Texts())
}
_ = tt.Click("Alarm: AM/PM")
tt.Key(0, ui.KeyP)
if v != (TimeOfDay{21, 0, 0}) {
t.Fatalf("P gave %v", v)
}
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key2)
if v != (TimeOfDay{12, 0, 0}) {
t.Fatalf("12 PM is noon, got %v", v)
}
_ = tt.Click("Alarm: Seconds")
tt.Key(0, ui.Key4)
tt.Key(0, ui.Key5)
if v != (TimeOfDay{12, 0, 45}) {
t.Fatalf("seconds %v", v)
}
}
func TestTimePickerRangeAndStep(t *testing.T) {
v, n := TimeOfDay{9, 0, 0}, 0
opts := TimePickerOptions{Min: TimeOfDay{9, 0, 0}, Max: TimeOfDay{17, 0, 0}, MinuteStep: 15}
tt := timePickerTester(&v, opts, &n)
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.KeyDown)
if v != (TimeOfDay{9, 0, 0}) || n != 0 || !tt.HasText("Choose a time from 09:00 to 17:00.") {
t.Fatalf("out of range accepted: %v %v", v, tt.Texts())
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyUp)
if v != (TimeOfDay{10, 0, 0}) || tt.HasText("Choose a time") {
t.Fatalf("back in range: %v", v)
}
_ = tt.Click("Alarm: Minutes")
tt.Key(0, ui.KeyUp)
if v != (TimeOfDay{10, 15, 0}) {
t.Fatalf("step 15 gave %v", v)
}
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key7)
if v != (TimeOfDay{10, 0, 0}) || !tt.HasText("Minutes must be a multiple of 15.") {
t.Fatalf("off-step minute accepted: %v", v)
}
}
func TestTimePickerPopup(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
opts := TimePickerOptions{Min: TimeOfDay{9, 0, 0}, MinuteStep: 5}
tt := timePickerTester(&v, opts, &n)
if err := tt.Click("Choose time"); err != nil {
t.Fatal(err)
}
if err := tt.Click("25"); err != nil {
t.Fatal(err)
}
if v != (TimeOfDay{9, 25, 0}) || n != 1 {
t.Fatalf("minute pick %v", v)
}
if err := tt.Click("08"); err != nil {
t.Fatal(err)
}
if v.Hour != 9 || n != 1 {
t.Fatalf("hour before Min was chosen: %v", v)
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("05") {
t.Fatal("Escape should close the popup")
}
}
func TestTimePickerRejectsBadOptions(t *testing.T) {
for _, opts := range []TimePickerOptions{{MinuteStep: 7}, {Min: TimeOfDay{10, 0, 0}, Max: TimeOfDay{9, 0, 0}}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: expected panic", opts)
}
}()
v := TimeOfDay{}
timePickerTester(&v, opts, new(int))
}()
}
}
func TestTimePickerSnapshot(t *testing.T) {
a, b := TimeOfDay{9, 30, 0}, TimeOfDay{21, 5, 40}
snapshot(t, "time_picker", 480, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
TimePicker(c, &a, TimePickerOptions{})
TimePicker(c, &b, TimePickerOptions{Hour12: true, Seconds: true})
TimePicker(c, &a, TimePickerOptions{Disabled: true})
})
})
}
+102
View File
@@ -0,0 +1,102 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// TimeRange is a start and an end time of day. End before Start means it ends the next day.
type TimeRange struct {
Start, End TimeOfDay
}
// Overnight reports whether r crosses midnight.
func (r TimeRange) Overnight() bool { return r.End.Compare(r.Start) < 0 }
// TimeRangePickerOptions configure a TimeRangePicker.
type TimeRangePickerOptions struct {
Seconds bool
Hour12 bool
// MinuteStep and SecondStep must divide 60; zero means 1.
MinuteStep, SecondStep int
// Min and Max bound both ends; a zero Max means 23:59:59.
Min, Max TimeOfDay
// AllowOvernight accepts an end before the start, meaning the next day.
AllowOvernight bool
Disabled bool
// Label names the pair for assistive technology.
Label string
}
// TimeRangePicker edits a start and an end time. It writes *value only when both ends are valid
// and the end follows the start, or crosses midnight with AllowOvernight.
func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOptions) DateTimeResult {
cfg := timePickerConfig("TimeRangePicker", TimePickerOptions{Seconds: opts.Seconds, Hour12: opts.Hour12,
MinuteStep: opts.MinuteStep, SecondStep: opts.SecondStep, Min: opts.Min, Max: opts.Max})
if !value.Start.Valid() || !value.End.Valid() {
panic(fmt.Sprintf("mujica: TimeRangePicker value %v is not a pair of times", *value))
}
if value.Overnight() && !opts.AllowOvernight {
panic(fmt.Sprintf("mujica: TimeRangePicker value %v crosses midnight without AllowOvernight", *value))
}
k := Tokens(c)
d := Density(c)
col := ui.Column(c).Gap(d.Unit()).AlignItems(ui.Start).Disabled(opts.Disabled).Role(ui.RoleGroup)
if opts.Label != "" {
col.Label(opts.Label)
}
draft := ui.Local(col, "time-range-draft", func() TimeRange { return *value })
seen := ui.Local(col, "time-range-seen", func() TimeRange { return *value })
problem := ui.Local(col, "time-range-error", func() string { return "" })
if *seen != *value {
*draft, *seen, *problem = *value, *value, ""
}
changed := false
col.Children(func() {
edited := false
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
start, end := cfg, cfg
start.label, end.label = msg(c, "time_range_picker.start"), msg(c, "time_range_picker.end")
start.invalid, end.invalid = *problem != "", *problem != ""
if _, e := timeEditor(c, &draft.Start, start); e {
edited = true
}
ui.Text(c, "–").TextColor(k.TextMuted).Role(ui.RoleNone)
if _, e := timeEditor(c, &draft.End, end); e {
edited = true
}
if draft.Overnight() && opts.AllowOvernight {
ui.Text(c, msg(c, "time_range_picker.next_day")).FontSize(theme.CaptionSize).TextColor(k.AccentText)
}
})
if edited {
*problem = timeRangeProblem(c, *draft, cfg, opts.AllowOvernight)
if *problem == "" && *draft != *value {
slog.Debug("mujica: time_range_picker change", "from", *value, "to", *draft)
*value, *seen, changed = *draft, *draft, true
}
}
datetimeError(c, *problem)
})
return DateTimeResult{Element: col, changed: changed}
}
// timeRangeProblem explains why r cannot be written, or returns "".
func timeRangeProblem(c *ui.Context, r TimeRange, cfg timeConfig, overnight bool) string {
for _, t := range []TimeOfDay{r.Start, r.End} {
if p := timePickerProblem(c, t, cfg); p != "" {
return p
}
}
switch {
case r.Start == r.End:
return msg(c, "time_range_picker.same")
case r.Overnight() && !overnight:
return msg(c, "time_range_picker.order")
}
return ""
}
+75
View File
@@ -0,0 +1,75 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func timeRangeTester(value *TimeRange, opts TimeRangePickerOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Children(func() {
if TimeRangePicker(c, value, opts).Changed() {
*changes++
}
})
}, 520, 420)
}
func TestTimeRangePickerOrder(t *testing.T) {
v, n := TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 0, 0}}, 0
tt := timeRangeTester(&v, TimeRangePickerOptions{}, &n)
_ = tt.Click("End time: Hours")
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key8)
if v.End != (TimeOfDay{17, 0, 0}) || n != 0 {
t.Fatalf("end before start accepted: %v", v)
}
if !tt.HasText("End must be after start; overnight ranges are off.") {
t.Fatalf("missing order error: %v", tt.Texts())
}
_ = tt.Click("Start time: Hours")
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key7)
if v != (TimeRange{TimeOfDay{7, 0, 0}, TimeOfDay{8, 0, 0}}) || n != 2 || tt.HasText("End must be") {
t.Fatalf("fixing the start should commit both: %v", v)
}
_ = tt.Click("End time: Hours")
tt.Key(0, ui.KeyDown)
if v.End != (TimeOfDay{8, 0, 0}) || !tt.HasText("Start and end must differ.") {
t.Fatalf("equal ends accepted: %v", v)
}
}
func TestTimeRangePickerOvernight(t *testing.T) {
v, n := TimeRange{TimeOfDay{22, 0, 0}, TimeOfDay{23, 0, 0}}, 0
tt := timeRangeTester(&v, TimeRangePickerOptions{AllowOvernight: true}, &n)
_ = tt.Click("End time: Hours")
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key6)
if v.End != (TimeOfDay{6, 0, 0}) || n != 2 || !v.Overnight() || !tt.HasText("+1 day") {
t.Fatalf("overnight not accepted: %v %v", v, tt.Texts())
}
}
func TestTimeRangePickerRejectsOvernightValue(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
v := TimeRange{TimeOfDay{22, 0, 0}, TimeOfDay{6, 0, 0}}
timeRangeTester(&v, TimeRangePickerOptions{}, new(int))
}
func TestTimeRangePickerSnapshot(t *testing.T) {
a := TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 30, 0}}
b := TimeRange{TimeOfDay{22, 0, 0}, TimeOfDay{6, 0, 0}}
snapshot(t, "time_range_picker", 420, 120, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Children(func() {
TimeRangePicker(c, &a, TimeRangePickerOptions{})
TimeRangePicker(c, &b, TimeRangePickerOptions{AllowOvernight: true})
})
})
}
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
)
// YearPickerOptions configure a YearPicker.
type YearPickerOptions struct {
// Min and Max bound the years chosen; zero leaves a side open.
Min, Max int
DisableYear func(int) bool
Clearable bool
// Today marks the current year; zero uses the system clock.
Today Date
Disabled bool
// Label names the field for assistive technology.
Label string
}
// YearPicker chooses a year into *value, zero meaning none, from a decade page.
// Arrows move by year and row, PageUp and PageDown by decade.
func YearPicker(c *ui.Context, value *int, opts YearPickerOptions) DateTimeResult {
if *value < 0 || opts.Min < 0 || opts.Max < 0 || opts.Min != 0 && opts.Max != 0 && opts.Min > opts.Max {
panic(fmt.Sprintf("mujica: YearPicker value %d or range %d..%d is not valid", *value, opts.Min, opts.Max))
}
now := datetimeToday(c, opts.Today).Year
home := *value
if home == 0 {
home = now
}
decade := func(o int) int { return o / 10 * 10 }
text := ""
if *value != 0 {
text = strconv.Itoa(*value)
}
label := opts.Label
if label == "" {
label = msg(c, "year_picker.placeholder")
}
col, picked, ok, cleared := datetimeGridPicker(c, datetimeGridField{
text: text, placeholder: msg(c, "year_picker.placeholder"), label: label,
clearable: opts.Clearable, hasValue: *value != 0, disabled: opts.Disabled, home: home,
}, datetimeGridConfig{
pageOf: func(o int) (int, int) { return decade(o) - 1, 12 },
cols: 3,
page: 10,
title: func(start int) string { return fmt.Sprintf("%d – %d", start+1, start+10) },
prev: msg(c, "year_picker.prev"),
next: msg(c, "year_picker.next"),
text: strconv.Itoa,
label: strconv.Itoa,
chosen: func(o int) bool { return o == *value },
current: func(o int) bool { return o == now },
enabled: func(o int) bool {
return o > 0 && (opts.Min == 0 || o >= opts.Min) && (opts.Max == 0 || o <= opts.Max) &&
(opts.DisableYear == nil || !opts.DisableYear(o))
},
muted: func(o, start int) bool { return o == start || o == start+11 },
})
to := *value
switch {
case cleared:
to = 0
case ok:
to = picked
}
changed := to != *value
if changed {
slog.Debug("mujica: year_picker change", "from", *value, "to", to)
*value = to
}
return DateTimeResult{Element: col, changed: changed}
}
+87
View File
@@ -0,0 +1,87 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func yearPickerTester(value *int, opts YearPickerOptions, changes *int) *ui.Tester {
opts.Today = calendarToday
opts.Label = "Vintage"
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Children(func() {
if YearPicker(c, value, opts).Changed() {
*changes++
}
})
}, 420, 420)
}
func TestYearPickerClickAndDecade(t *testing.T) {
v, n := 0, 0
tt := yearPickerTester(&v, YearPickerOptions{}, &n)
_ = tt.Click("Vintage")
if !tt.HasText("2020 – 2029") || !tt.HasText("2019") || !tt.HasText("2030") {
t.Fatalf("decade page: %v", tt.Texts())
}
_ = tt.Click("Previous decade")
if err := tt.Click("2014"); err != nil {
t.Fatal(err)
}
if v != 2014 || n != 1 || tt.HasText("2015") {
t.Fatalf("picked %d", v)
}
}
func TestYearPickerKeyboard(t *testing.T) {
v, n := 2026, 0
tt := yearPickerTester(&v, YearPickerOptions{}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.Focused("2026") {
t.Fatal("popup should focus the chosen year")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if !tt.HasText("2030 – 2039") || !tt.Focused("2030") {
t.Fatalf("crossing the decade should turn the page: %v", tt.Texts())
}
tt.Key(0, ui.KeyPageUp)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeySpace)
if v != 2017 || n != 1 {
t.Fatalf("Space chose %d", v)
}
}
func TestYearPickerLimitsAndClear(t *testing.T) {
v, n := 2024, 0
opts := YearPickerOptions{Min: 2022, Max: 2027, Clearable: true, DisableYear: func(y int) bool { return y == 2025 }}
tt := yearPickerTester(&v, opts, &n)
_ = tt.Click("Vintage")
for _, y := range []string{"2021", "2025", "2028"} {
_ = tt.Click(y)
}
if v != 2024 || n != 0 {
t.Fatalf("disabled year chosen: %d", v)
}
_ = tt.Click("2026")
_ = tt.Click("Clear")
if v != 0 || n != 2 {
t.Fatalf("clear left %d (%d)", v, n)
}
}
func TestYearPickerSnapshot(t *testing.T) {
a, b := 2026, 0
snapshot(t, "year_picker", 520, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
YearPicker(c, &b, YearPickerOptions{Today: calendarToday})
YearPicker(c, &a, YearPickerOptions{Today: calendarToday, Clearable: true})
})
})
}