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 0000000..8a00542 Binary files /dev/null and b/testdata/snapshots/darwin/calendar-dark.png differ diff --git a/testdata/snapshots/darwin/calendar-light.png b/testdata/snapshots/darwin/calendar-light.png new file mode 100644 index 0000000..8a54f5a Binary files /dev/null and b/testdata/snapshots/darwin/calendar-light.png differ diff --git a/testdata/snapshots/darwin/date_picker-dark.png b/testdata/snapshots/darwin/date_picker-dark.png new file mode 100644 index 0000000..7dd35a5 Binary files /dev/null and b/testdata/snapshots/darwin/date_picker-dark.png differ diff --git a/testdata/snapshots/darwin/date_picker-light.png b/testdata/snapshots/darwin/date_picker-light.png new file mode 100644 index 0000000..88cd8ee Binary files /dev/null and b/testdata/snapshots/darwin/date_picker-light.png differ 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 0000000..baf0016 Binary files /dev/null and b/testdata/snapshots/darwin/date_range_picker-dark.png differ diff --git a/testdata/snapshots/darwin/date_range_picker-light.png b/testdata/snapshots/darwin/date_range_picker-light.png new file mode 100644 index 0000000..e273300 Binary files /dev/null and b/testdata/snapshots/darwin/date_range_picker-light.png differ 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 0000000..c72db7b Binary files /dev/null and b/testdata/snapshots/darwin/date_time_picker-dark.png differ 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 0000000..d4d703b Binary files /dev/null and b/testdata/snapshots/darwin/date_time_picker-light.png differ diff --git a/testdata/snapshots/darwin/month_picker-dark.png b/testdata/snapshots/darwin/month_picker-dark.png new file mode 100644 index 0000000..79c9a4d Binary files /dev/null and b/testdata/snapshots/darwin/month_picker-dark.png differ diff --git a/testdata/snapshots/darwin/month_picker-light.png b/testdata/snapshots/darwin/month_picker-light.png new file mode 100644 index 0000000..18979ce Binary files /dev/null and b/testdata/snapshots/darwin/month_picker-light.png differ diff --git a/testdata/snapshots/darwin/time_picker-dark.png b/testdata/snapshots/darwin/time_picker-dark.png new file mode 100644 index 0000000..12a4377 Binary files /dev/null and b/testdata/snapshots/darwin/time_picker-dark.png differ diff --git a/testdata/snapshots/darwin/time_picker-light.png b/testdata/snapshots/darwin/time_picker-light.png new file mode 100644 index 0000000..de6c31a Binary files /dev/null and b/testdata/snapshots/darwin/time_picker-light.png differ 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 0000000..d3f8038 Binary files /dev/null and b/testdata/snapshots/darwin/time_range_picker-dark.png differ diff --git a/testdata/snapshots/darwin/time_range_picker-light.png b/testdata/snapshots/darwin/time_range_picker-light.png new file mode 100644 index 0000000..e32d8f1 Binary files /dev/null and b/testdata/snapshots/darwin/time_range_picker-light.png differ diff --git a/testdata/snapshots/darwin/year_picker-dark.png b/testdata/snapshots/darwin/year_picker-dark.png new file mode 100644 index 0000000..e171caa Binary files /dev/null and b/testdata/snapshots/darwin/year_picker-dark.png differ diff --git a/testdata/snapshots/darwin/year_picker-light.png b/testdata/snapshots/darwin/year_picker-light.png new file mode 100644 index 0000000..e3f2cff Binary files /dev/null and b/testdata/snapshots/darwin/year_picker-light.png differ diff --git a/time_picker.go b/time_picker.go new file mode 100644 index 0000000..12a4391 --- /dev/null +++ b/time_picker.go @@ -0,0 +1,426 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TimeOfDay is a wall-clock time with no date and no location. +type TimeOfDay struct { + Hour, Minute, Second int +} + +// Valid reports whether t is a time between 00:00:00 and 23:59:59. +func (t TimeOfDay) Valid() bool { + return t.Hour >= 0 && t.Hour < 24 && t.Minute >= 0 && t.Minute < 60 && t.Second >= 0 && t.Second < 60 +} + +// String formats t as 15:04:05. +func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d:%02d", t.Hour, t.Minute, t.Second) } + +// Compare returns -1, 0 or 1 as t is before, equal to or after o. +func (t TimeOfDay) Compare(o TimeOfDay) int { + a, b := t.seconds(), o.seconds() + switch { + case a < b: + return -1 + case a > b: + return 1 + } + return 0 +} + +func (t TimeOfDay) seconds() int { return t.Hour*3600 + t.Minute*60 + t.Second } + +// TimePickerOptions configure a TimePicker. +type TimePickerOptions struct { + // Seconds shows and edits the seconds. + Seconds bool + // Hour12 shows a 12-hour clock with AM and PM. + Hour12 bool + // MinuteStep and SecondStep must divide 60; zero means 1. + MinuteStep, SecondStep int + // Min and Max bound the time; a zero Max means 23:59:59. + Min, Max TimeOfDay + Disabled bool + // Label names the field for assistive technology. + Label string +} + +// TimePicker edits *value by typing into hour, minute and second segments or by choosing in a popup. +// Times outside the range or off the step are reported and not written. +func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTimeResult { + cfg := timePickerConfig("TimePicker", opts) + if !value.Valid() { + panic(fmt.Sprintf("mujica: TimePicker value %v is not a time of day", *value)) + } + col := ui.Column(c).Gap(Density(c).Unit()).AlignItems(ui.Start).Disabled(opts.Disabled) + draft := ui.Local(col, "time-picker-draft", func() TimeOfDay { return *value }) + seen := ui.Local(col, "time-picker-seen", func() TimeOfDay { return *value }) + problem := ui.Local(col, "time-picker-error", func() string { return "" }) + if *seen != *value { + *draft, *seen, *problem = *value, *value, "" + } + changed := false + col.Children(func() { + cfg.invalid = *problem != "" + if _, edited := timeEditor(c, draft, cfg); edited { + *problem = timePickerProblem(c, *draft, cfg) + if *problem == "" && *draft != *value { + slog.Debug("mujica: time_picker change", "from", *value, "to", *draft) + *value, *seen, changed = *draft, *draft, true + } + } + datetimeError(c, *problem) + }) + return DateTimeResult{Element: col, changed: changed} +} + +// timeConfig is the resolved behavior of a time editor. +type timeConfig struct { + seconds, hour12 bool + minuteStep, secondStep int + min, max TimeOfDay + label string + invalid bool +} + +// timePickerConfig checks opts and fills in defaults. +func timePickerConfig(name string, opts TimePickerOptions) timeConfig { + cfg := timeConfig{seconds: opts.Seconds, hour12: opts.Hour12, minuteStep: max(opts.MinuteStep, 1), + secondStep: max(opts.SecondStep, 1), min: opts.Min, max: opts.Max, label: opts.Label} + for _, s := range []int{opts.MinuteStep, opts.SecondStep} { + if s < 0 || s > 0 && 60%s != 0 { + panic(fmt.Sprintf("mujica: %s step %d does not divide 60", name, s)) + } + } + if cfg.max == (TimeOfDay{}) { + cfg.max = TimeOfDay{23, 59, 59} + } + if !cfg.min.Valid() || !cfg.max.Valid() || cfg.min.Compare(cfg.max) > 0 { + panic(fmt.Sprintf("mujica: %s range %v..%v is not valid", name, cfg.min, cfg.max)) + } + return cfg +} + +// timePickerProblem explains why t cannot be written, or returns "". +func timePickerProblem(c *ui.Context, t TimeOfDay, cfg timeConfig) string { + switch { + case t.Minute%cfg.minuteStep != 0: + return fmt.Sprintf(msg(c, "time_picker.minute_step"), cfg.minuteStep) + case cfg.seconds && t.Second%cfg.secondStep != 0: + return fmt.Sprintf(msg(c, "time_picker.second_step"), cfg.secondStep) + case t.Compare(cfg.min) < 0 || t.Compare(cfg.max) > 0: + return fmt.Sprintf(msg(c, "time_picker.out_of_range"), timeText(c, cfg.min, cfg), timeText(c, cfg.max, cfg)) + } + return "" +} + +// timeText formats t for messages, honoring the 12-hour clock and seconds. +func timeText(c *ui.Context, t TimeOfDay, cfg timeConfig) string { + s := fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) + if cfg.hour12 { + s = fmt.Sprintf("%d:%02d", timeHour12(t.Hour), t.Minute) + } + if cfg.seconds { + s += fmt.Sprintf(":%02d", t.Second) + } + if cfg.hour12 { + s = fmt.Sprintf(msg(c, "time_picker.with_period"), s, timePeriod(c, t.Hour)) + } + return s +} + +func timeHour12(h int) int { + if h%12 == 0 { + return 12 + } + return h % 12 +} + +func timePeriod(c *ui.Context, h int) string { + if h < 12 { + return msg(c, "time_picker.am") + } + return msg(c, "time_picker.pm") +} + +// timePart is one segment of a time editor. +type timePart int + +const ( + timeHourPart timePart = iota + timeMinutePart + timeSecondPart + timePeriodPart +) + +var timePartNames = [...]string{"time_picker.hours", "time_picker.minutes", "time_picker.seconds", "time_picker.period"} + +func timeParts(cfg timeConfig) []timePart { + parts := []timePart{timeHourPart, timeMinutePart} + if cfg.seconds { + parts = append(parts, timeSecondPart) + } + if cfg.hour12 { + parts = append(parts, timePeriodPart) + } + return parts +} + +// timeEditor draws a segmented time field with a popup and reports whether the user edited *draft. +func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Element, edited bool) { + k := Tokens(c) + d := Density(c) + field = datetimeField(c, ui.Row(c), cfg.invalid).Gap(d.Unit() * 2) + open := ui.Local(field, "time-open", func() bool { return false }) + fresh := ui.Local(field, "time-fresh", func() bool { return false }) + field.Children(func() { + segs := ui.Row(c).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(cfg.label) + segs.Children(func() { + focusNext := false + for i, part := range timeParts(cfg) { + if i > 0 && part != timePeriodPart { + ui.Text(c, ":").TextColor(k.TextMuted).Role(ui.RoleNone) + } + if part == timePeriodPart { + ui.Box(c).Width(d.Unit()) + } + seg, done := timeSegment(c, draft, part, cfg, &edited) + if focusNext { + seg.Focus() + focusNext = false + } + focusNext = done + } + }) + b := datetimeIconButton(c, "clock", msg(c, "time_picker.open")) + if b.Clicked() { + *open, *fresh = !*open, true + } + }) + if !*open && field.Shortcut(ui.Alt, ui.KeyDown) { + *open, *fresh = true, true + } + datetimePopup(c, field, open, func() { + if timeColumns(c, draft, cfg, *fresh) { + edited = true + } + *fresh = false + }) + return field, edited +} + +// timeSegment draws one focusable segment; done reports a fully typed value. +func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, edited *bool) (seg *ui.Element, done bool) { + k := Tokens(c) + d := Density(c) + name := msg(c, timePartNames[part]) + if cfg.label != "" { + name = cfg.label + ": " + name + } + seg = ui.Box(c).Focusable().FocusRing(false).Role(ui.RoleStepper).Label(name). + Padding(d.Unit()/2, d.Unit()).Radius(theme.ControlRadius).MinWidth(theme.BodySize * 1.6).Center() + typed := ui.Local(seg, "time-typed", func() string { return "" }) + if !seg.Focused() { + *typed = "" + } + set := func(t TimeOfDay) { + if t != *draft { + *draft = t + *edited = true + } + } + t := *draft + step := func(dir int) { + switch part { + case timeHourPart: + t.Hour = (t.Hour + dir + 24) % 24 + case timeMinutePart: + t.Minute = timeStep(t.Minute, cfg.minuteStep, dir) + case timeSecondPart: + t.Second = timeStep(t.Second, cfg.secondStep, dir) + case timePeriodPart: + t.Hour = (t.Hour + 12) % 24 + } + set(t) + } + switch { + case seg.Shortcut(0, ui.KeyUp): + step(1) + case seg.Shortcut(0, ui.KeyDown): + step(-1) + } + if part == timePeriodPart { + if seg.Shortcut(0, ui.KeyA) && t.Hour >= 12 || seg.Shortcut(0, ui.KeyP) && t.Hour < 12 { + t.Hour = (t.Hour + 12) % 24 + set(t) + } + } else { + for n := range 10 { + if seg.Shortcut(0, ui.Key0+ui.Key(n)) { + done = timeType(typed, n, part, cfg, &t) + set(t) + } + } + } + text := "" + switch part { + case timeHourPart: + text = fmt.Sprintf("%02d", t.Hour) + if cfg.hour12 { + text = fmt.Sprintf("%2d", timeHour12(t.Hour)) + } + case timeMinutePart: + text = fmt.Sprintf("%02d", t.Minute) + case timeSecondPart: + text = fmt.Sprintf("%02d", t.Second) + case timePeriodPart: + text = timePeriod(c, t.Hour) + } + fg := k.Text + switch { + case seg.Focused(): + seg.Background(k.Accent) + fg = k.OnAccent + case seg.Hovered(): + seg.Background(k.SurfaceHover) + } + seg.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum").SingleLine() }) + return seg, done +} + +// timeStep moves v to the next multiple of step in dir, wrapping within an hour. +func timeStep(v, step, dir int) int { + if dir > 0 { + return (v/step*step + step) % 60 + } + if v%step != 0 { + return v / step * step + } + return (v - step + 60) % 60 +} + +// timeType adds digit n to a segment's typed digits and reports when the segment is complete. +func timeType(typed *string, n int, part timePart, cfg timeConfig, t *TimeOfDay) bool { + lo, hi := 0, 59 + if part == timeHourPart { + hi = 23 + if cfg.hour12 { + lo, hi = 1, 12 + } + } + s := *typed + fmt.Sprint(n) + v, err := strconv.Atoi(s) + if err != nil { + panic(err) + } + if len(s) > 2 || v > hi { + s, v = fmt.Sprint(n), n + } + *typed = s + if v >= lo { + switch part { + case timeHourPart: + if cfg.hour12 { + v = v%12 + t.Hour/12*12 + } + t.Hour = v + case timeMinutePart: + t.Minute = v + case timeSecondPart: + t.Second = v + } + } + if len(s) == 2 || n*10 > hi && len(s) == 1 { + *typed = "" + return true + } + return false +} + +// timeColumns lists choosable values per segment and reports whether a choice edited *draft. +func timeColumns(c *ui.Context, draft *TimeOfDay, cfg timeConfig, fresh bool) (edited bool) { + k := Tokens(c) + d := Density(c) + h := d.ControlHeight() - 4 + within := func(lo, hi TimeOfDay) bool { return hi.Compare(cfg.min) >= 0 && lo.Compare(cfg.max) <= 0 } + ui.Row(c).Gap(d.Unit()).Children(func() { + for _, part := range timeParts(cfg) { + t := *draft + var options []TimeOfDay + switch part { + case timeHourPart: + for v := range 24 { + if !cfg.hour12 || v/12 == t.Hour/12 { + options = append(options, TimeOfDay{v, t.Minute, t.Second}) + } + } + case timeMinutePart: + for v := 0; v < 60; v += cfg.minuteStep { + options = append(options, TimeOfDay{t.Hour, v, t.Second}) + } + case timeSecondPart: + for v := 0; v < 60; v += cfg.secondStep { + options = append(options, TimeOfDay{t.Hour, t.Minute, v}) + } + case timePeriodPart: + options = []TimeOfDay{{t.Hour % 12, t.Minute, t.Second}, {t.Hour%12 + 12, t.Minute, t.Second}} + } + name := msg(c, timePartNames[part]) + ui.Scroll(c).Height(h * 6).Width(d.ControlHeight() * 1.5).Children(func() { + ui.Column(c).FocusGroup(ui.Vertical).Role(ui.RoleRadioGroup).Label(name).Children(func() { + for _, o := range options { + var lo, hi TimeOfDay + text := "" + switch part { + case timeHourPart: + lo, hi = TimeOfDay{o.Hour, 0, 0}, TimeOfDay{o.Hour, 59, 59} + text = fmt.Sprintf("%02d", o.Hour) + if cfg.hour12 { + text = fmt.Sprintf("%d", timeHour12(o.Hour)) + } + case timeMinutePart: + lo, hi = TimeOfDay{o.Hour, o.Minute, 0}, TimeOfDay{o.Hour, o.Minute, 59} + text = fmt.Sprintf("%02d", o.Minute) + case timeSecondPart: + lo, hi = o, o + text = fmt.Sprintf("%02d", o.Second) + case timePeriodPart: + lo, hi = TimeOfDay{o.Hour / 12 * 12, 0, 0}, TimeOfDay{o.Hour/12*12 + 11, 59, 59} + text = timePeriod(c, o.Hour) + } + sel := t + item := ui.RadioBase(c, &sel, o).Height(h).FillWidth().Center().Radius(theme.ControlRadius). + Disabled(!within(lo, hi)).Label(text) + fg := k.Text + switch { + case o == t: + item.Background(k.Accent) + fg = k.OnAccent + if fresh { + item.ScrollIntoView() + } + case item.Pressed(): + item.Background(k.Selection) + case item.Hovered(): + item.Background(k.SurfaceHover) + } + internal.Ring(item, k.Focus, theme.ControlRadius) + item.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum") }) + if item.Clicked() && o != *draft { + *draft, edited = o, true + } + } + }) + }) + } + }) + return edited +} 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}) + }) + }) +}