From e61e38b65606549429cd874c9550316dd6fc915a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 12:22:05 -0400 Subject: [PATCH] Date & Time: components 051-058 (wip) --- calendar.go | 267 +++++++++++ calendar_test.go | 165 +++++++ date_picker.go | 155 +++++++ date_picker_test.go | 152 +++++++ date_range_picker.go | 174 +++++++ date_range_picker_test.go | 138 ++++++ date_time_picker.go | 181 ++++++++ date_time_picker_test.go | 152 +++++++ datetime.go | 201 +++++++++ datetime_grid.go | 182 ++++++++ docs/components/051-calendar.md | 55 +++ docs/components/052-date-picker.md | 59 +++ docs/components/053-date-range-picker.md | 57 +++ docs/components/054-time-picker.md | 59 +++ docs/components/055-time-range-picker.md | 55 +++ docs/components/056-date-time-picker.md | 62 +++ docs/components/057-month-picker.md | 55 +++ docs/components/058-year-picker.md | 55 +++ example_datetime_test.go | 110 +++++ examples/gallery/pages_datetime.go | 398 +++++++++++++++- messages_datetime.go | 86 ++++ month_picker.go | 110 +++++ month_picker_test.go | 96 ++++ testdata/snapshots/darwin/calendar-dark.png | Bin 0 -> 17972 bytes testdata/snapshots/darwin/calendar-light.png | Bin 0 -> 16715 bytes .../snapshots/darwin/date_picker-dark.png | Bin 0 -> 7489 bytes .../snapshots/darwin/date_picker-light.png | Bin 0 -> 7301 bytes .../darwin/date_range_picker-dark.png | Bin 0 -> 6730 bytes .../darwin/date_range_picker-light.png | Bin 0 -> 6479 bytes .../darwin/date_time_picker-dark.png | Bin 0 -> 6875 bytes .../darwin/date_time_picker-light.png | Bin 0 -> 6542 bytes .../snapshots/darwin/month_picker-dark.png | Bin 0 -> 5440 bytes .../snapshots/darwin/month_picker-light.png | Bin 0 -> 5381 bytes .../snapshots/darwin/time_picker-dark.png | Bin 0 -> 5336 bytes .../snapshots/darwin/time_picker-light.png | Bin 0 -> 5060 bytes .../darwin/time_range_picker-dark.png | Bin 0 -> 7143 bytes .../darwin/time_range_picker-light.png | Bin 0 -> 6724 bytes .../snapshots/darwin/year_picker-dark.png | Bin 0 -> 4302 bytes .../snapshots/darwin/year_picker-light.png | Bin 0 -> 4177 bytes time_picker.go | 426 ++++++++++++++++++ time_picker_test.go | 153 +++++++ time_range_picker.go | 102 +++++ time_range_picker_test.go | 75 +++ year_picker.go | 77 ++++ year_picker_test.go | 87 ++++ 45 files changed, 3943 insertions(+), 1 deletion(-) create mode 100644 calendar.go create mode 100644 calendar_test.go create mode 100644 date_picker.go create mode 100644 date_picker_test.go create mode 100644 date_range_picker.go create mode 100644 date_range_picker_test.go create mode 100644 date_time_picker.go create mode 100644 date_time_picker_test.go create mode 100644 datetime.go create mode 100644 datetime_grid.go create mode 100644 docs/components/051-calendar.md create mode 100644 docs/components/052-date-picker.md create mode 100644 docs/components/053-date-range-picker.md create mode 100644 docs/components/054-time-picker.md create mode 100644 docs/components/055-time-range-picker.md create mode 100644 docs/components/056-date-time-picker.md create mode 100644 docs/components/057-month-picker.md create mode 100644 docs/components/058-year-picker.md create mode 100644 example_datetime_test.go create mode 100644 messages_datetime.go create mode 100644 month_picker.go create mode 100644 month_picker_test.go create mode 100644 testdata/snapshots/darwin/calendar-dark.png create mode 100644 testdata/snapshots/darwin/calendar-light.png create mode 100644 testdata/snapshots/darwin/date_picker-dark.png create mode 100644 testdata/snapshots/darwin/date_picker-light.png create mode 100644 testdata/snapshots/darwin/date_range_picker-dark.png create mode 100644 testdata/snapshots/darwin/date_range_picker-light.png create mode 100644 testdata/snapshots/darwin/date_time_picker-dark.png create mode 100644 testdata/snapshots/darwin/date_time_picker-light.png create mode 100644 testdata/snapshots/darwin/month_picker-dark.png create mode 100644 testdata/snapshots/darwin/month_picker-light.png create mode 100644 testdata/snapshots/darwin/time_picker-dark.png create mode 100644 testdata/snapshots/darwin/time_picker-light.png create mode 100644 testdata/snapshots/darwin/time_range_picker-dark.png create mode 100644 testdata/snapshots/darwin/time_range_picker-light.png create mode 100644 testdata/snapshots/darwin/year_picker-dark.png create mode 100644 testdata/snapshots/darwin/year_picker-light.png create mode 100644 time_picker.go create mode 100644 time_picker_test.go create mode 100644 time_range_picker.go create mode 100644 time_range_picker_test.go create mode 100644 year_picker.go create mode 100644 year_picker_test.go diff --git a/calendar.go b/calendar.go new file mode 100644 index 0000000..b3ebeb2 --- /dev/null +++ b/calendar.go @@ -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() +} diff --git a/calendar_test.go b/calendar_test.go new file mode 100644 index 0000000..d1ce6ca --- /dev/null +++ b/calendar_test.go @@ -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 }}) + }) + }) +} diff --git a/date_picker.go b/date_picker.go new file mode 100644 index 0000000..c17fe93 --- /dev/null +++ b/date_picker.go @@ -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 "" +} diff --git a/date_picker_test.go b/date_picker_test.go new file mode 100644 index 0000000..45a97a8 --- /dev/null +++ b/date_picker_test.go @@ -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"}) + }) + }) + }) +} diff --git a/date_range_picker.go b/date_range_picker.go new file mode 100644 index 0000000..0ce0323 --- /dev/null +++ b/date_range_picker.go @@ -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()) +} diff --git a/date_range_picker_test.go b/date_range_picker_test.go new file mode 100644 index 0000000..0de6cae --- /dev/null +++ b/date_range_picker_test.go @@ -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}) + }) + }) +} diff --git a/date_time_picker.go b/date_time_picker.go new file mode 100644 index 0000000..b5c60ce --- /dev/null +++ b/date_time_picker.go @@ -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") +} diff --git a/date_time_picker_test.go b/date_time_picker_test.go new file mode 100644 index 0000000..d10c0dd --- /dev/null +++ b/date_time_picker_test.go @@ -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}) + }) + }) +} diff --git a/datetime.go b/datetime.go new file mode 100644 index 0000000..9da7642 --- /dev/null +++ b/datetime.go @@ -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) +} diff --git a/datetime_grid.go b/datetime_grid.go new file mode 100644 index 0000000..7cb7c48 --- /dev/null +++ b/datetime_grid.go @@ -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 +} diff --git a/docs/components/051-calendar.md b/docs/components/051-calendar.md new file mode 100644 index 0000000..72d7a72 --- /dev/null +++ b/docs/components/051-calendar.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/052-date-picker.md b/docs/components/052-date-picker.md new file mode 100644 index 0000000..4a7b7a4 --- /dev/null +++ b/docs/components/052-date-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/053-date-range-picker.md b/docs/components/053-date-range-picker.md new file mode 100644 index 0000000..f400753 --- /dev/null +++ b/docs/components/053-date-range-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/054-time-picker.md b/docs/components/054-time-picker.md new file mode 100644 index 0000000..3740297 --- /dev/null +++ b/docs/components/054-time-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/055-time-range-picker.md b/docs/components/055-time-range-picker.md new file mode 100644 index 0000000..377fe2e --- /dev/null +++ b/docs/components/055-time-range-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/056-date-time-picker.md b/docs/components/056-date-time-picker.md new file mode 100644 index 0000000..b6dcb14 --- /dev/null +++ b/docs/components/056-date-time-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/057-month-picker.md b/docs/components/057-month-picker.md new file mode 100644 index 0000000..c0eb9e5 --- /dev/null +++ b/docs/components/057-month-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/docs/components/058-year-picker.md b/docs/components/058-year-picker.md new file mode 100644 index 0000000..2ff6c6f --- /dev/null +++ b/docs/components/058-year-picker.md @@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 diff --git a/example_datetime_test.go b/example_datetime_test.go new file mode 100644 index 0000000..be9afcc --- /dev/null +++ b/example_datetime_test.go @@ -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) +} diff --git a/examples/gallery/pages_datetime.go b/examples/gallery/pages_datetime.go index ec8e090..888c49a 100644 --- a/examples/gallery/pages_datetime.go +++ b/examples/gallery/pages_datetime.go @@ -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)` +) diff --git a/messages_datetime.go b/messages_datetime.go new file mode 100644 index 0000000..1d44b95 --- /dev/null +++ b/messages_datetime.go @@ -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", "下一个十年"}, + }) +} diff --git a/month_picker.go b/month_picker.go new file mode 100644 index 0000000..b88359a --- /dev/null +++ b/month_picker.go @@ -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} +} diff --git a/month_picker_test.go b/month_picker_test.go new file mode 100644 index 0000000..0d2f4bd --- /dev/null +++ b/month_picker_test.go @@ -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}) + }) + }) +} diff --git a/testdata/snapshots/darwin/calendar-dark.png b/testdata/snapshots/darwin/calendar-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8a00542d15c18c635c025309e392e7dac381343e GIT binary patch literal 17972 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
89m%nA2SD|evH%hTeJ`8zhYToafD z3IE%mo`r}4-b=&5$QGS{m%qQ!@QRS3Gy9Z`R3#;q-gjb!r)rHwnWP|5N-0xF>GSk3 zMC37#5#!=|v2*;#f>VLKm#=R6w6rDt6(wRbQ2)~i^nbgkx({%pl;Qi~)>RWnI z*6hULp?R)3i_Yn4Y6jCLQy{P2=BatQ__I|T1yc&I_83ll4|7z}p+;hd)B6Ttol6bQ zmSq&WKMV57*)e$K)CAkaF(mWIv~$d8A5!_^cj{uOY$&D*tLJ}wIxslk)#~{c{tvUX zpskkZPJLi*S7r7=?uXoBWu?(qSL5~j@o>9+R(hE@)zAZ2;=EIi)*^`=#}+Q47)dI+ zDmsNfRDTtD{LWu@`*yM0%NId7qD(WK7oE&qp+X{^4T3%JxBrO+VpZXUyEy9x zxo%D}zJRu@pn{5;gZhtuFLJF;Dw36*MN0ATC>`WrYIcMA%JQm~w)*Hnou3d+5?0)9 z;pW%gzujB*myd1CK`FkNfEAnj?V0Vwxx~;fD6*vSJq|jhYNmU}4$V3ic|SCh`PjMp zA>Hag)^giKb8q*0ejAEP4NGx+U;aCiJ&{dos{#}PS~-269rVTE+L8&7loEFfLz_Fi zEK#JQHw-iyDIbwSJDRLDY~NoOgb%FNv}t~7W)A5n`d07vX+LLyg25tBC7l+9Z-|>H zrodRmA^vLn(cPi#qB$au7&UCzcw}tEc*`j$vG*L0lu)n5`)IWuh!wKKl`_g6n;{WJ zsl&+mmA(HXWGJdmlQHExIk`~PY;4+q=}nVKGi>uANf315;?a|L2am|OGoXtcs)JE( zCI72E1utjD(NE>K<#+P8KTLx^bVKALi!9_~#j~kXsHM-;cxR&v-~<0!EOQyF&|>3- z9z8pA#V~yX_CSWg3-#b5X60U zw_loZk_C%LNQg*z?vjjdXj`SaswaNyF_DocXXjZClMn-J`PyD`ObL=e3n;>_mk6dcR=@C{oJGb#_Vsqqr6(=kYqxq3~{+|sqk>wF&duApK@H(M6MF1 zu0_J7`no4WTa?};dYNSRdRGYv2@uoP)YKfM9hFQ*c7+@4R7+Qm42?h@drTRWibs|; zk&6F;Ot5Qlo!Letday^%CmzHimQ8^ZiJ?n~0eiW+f9FXU!}-@Yde@mJqr*;GMh1yK zn#{6AAZzxhqNIguqb`-f26x#~eSTn|pS87GEgB^n%jar3rN}&<3igwbtuJFlF&D%` z4I5N8G<>eWt4+=Ag~Zd^FxQQ_i`jCMYeqIA@h-APeZ0W(!MLZgEW<++eh~w*jMzEK)`xFpRt9hf!!j;AHSAfJ_T7T9G;r z2>|5kQdC@Q&!adC2K@JvESp2nkEExwHIPI70Doq`6Z#f77Jn!>JpKRw@_$(e0su}x Y?BCoA*t$%@|L9XnOkNZutRMK_0F}iG=Kufz literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/date_picker-dark.png b/testdata/snapshots/darwin/date_picker-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7dd35a56d6570272903a79f0badc180a65c9366e GIT binary patch literal 7489 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/date_picker-light.png b/testdata/snapshots/darwin/date_picker-light.png new file mode 100644 index 0000000000000000000000000000000000000000..88cd8ee679a23c3830c09e409420bc49b304c76e GIT binary patch literal 7301 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 B_CWvu literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/date_range_picker-dark.png b/testdata/snapshots/darwin/date_range_picker-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..baf00167625db783f6c79932d4c7fd1ebca71caa GIT binary patch literal 6730 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/date_time_picker-dark.png b/testdata/snapshots/darwin/date_time_picker-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..c72db7b99e34341a1fea99795394deb9489f6d06 GIT binary patch literal 6875 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 Tyfec7v;v;1Xe*Z~T7>=w9^uq9 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/date_time_picker-light.png b/testdata/snapshots/darwin/date_time_picker-light.png new file mode 100644 index 0000000000000000000000000000000000000000..d4d703bb23f1312658dba9d0ad82ee3cd754ba72 GIT binary patch literal 6542 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 E1;Aoab^rhX literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/month_picker-dark.png b/testdata/snapshots/darwin/month_picker-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..79c9a4db3ee1b1b98ce3c2effe8501c8cd14d65d GIT binary patch literal 5440 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 SuNwAe6i`vnl&^ek9`YYqMH{#P literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/month_picker-light.png b/testdata/snapshots/darwin/month_picker-light.png new file mode 100644 index 0000000000000000000000000000000000000000..18979ce59fae4de361162722505bdc7e9c018c95 GIT binary patch literal 5381 zcmd5=cTf{tkVg=V^d?n$2kGqtK@=jPgFvJcI#NQU_o|{0ilO%)2z(&DB=jOplqLdF z0!WR3lt2(dz1-Z~|9AJ-z1exQZ|1$7-JSQ_mSAE8qM_uVBq1T8(bWN(l8}%Bh`b4a zl(-*bINp+wFpug2pO}Xf{3#5zWg5O17O5C`$rDHac;g)}e|AU=;KUn#58A-pqN)Da z+@w!D(Q=UN@Z6eiV3A)O0MLV~!@)q{(+zr2dfpi8@Au!|2IxlB30Zx7H~T!wbLTkp zdPJ?MsiS4$Pxw~u{F~8e>)6M<^nlx+pAJNmu#r%cP>_(t-0CH9Ad&h1d;Cg8A$PrQ z2)2EX48CANenaR9wbI=V1MwrikTlFO}*2S*WXk2Lli=+X4(uWV?&biFlymC26c;I8cDH}w4UfbuKJP+QvlnASI~ zSJJ~ZQ!%&P)?}EXPvvoFI?Z=B?`&L?Y|3ttu@To?^y2^3aVC^9?*`Yt9{F{?wsH+^ zvwSe5&pY{6^uOdUo*r)<=j#inFMS!U7L~yI{!^Wp*k_ayLi4pJd}0)1tEK-9^Y!ys z|EtHmzZ7{V!&!(X*T@akOpUVnxVQ<66m0nZW14F&oF2FxZkn4hyecSC(22-R=y+0m zD*fL!D*a-yv-F|BMpfCfrj z1obz$+uI}8Qn*d*>>{0=OEWWt5OaZ@me!_j12X1d@E{kZqLLE$dE*1gvGCLVH_a2N9!6IWBL!4NkGV}b&|Q>30>p1HUX;$3X)HT)IJ7ak5ZZfQZm zKW?)%D3p?1?tKo9%cX+(v$974x`Js;pcG1S($og$mporP15_lp@zfd+%q@oyDw&rm z_xJtB5s|*FrJP`}xu>VsYT16Eppuawqa-Dbtjr%9h)urV+B9OWwfNpQ+Oxy>CX>H@>4Z*)ieF`Ui@L=rtK;um8dSjG5YM3bn zzk8~uq!xOKEl-wBN%>oMM{B|pSK_*`uwYSJRAC~@;Kffy8V7TO2&IpJt9RbJN^}WF zsYYK13(@Hd@bj0JqH1U%vGl4z3fF6IB;L{vFU-^E85*v%@`crR6?Sxd0`k*PQf~T% zG&DTCGe2Km;SK~r<@elG!6Ohqm_19d&mzkK@RcVU_NM(hZ@11h(B`EtQVYX>oA=)oDq9F^*Rr#-1GTO1F{kMW zVogoq+{`))3l!ANxS}1cy_rv47wV6YS@zHV-bvQx+;BKhbd`&J{Lc2?c;xlv>3|u2X~q1sTO|?78j0f$axqMevM8)D>79K^qis=D9^pI42a^pd0H} z{;jKV&S6W%;Urn4v3!O?2)YMqB=>bAOP0_lb6frAsww}5AD}~ z(zEg-;W~oU>`H&+4|LB?o_wva(-=~H`0yHrWto-|5a>774o~6SJ^5jLmw~|%@WCHD zE>ff?B_^gQzl-IO2_9ve7#&^t`MM4SAeRDgJB+D?yoA}7HDe5 zu~Q3NQi}Sl^qrlhGgA)s|A?Ha+GM6A*I#{ZzJD&OBqEaCCv$!zap3VIf?iV@wrtJR z9ks8vqIDQA_KUon3?b=AU_aH1rDM=!od(@Y2=w_&ZjRgfy$E zuK%8iT&Wf_6FzaE-Q&tBxZ|hvB|60TWLqtkM5gl|n?g%7kyI;QKxTJ`UFU+V`M)>! z%x6NRC$|IM@{I?8!B9J{y}x}KCZd=guT)CIL9eeH3mieFm%i=dOUom7VTo2dBW7*$ z=pkb~fBFckoZM~FSV&Ayu`WH@pY@FyYhxu{b6N`)Q%I{P3% z3^wuY?JSs{=zyEpH+=eIl9HRyTqPxYvreLr@JEu7zu&EDQgHk7u=-&ZHzN%a{ZW2;bt1sKASP3_T4G&ukpI~~;m7`+nFx?@YDY#6U>aMty zRDvZ_Wo4y`<7dkcCIfCi_m`TYw?;jb$ZBv8KPPFM&9~z2biQu%!Awm~$|&{mJ{xhe zvdjs7pkirdg=%glVsq}xm&pkj%QERwSNcy3#>or5Bm)OOE&X1ksFvTY)Y!d`cvTwt zD=zf8f&QiCrgJ|?hg>K})?pnH>Q6U3RA}@MB4X_$q5=1Qwcl1<`q}N5!EzAYjMK zi$Rl?_^JZA=UjXs;{u3EVKx0RnWG-fjnMgcQqDEkGOXN-^mr9me;FPc=#A>9$F9`b zwm4Xi>UYRN#CkspS$Ep&>-oyRANdc!Oucsw$-?IJEva(n#>KqmiQ@HeB z`uWW@2;@EH4GA}nITU=AWpvN!{$|>=GyDK z&WqA1DDQa%@3UQ&hDO+ypkM*DO-ligpYL_mr+I@ZD7>KcekM*ZgLY(INE%!l&X*;!!?|KTrjI)c`6xlidR znW5~|rWa|2l!j%gvA3}E{+HF!h^UqBBVFYLpi@o`ATRHb<9(X0Aq^EHH!}4h^O|oc z2gh7WQb~!EMh|S0U57EDS;aFlTYy26jwkD@i<||TVNb}#ZFXX^9`@|JmI@miwSdY! zdQAaPJ27&i8sosWwwl6bPEIIv$1_7iXk?^%)FT(=IhLFG6fSkI*`%O5 zL^Q#H{GHr)-)O!z!FK5~CIoD!ZIZ^yT%S2&-iD}MpGstn74E|6^VBxszRq3a$R1v4 zak%R2J=HB5p2d$(?)DbzeCLo)N@6b+NisJ2$5-H(Befq2^(Zr~tO^+iJ&HQ0p3Qn* z4X^t2>5UfT;VC{BNDqn$P>4-%Omhw^`I)zwr6|1FdR2bx4XVB7|(#Zy|!IxxhF3($s-eEWoem&HW3yN z==~+&(*Kq=9uycj{FkR-6)Rypzy!EWC48*CrI=vuO`WzF_9 z4zTeld|8evUg9OFDj|o>%q-k<*j73UB^VPndjGvFy~LHCGwdgtWa(UwL9B?qf%Z^R zl^)CfWZz5<}KqUM@L4+M%ubw$f9a~EaZir zE?=cY7E~Bl>Qf6p@WAP;d3E`a}MpjH72P%1tB$W2;VAU3m`8f6R zlGHoR)58(k-x)#JnJR;Cm>ClrHs-|k<#~N!7C0+~ROZNJELpf}PtfEF=7EqM1x z(Zlu-Tw`r*N_@N%)6)0SVJ4>Z)1m$5rf^w?25*ReZM8#g{^{^%8~&O~M>-x^=DM57 zzzeq^R`GypWqkP$jiO}To78?&(%3K9qeIivHUH2$_CW^$TCILJgy`xaBCUjf{1{6| z1(!3O_|3`oV)r-oeHV#L-F3SAP8Jr~*;zhI^H@nrEts5dwY^5JdsyZCI8tH>Dny9nYwZdY;BMH z{G&TUD!WmCvzxGba5$G;eCN^czb!P3xdrK&C)jk6RyzFkjDRnko)XeTtmMuoAOZUr z!qHJ8lSo>Yq@5$wS>2sB%REy1Jj7 zz`Hv;)SO$5zFK%nOD_~MpOtFs z2~JJEumAjct)KuSmR?bJ_UL|3sfDNE*?C9sL8LNvF)eMXy7~{1;wc_T?5@jOSy;G3 zfi9t;Pdz=)=kZC!4ugK@GoOBzD8WvS&Xe@YzUF6SIo!3evEfukGwl9^jgNJGbNg9q z%P_uPZMnh7ur?*St!0jIC7d~WKO0l_c)b_HJq2t;t0eTyy2;V zQ%lcjmKS#eZ>U~%M?%s;JBI<&t)XEGM+6ILTOEDXn#!6z^%)RteD;EPq5{WWI&Qr& zy2pO3%DT-5id}quxxYY;(#Z1r^xI_R5KQUcY`(cH~fA=^zN0^bJZgF>VGjW0j!f)A9U&81}>ZuLi7 z<5-7RJSMV|KkTAb%aqUA=07bn2+_ZTUCYWLOwOu5JOLRS7u{?>K$z(8r$d@7+G}cR z%Gx$JVIJ>ZYf@&4PXGg-U+@p~fXa(26Kbo@+XYnF|IUk6;!8P_I=i5DoQ*FDo}Kt7 z!!?&rZw1jTVoIXXH`z!H>@e@p5oKlsyv}||j$G0C zU=p_D&kU~ij3m2?4nPg3A_2GKuCKZdR5bdZ%rp^W85J|^)Liw_2bcLfP z_`ss)r|E*9~CL@cy6!}dybJGw!EDRuXWRB_kMzY!n zN{lgry6*}nYO7}h#=wu}+cnEslZ!LGCRhw)qO=FNr3b`kn-y|)%`7s8bxh|zuw2|r zd3&`U#Yr&{ca3c-?kUKqQdNecuOh&(lc1|EyD=MGB?``0$$di(&h@fMO8*O8{x^F6 g|G8`C5JF30VY^|w53x@pjyFkkpBe#cH67yq1Mf}kssI20 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/time_picker-dark.png b/testdata/snapshots/darwin/time_picker-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..12a4377873f02a260da347ac94256addf6b266cb GIT binary patch literal 5336 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/time_range_picker-dark.png b/testdata/snapshots/darwin/time_range_picker-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d3f8038f0e4c69c5aedbdd29ac629054c7fc1a68 GIT binary patch literal 7143 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/year_picker-dark.png b/testdata/snapshots/darwin/year_picker-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e171caa7110bcb8aa7e165c84b2c73b631481ecb GIT binary patch literal 4302 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/year_picker-light.png b/testdata/snapshots/darwin/year_picker-light.png new file mode 100644 index 0000000000000000000000000000000000000000..e3f2cff26c5ba7de9d012a82505505fcb0a42bd6 GIT binary patch literal 4177 zcmdT{X*3&Lw@&M=sb;0ADp3`R7;7F%gbLC|P&KthQFF~DH4hO}s;OCtS$V0U#7xbq zRFSHhUh_N;L5iEZ)?N44x9*So>-)~x>#TjwkG=QV`|Q1+gGD}kz|6?Q2mk<>;kqys z0C1U}$|LD7Q`h}T9t;4$286>jjbCSOeDHt5x!lm!+6RrSkmR_-00QwEzprAbdv4`S z%H^JjeXLz}m9O-1e!zyOrN<^|B=2!q2neL_;jZm+#g6kD8%&cX@h`S;AO`}(&^IZ3 z2an2sYHlmHc-DL_y&oj2^(IT<&ys~bAv)cejjA2M_BNWCjVAh1C%^_E9Rk!~0@424 zZZ(6uGz8=W`ai^26lh20J`P2F1bG5>u4{sgzCj>rGe`S7ygCa6w;Z>NTAxw{8xe?T zrlQ*W#Rcdcz(1d8dORdBfhU%y{AXdBQ_!h3+o`nyLJPZ*#Kybtrv@6ehI1 z{qd#5kcP;HGnKKj>F7%Vb3>dL>*zVjXAwvul6d-r7$8VXPaV;%e*a&G5w6?3?O&{M zk*_R@F_*WHNZ~{ScEFc?uKJYXd?*1~Z?@DF6PE2A#wKmAUb*}bF(9=~#BzJD+hg(7 z)oGED`=FG>FJFLB>=3|CdMMRaQcnXZgHOxN*EhhX3JF5a>GAe729n>`p+wm7g~3!& zoQ{CkXBz)=Zv-x9i4RSP?Jqer%uG)|&YQtVTIhWyAl4NkzOndt`cAjnnhvNP6<69~ zlV`O5Y?$o}w#1h~22ynv0+ybdkc<+0C%)r!0d4#mL?6n*YV~jPgwoRL#6D z{`%Y*sp%Y>Q+FlrE|N%RO48SsuRj_8#pE5CvUV_)vbIv<#>Kd3`porQ;OsN%;~hMl zon7BFsKJsHGBn=KI+ftjTL9jJ2MnV&>-4tez4Hu3R_7qWB-&-C1T(NTn zcDZwh!{#Lyy?HjhO{hm2Kb|D_q!?_15{p zz7MQXqW)n1QMei=40u`v^fn>(%yUTCoxc%ZdRw$S6c;BuQM<(}Kk0R_0*Zvz+xxuN zJ-B!GZi2nbXTgB&7_QPsAC%#FKk6)}(@E=(gLtGJ1|Mutm}1Y^;4#Z34}VM9ZsK*Xe~YI50A(k~=#UNOoV$EW^hbwkP5q^2OgCK!nvO8wF4 z&vT8jtDav*v|sGnty^2+YEXL&Ptzecbj8qU1XuKZbh6R?jK0)8uTbMO(jMrI!2g);(h`SDbNzQWB44r3WjOoVl z_b)48q6W=tU8J$Fy`xwp{t}BuFLPP;lByIV7Y}ee5oqpH% zP7FC%zW`MfVc_l<^hRi12Kw@puDWm_(?e7!>5I(??QurxpWkp|!H6`ISfhs#Bx+5? z?-+q7ciS>a@_Wb|yUR?!!^$XLmySM|ph5s6W?!dk1@` z%1(jWj*iG3pnLHB3xbA)iVEo&Zypjg$GIRgFh{PB96MySwk@hY zS5}^E@p1l28DWPqTb5(f%xnz)uJsyen-_?nl>%}V*p2P)X+y1LvvKovOH{kHqXKMDZoUb~ zAyNESCMKphtTev@E|{&y=`GwOyaHTU7!?&oFJQ}w_X8vDNz1LStPqgJorcZr$qyYA zE57RMFM66~z~&a$!{_S^z_PhF9Pewi6(=4UL^)+i$(QFB{~`^=o94w;M|7j{X~wD|y3i@|cxbkR4$Wfn-8MD? z%4uXo8*7F#p8WZU<@>$8sua;H zP!yT~jIy&kQ+1!_jTjumcHOQVj@W$2l!YpK}wXw`U3 z0?Ve)=*9HWzU$Kdr{!fdh2ku$eR5^oEQMcAj?1$7$mGRn*-eGJ#xXgGKh8?l3cI_% z?!|oby8hWTWGHqBK6N`H=tRij!9mtXqaq*>xUk4YC1cZ%QTJS3Dc{du{mcn}@X4xQ z64G4bwXAR{^t`2|q`opaSy*4)%-Y&o-EsJ@!@DOZk!z(8iRj!GfwL>!vhFcDE;>p{;3WC0E`oqfF&E+ZK zN*7vp0stKq(KXH-2|>Xt!DMI5>UD9eFt)0#nc&&I#UdjkvfEU`d|d{nCo^(3z51x!ICWRg>Q7$q^FJepMH}Af^uJa#R8D zqv-Rs888^Eeo3(0@@sp;n>*18s@^CfeGb0QQLkX{TiU+EV5X@9_#yAb49yHEA~#pv zx!B!u4<3QSvfh;Uo{%Tu8R>H}Go6{4rEA><@VN!s9u)wikiMHa~5S+aPDLcS)hPpXvMr zCw^89aQ;mP^AO7ZAm*+scdr!51vQ*i7 z7d>X5&fvX1J3-)DBNgDz#RGi&LA_#udrUkk@!ED*V1I4uPDv z{rGV4@?LslqXk(U@aOJ1u31Ib5fs%9U;@(thsAXLAUOm&j}iR_uXV(=$fp`=A)I43 zyi?5-?PO*y-1XiqhO?d$Zk@1QG$kq%DQ6ugs>eZalbYr*YYrw%CaaR^gWI>`m+Xhd z*xAPo+5ogtA(xn*189K%n&l1^h)_Jnc$0)I^cV!vExZ~d>#dlXtn^aIV{5uEKE6ju zxJz37HYr;AIVrD999whwl2AlhF6#O89oKTfhb}s0Wft&t^i!QW3vzWEaHR@JpV6zb rt@z(r?E8PH|JO$Sr=2df?Z^V+#BW= 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 +} diff --git a/time_picker_test.go b/time_picker_test.go new file mode 100644 index 0000000..a5eb6a8 --- /dev/null +++ b/time_picker_test.go @@ -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}) + }) + }) +} diff --git a/time_range_picker.go b/time_range_picker.go new file mode 100644 index 0000000..4776748 --- /dev/null +++ b/time_range_picker.go @@ -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 "" +} diff --git a/time_range_picker_test.go b/time_range_picker_test.go new file mode 100644 index 0000000..44f3f2b --- /dev/null +++ b/time_range_picker_test.go @@ -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}) + }) + }) +} diff --git a/year_picker.go b/year_picker.go new file mode 100644 index 0000000..b607152 --- /dev/null +++ b/year_picker.go @@ -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} +} diff --git a/year_picker_test.go b/year_picker_test.go new file mode 100644 index 0000000..da6b59b --- /dev/null +++ b/year_picker_test.go @@ -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}) + }) + }) +}