Date & Time: components 051-058 (wip)
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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 }})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)`
|
||||
)
|
||||
@@ -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", "下一个十年"},
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||