diff --git a/calendar.go b/calendar.go index b3ebeb2..7c96a59 100644 --- a/calendar.go +++ b/calendar.go @@ -12,8 +12,7 @@ import ( "mujicaui/theme" ) -// WeekStart is the first column of a month. The zero value follows the locale: -// Monday for zh-CN, Sunday for en-US. +// WeekStart is a month's first column; zero follows the locale. type WeekStart int const ( @@ -25,7 +24,7 @@ const ( // CalendarOptions configure a Calendar. type CalendarOptions struct { - // Min and Max bound the days that can be chosen; zero leaves a side open. + // Min and Max bound choosable days; zero is open. Min, Max Date // DisableDate marks single days that cannot be chosen. DisableDate func(Date) bool @@ -38,7 +37,6 @@ type CalendarOptions struct { } // 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) @@ -115,7 +113,7 @@ func calendarSeek(d Date, dir int, enabled func(Date) bool) Date { return d } -// calendarMonth draws the month of *cursor and returns the day picked and the day hovered. +// calendarMonth draws *cursor's month; returns picked and hovered days. func calendarMonth(c *ui.Context, cursor *Date, cfg calendarConfig) (picked, hovered Date) { k := Tokens(c) d := Density(c) @@ -140,7 +138,7 @@ func calendarMonth(c *ui.Context, cursor *Date, cfg calendarConfig) (picked, hov 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(). + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, 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) @@ -154,7 +152,7 @@ func calendarMonth(c *ui.Context, cursor *Date, cfg calendarConfig) (picked, hov 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). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleColumnHeader). Label(msg(c, "calendar.weekday_long_"+strconv.Itoa(wd))) } }) diff --git a/date_picker.go b/date_picker.go index c17fe93..d0630a5 100644 --- a/date_picker.go +++ b/date_picker.go @@ -24,12 +24,13 @@ type DatePickerOptions struct { // Placeholder shows in the empty field; empty uses the built-in copy. Placeholder string Disabled bool + // ReadOnly keeps the text focusable and copyable but blocks edits and the popup. + ReadOnly 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. +// DatePicker types or picks *value; invalid text is kept and reported. func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResult { datetimeCheckDate("DatePicker value", *value) datetimeCheckBounds("DatePicker", opts.Min, opts.Max) @@ -53,6 +54,13 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu 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 }) + shownLayout := ui.Local(col, "date-picker-layout", func() string { return layout }) + if *shownLayout != layout { + if *text == Date.Format(*value, *shownLayout) || value.IsZero() && *text == "" { + *text = format(*value) + } + *shownLayout = layout + } if *seen != *value { *seen, *text, *problem = *value, format(*value), "" } @@ -96,23 +104,26 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu } var field *ui.Element col.Children(func() { - field = datetimeField(c, ui.Row(c), *problem != "") + field = datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly) field.Children(func() { - in := ui.TextInputBase(c, text).Grow(1).Shrink(1).Placeholder(placeholder).FontFeatures("tnum") + in := textInputEditor(c, field, text, opts.ReadOnly, false).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) { + if !opts.ReadOnly && (in.Submitted() || *wasFocused && !focused && *text != format(*value)) { submit() } *wasFocused = focused + if opts.ReadOnly { + return + } 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 datetimeIconButton(c, "calendar", msg(c, "date_picker.open")).Role(ui.RolePopUpButton).Clicked() { if *open { *open = false } else { @@ -120,10 +131,10 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu } } }) - if !*open && (field.Shortcut(0, ui.KeyDown) || field.Shortcut(ui.Alt, ui.KeyDown)) { + if !*open && !opts.ReadOnly && (field.Shortcut(0, ui.KeyDown) || field.Shortcut(ui.Alt, ui.KeyDown)) { openAt() } - datetimePopup(c, field, open, func() { + datetimePopup(c, field, open, opts.ReadOnly, func() { picked, _ := calendarMonth(c, cursor, calendarConfig{ chosen: func(x Date) bool { return x == *value }, enabled: enabled, diff --git a/date_range_picker.go b/date_range_picker.go index 0ce0323..096e95b 100644 --- a/date_range_picker.go +++ b/date_range_picker.go @@ -37,12 +37,13 @@ type DateRangePickerOptions struct { // Today is marked in the calendar; zero uses the system clock. Today Date Disabled bool + // ReadOnly keeps the field focusable and copyable but blocks the popup. + ReadOnly 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. +// DateRangePicker picks start and end; Confirm writes *value. func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOptions) DateTimeResult { datetimeCheckDate("DateRangePicker Start", value.Start) datetimeCheckDate("DateRangePicker End", value.End) @@ -84,10 +85,10 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption } var field *ui.Element col.Children(func() { - field = datetimeField(c, ui.Row(c), false) + field = datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly) 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) { + t := datetimeTrigger(c, text, msg(c, "date_range_picker.placeholder"), "calendar").Label(label).Description(text).Error(*problem) + if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) { *open = !*open if *open { *draft, *problem, *hover = *value, "", Date{} @@ -99,9 +100,9 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption } } }) - datetimePopup(c, field, open, func() { + datetimePopup(c, field, open, opts.ReadOnly, func() { ui.Text(c, dateRangeSummary(c, *draft, layout)).TextColor(k.TextMuted). - FontSize(theme.CaptionSize).FontFeatures("tnum").Role(ui.RoleStatus) + FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").Role(ui.RoleStatus) end := draft.End if end.IsZero() && !draft.Start.IsZero() { end = *hover @@ -144,6 +145,9 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption cancelled = true } *wasOpen = *open + if !*open { + *problem = "" + } return DateTimeResult{Element: col, changed: changed, cancelled: cancelled} } diff --git a/date_time_picker.go b/date_time_picker.go index b5c60ce..9e7c482 100644 --- a/date_time_picker.go +++ b/date_time_picker.go @@ -12,8 +12,10 @@ import ( // DateTimePickerOptions configure a DateTimePicker. type DateTimePickerOptions struct { - // Location is the time zone the user edits in. Required. + // Location is the time zone the user edits in; required. Location *time.Location + // Layout is the Go layout of the date part; empty uses 2006-01-02. + Layout string // Min and Max bound the instant; zero leaves a side open. Min, Max time.Time Seconds bool @@ -23,12 +25,13 @@ type DateTimePickerOptions struct { // Today is marked in the calendar; zero uses the system clock. Today Date Disabled bool + // ReadOnly keeps the field focusable and copyable but blocks the popup. + ReadOnly 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. +// DateTimePicker edits *value as wall time in opts.Location. func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) DateTimeResult { loc := opts.Location if loc == nil { @@ -56,7 +59,11 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) 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) } + typedBad := ui.Local(col, "date-time-typed-bad", func() bool { return false }) + if opts.Layout == "" { + opts.Layout = "2006-01-02" + } + format := func(t time.Time) string { return dateTimeText(c, t.In(loc), opts.Layout, cfg) } changed, cancelled, done := false, false, false label := opts.Label if label == "" { @@ -67,10 +74,10 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) text = format(*value) } col.Children(func() { - field := datetimeField(c, ui.Row(c), false) + field := datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly) 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) { + t := datetimeTrigger(c, text, msg(c, "date_time_picker.placeholder"), "calendar").Label(label).Description(text).Error(*problem) + if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) { *open = !*open if *open { *day, *clock, *problem = today, TimeOfDay{}, "" @@ -83,7 +90,7 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) } } }) - datetimePopup(c, field, open, func() { + datetimePopup(c, field, open, opts.ReadOnly, func() { picked, _ := calendarMonth(c, cursor, calendarConfig{ chosen: func(x Date) bool { return x == *day }, enabled: calendarEnabled(lo, hi, nil), @@ -94,11 +101,17 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) if !picked.IsZero() { *day, *problem = picked, "" } + bad := "" ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { - if _, edited := timeEditor(c, clock, cfg); edited { - *problem = "" + cfg.problem = *problem + _, edited, b := timeEditor(c, clock, cfg) + switch { + case b != "": + *problem, *typedBad, bad = b, true, b + case edited || *typedBad: + *problem, *typedBad = "", false } - ui.Text(c, loc.String()).FontSize(theme.CaptionSize).TextColor(k.TextMuted) + ui.Text(c, loc.String()).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) }) datetimeError(c, *problem) datetimeFooter(c, func() { @@ -107,6 +120,9 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) } if Button(c, msg(c, "calendar.confirm"), ButtonOptions{}).Clicked() { at, p := dateTimeConfirm(c, *day, *clock, loc, opts, cfg) + if bad != "" { + p = bad + } *problem = p if p == "" { if !at.Equal(*value) { @@ -124,6 +140,9 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) cancelled = true } *wasOpen = *open + if !*open { + *problem = "" + } return DateTimeResult{Element: col, changed: changed, cancelled: cancelled} } @@ -132,7 +151,7 @@ func dateTimeConfirm(c *ui.Context, d Date, t TimeOfDay, loc *time.Location, opt if p := timePickerProblem(c, t, cfg); p != "" { return time.Time{}, p } - wall := fmt.Sprintf("%v %s", d, timeText(c, t, cfg)) + wall := d.Format(opts.Layout) + " " + timeText(c, t, cfg) at, n := dateTimeResolve(d, t, loc) switch { case n == 0: @@ -142,14 +161,14 @@ func dateTimeConfirm(c *ui.Context, d Date, t TimeOfDay, loc *time.Location, opt 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)) + return at, fmt.Sprintf(msg(c, "calendar.before_min"), dateTimeText(c, opts.Min.In(loc), opts.Layout, 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, fmt.Sprintf(msg(c, "calendar.after_max"), dateTimeText(c, opts.Max.In(loc), opts.Layout, cfg)) } return at, "" } -// dateTimeResolve finds the instants showing wall time d t in loc: none in a DST gap, two in an overlap. +// dateTimeResolve counts instants for a wall time: 0 gap, 2 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) @@ -175,7 +194,7 @@ func dateTimeResolve(d Date, t TimeOfDay, loc *time.Location) (time.Time, int) { 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") +// dateTimeText formats a wall time with the picker's date layout and clock style. +func dateTimeText(c *ui.Context, w time.Time, layout string, cfg timeConfig) string { + return w.Format(layout) + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), w.Second()}, cfg) + " " + w.Format("MST") } diff --git a/datetime.go b/datetime.go index 9da7642..e258df2 100644 --- a/datetime.go +++ b/datetime.go @@ -2,6 +2,7 @@ package mujica import ( "fmt" + "log/slog" "time" "github.com/egoist/mygo/ui" @@ -11,8 +12,7 @@ import ( "mujicaui/theme" ) -// Date is a calendar day with no time of day and no location. -// The zero value means no date. +// Date is a calendar day without clock or location; zero means none. type Date struct { Year int Month time.Month @@ -136,28 +136,33 @@ func datetimeIconButton(c *ui.Context, icon, label string) *ui.Element { } // 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 { +func datetimeField(c *ui.Context, row *ui.Element, invalid, readOnly bool) *ui.Element { k := Tokens(c) d := Density(c) - border := k.ControlBorder + bg, border := k.Surface, k.ControlBorder switch { case invalid: border = k.Danger + case readOnly: + bg, border = k.Background, k.Border 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) + AlignItems(ui.Center).Radius(theme.ControlRadius).Background(bg).Border(theme.BorderWidth, border) } -// datetimeTrigger is the button inside a field that shows text and opens a popup. +// datetimeTrigger is the popup button inside a field; Cmd+C copies its text. 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) + MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RolePopUpButton) internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Shortcut(ui.Cmd, ui.KeyC) && text != "" { + c.WriteClipboard(text) + } b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(k.TextMuted) if text == "" { @@ -169,10 +174,14 @@ func datetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element 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 { +// datetimePopup shows fn below anchor while *open; a disabled or read-only anchor closes it. +func datetimePopup(c *ui.Context, anchor *ui.Element, open *bool, readOnly bool, fn func()) *ui.Element { k := Tokens(c) d := Density(c) + if *open && (readOnly || anchor.IsDisabled()) { + slog.Debug("mujica: datetime popup closed by state", "readOnly", readOnly) + *open = false + } return ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { born := ui.Local(panel, "datetime-born", func() bool { return true }) if *born { @@ -192,7 +201,7 @@ func datetimeError(c *ui.Context, text string) { if text == "" { return } - ui.Text(c, text).FontSize(theme.CaptionSize).TextColor(Tokens(c).Danger).Role(ui.RoleStatus) + ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).Danger).Role(ui.RoleStatus) } // datetimeFooter lays out popup actions at the end of a row. diff --git a/datetime_grid.go b/datetime_grid.go index 7cb7c48..7c35d05 100644 --- a/datetime_grid.go +++ b/datetime_grid.go @@ -39,7 +39,7 @@ func datetimeGrid(c *ui.Context, cursor *int, cfg datetimeGridConfig) (picked in 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(). + ui.Text(c, cfg.title(start)).Font(theme.SerifFont).FontSize(FontSize(c, 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 @@ -61,10 +61,9 @@ func datetimeGrid(c *ui.Context, cursor *int, cfg datetimeGridConfig) (picked in case cells.Shortcut(0, ui.KeyPageDown): *cursor += cfg.page case cells.Shortcut(0, ui.KeyHome): - *cursor, _ = cfg.pageOf(*cursor) + *cursor, _ = datetimeGridBounds(*cursor, cfg) case cells.Shortcut(0, ui.KeyEnd): - s, n := cfg.pageOf(*cursor) - *cursor = s + n - 1 + _, *cursor = datetimeGridBounds(*cursor, cfg) default: moved = false } @@ -145,33 +144,46 @@ func datetimeGridCell(c *ui.Context, o, start, cursor int, cfg datetimeGridConfi return cell.Clicked() && enabled } +// datetimeGridBounds returns the page's own first and last ordinals. +func datetimeGridBounds(o int, cfg datetimeGridConfig) (first, last int) { + start, n := cfg.pageOf(o) + first, last = start, start+n-1 + for first < last && cfg.muted(first, start) { + first++ + } + for last > first && cfg.muted(last, start) { + last-- + } + return first, last +} + // datetimeGridField is the field and popup shared by MonthPicker and YearPicker. type datetimeGridField struct { text, placeholder, label string clearable, hasValue bool - disabled bool + disabled, readOnly 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. +// datetimeGridPicker draws f and its popup; reports a pick or clear. 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 := datetimeField(c, ui.Row(c), false, f.readOnly) 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) { + if !f.readOnly && (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() { + if f.clearable && f.hasValue && !f.readOnly && datetimeIconButton(c, "x", msg(c, "calendar.clear")).Clicked() { cleared = true } }) - datetimePopup(c, field, open, func() { + datetimePopup(c, field, open, f.readOnly, func() { cfg.autoFocus = true if picked, ok = datetimeGrid(c, cursor, cfg); ok { *open = false diff --git a/datetime_review_test.go b/datetime_review_test.go new file mode 100644 index 0000000..a995fbf --- /dev/null +++ b/datetime_review_test.go @@ -0,0 +1,236 @@ +package mujica + +import ( + "image/color" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// datePickerLive builds a DatePicker whose options a test changes between frames. +func datePickerLive(value *Date, opts *DatePickerOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Padding(16).Gap(8).Children(func() { + o := *opts + o.Today, o.Label = calendarToday, "Due" + if DatePicker(c, value, o).Changed() { + *changes++ + } + }) + }, 420, 460) +} + +func TestDatePickerDisablingClosesPopup(t *testing.T) { + v, n := Date{2026, time.October, 5}, 0 + opts := DatePickerOptions{} + tt := datePickerLive(&v, &opts, &n) + _ = tt.Click("Open calendar") + day := datetimeTestLabel(oct(5)) + if !tt.Focused(day) { + t.Fatal("setup: the popup should focus the chosen day") + } + opts.Disabled = true + tt.Frame() + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + _ = tt.Click(datetimeTestLabel(oct(20))) + if v != oct(5) || n != 0 { + t.Fatalf("disabled picker committed %v (%d events)", v, n) + } + if tt.HasText("October 2026") || tt.Focused(datetimeTestLabel(oct(6))) || tt.Focused("Due") { + t.Fatal("the popup should close and nothing in the disabled picker keep focus") + } +} + +func TestDatePickerDisabledBlocksInput(t *testing.T) { + v, n := Date{2026, time.October, 5}, 0 + tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Disabled: true}, &n) + _ = tt.Click("Due") + tt.Type("2027-01-01") + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyDown) + _ = tt.Click("Open calendar") + if v != oct(5) || n != 0 || tt.Focused("Due") || tt.HasText("October 2026") { + t.Fatalf("disabled picker took input: %v (%d)", v, n) + } +} + +func TestTimePickerKeepsInvalidHour(t *testing.T) { + v, n := TimeOfDay{9, 30, 0}, 0 + tt := timePickerTester(&v, TimePickerOptions{}, &n) + _ = tt.Click("Alarm: Hours") + tt.Key(0, ui.Key2) + tt.Key(0, ui.Key9) + if v != (TimeOfDay{2, 30, 0}) || n != 1 { + t.Fatalf("first digit should apply: %v", v) + } + if !tt.HasText("29") || !tt.HasText("Hours must be from 0 to 23.") || !tt.Focused("Alarm: Hours") { + t.Fatalf("29 should stay shown with an error: %v", tt.Texts()) + } + tt.Key(0, ui.Key1) + tt.Key(0, ui.Key5) + if v != (TimeOfDay{15, 30, 0}) || n != 3 || tt.HasText("Hours must be") { + t.Fatalf("fixing the hour: %v", v) + } +} + +func TestTimeSegmentDescription(t *testing.T) { + var got []string + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + got = []string{timeSegmentDescription(c, timeHourPart, " 9", 1, 12), timeSegmentDescription(c, timePeriodPart, "PM", 0, 59)} + }, 100, 100) + if got[0] != "9, from 1 to 12" || got[1] != "PM" { + t.Fatalf("descriptions %q", got) + } +} + +func TestTimePickerSegmentFocusRing(t *testing.T) { + v := TimeOfDay{9, 30, 0} + tt := timePickerTester(&v, TimePickerOptions{}, new(int)) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.Key(0, ui.KeyTab) + r, ok := tt.Find("Alarm: Hours") + if !ok || !tt.Focused("Alarm: Hours") { + t.Fatal("setup: hours not focused") + } + want := theme.Light().Focus + img := tt.Image() + found := false + for dy := float32(-4); dy <= -2 && !found; dy++ { + c := img.RGBAAt(int(r.X+r.W/2), int(r.Y+dy)) + found = datetimeNear(c, want) + } + if !found { + t.Fatal("no focus ring above the focused segment") + } +} + +func datetimeNear(c color.RGBA, w ui.Color) bool { + d := func(a, b uint8) int { + if a > b { + return int(a - b) + } + return int(b - a) + } + return d(c.R, w.R)+d(c.G, w.G)+d(c.B, w.B) < 60 +} + +func TestDatePickerLayoutChangeReformats(t *testing.T) { + v, n := Date{2026, time.October, 5}, 0 + opts := DatePickerOptions{} + tt := datePickerLive(&v, &opts, &n) + opts.Layout = "02/01/2006" + tt.Frame() + _ = tt.Click("Due") + tt.Key(0, ui.KeyEnter) + if v != oct(5) || n != 0 || tt.HasText("Enter a date like") { + t.Fatalf("committed text kept the old layout: %v", tt.Texts()) + } + _ = tt.Click("Due") + tt.Command("selectAll") + tt.Type("12/10") + opts.Layout = "2006-01-02" + tt.Frame() + tt.Key(0, ui.KeyEnter) + if !tt.HasText("Enter a date like 2026-10-05.") || v != oct(5) { + t.Fatal("a draft being edited should be kept across a layout change") + } +} + +func TestYearPickerHomeEndStayOnPage(t *testing.T) { + v := 2026 + tt := yearPickerTester(&v, YearPickerOptions{}, new(int)) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyHome) + if !tt.HasText("2020 – 2029") || !tt.Focused("2020") { + t.Fatalf("Home left the page: %v", tt.Texts()[:4]) + } + tt.Key(0, ui.KeyEnd) + if !tt.HasText("2020 – 2029") || !tt.Focused("2029") { + t.Fatal("End left the page") + } +} + +func TestDatePickerReadOnly(t *testing.T) { + v, n := Date{2026, time.October, 5}, 0 + tt := datePickerTester(&v, DatePickerOptions{Label: "Due", ReadOnly: true, Clearable: true}, &n) + _ = tt.Click("Due") + if !tt.Focused("Due") { + t.Fatal("read-only field should take focus") + } + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "2026-10-05" { + t.Fatalf("copied %q", tt.Clipboard()) + } + tt.Type("2027-01-01") + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyDown) + if v != oct(5) || n != 0 || tt.HasText("October 2026") || tt.HasText("Open calendar") || tt.HasText("Clear") { + t.Fatalf("read-only picker changed: %v %v", v, tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyTab) + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "2026-10-05" { + t.Fatalf("typed text leaked into the field: %q", tt.Clipboard()) + } +} + +func TestTimePickerReadOnly(t *testing.T) { + v, n := TimeOfDay{9, 30, 0}, 0 + tt := timePickerTester(&v, TimePickerOptions{ReadOnly: true}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.Key1) + tt.Key(ui.Alt, ui.KeyDown) + if !tt.Focused("Alarm: Hours") || v != (TimeOfDay{9, 30, 0}) || n != 0 || tt.HasText("Choose time") { + t.Fatalf("read-only time changed: %v", v) + } + tt.Key(ui.Cmd, ui.KeyC) + if tt.Clipboard() != "09:30" { + t.Fatalf("copied %q", tt.Clipboard()) + } +} + +func TestMonthPickerReadOnly(t *testing.T) { + v, n := YearMonth{2026, time.May}, 0 + tt := monthPickerTester(&v, MonthPickerOptions{ReadOnly: true, Clearable: true}, &n) + _ = tt.Click("Billing month") + tt.Key(0, ui.KeyDown) + if tt.HasText("January") || tt.HasText("Clear") || !tt.Focused("Billing month") { + t.Fatalf("read-only month opened: %v", tt.Texts()) + } + tt.Key(ui.Cmd, ui.KeyC) + if tt.Clipboard() != "May 2026" || v != (YearMonth{2026, time.May}) || n != 0 { + t.Fatalf("copied %q, value %v", tt.Clipboard(), v) + } +} + +func TestDateTimePickerLayout(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, Layout: "02/01/2006"}, &n, &x) + _ = tt.Click("Meeting") + _ = 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 || !tt.HasText("Choose 10/10/2026 12:00 UTC or earlier.") { + t.Fatalf("error ignores the layout: %v", tt.Texts()) + } + _ = tt.Click(datetimeTestLabel(oct(9))) + _ = tt.Click("Confirm") + if n != 1 || !tt.HasText("09/10/2026 13:00 UTC") { + t.Fatalf("display ignores the layout: %v", tt.Texts()) + } +} diff --git a/docs/components/051-calendar.md b/docs/components/051-calendar.md index 72d7a72..c891a73 100644 --- a/docs/components/051-calendar.md +++ b/docs/components/051-calendar.md @@ -53,3 +53,4 @@ ui.Render(view, 400, 300, 1) - MyGo 的 `ui.Calendar` 固定周一起始、只有英文且不支持禁用日期(ui/date.go:85-160),因此本组件基于 `RadioBase` 自绘网格,不复用 `ui.Calendar`。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 - `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 +- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/docs/components/052-date-picker.md b/docs/components/052-date-picker.md index 4a7b7a4..0a3352c 100644 --- a/docs/components/052-date-picker.md +++ b/docs/components/052-date-picker.md @@ -33,12 +33,13 @@ ui.Render(view, 400, 300, 1) | `Today` | `Date` | 零值(系统时钟) | 弹层日历中的今日标记;空值时弹层打开到此月份。 | | `Clearable` | `bool` | false | 显示清空按钮;清空输入后提交表示“无日期”。 | | `Placeholder` | `string` | 内置“选择日期” | 空输入框的占位文字。 | +| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 | | `Disabled` | `bool` | false | 禁用输入与弹层。 | | `Label` | `string` | 占位文字 | 辅助技术名称。 | ## 状态 -默认、悬停(边框加深)、聚焦(焦点色边框)、错误(危险色边框 + 下方说明,输入保留)、禁用。弹层内日历状态同 Calendar。 +默认、悬停(边框加深)、聚焦(焦点色边框)、只读(背景与边框减弱,可选择复制)、错误(危险色边框 + 下方说明,输入保留)、禁用。弹层内日历状态同 Calendar。 ## 事件 @@ -54,6 +55,7 @@ ui.Render(view, 400, 300, 1) - 无效或越界的输入(如 2026-02-30)显示具体错误并保留原文,不会被修正为相邻日期。 - MyGo 的 `ui.DateInput` 固定格式且不可键入(ui/date.go:13),因此本组件组合 `TextInputBase` 与自绘日历弹层(`PopoverBase`)。 -- 暂不提供只读模式:MyGo v0.2.10 未公开 TextInputBase 的只读开关。 +- 只读模式沿用 TextInput 的做法:编辑副本并在每帧恢复,文本仍可选择与复制。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 - `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 +- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/docs/components/053-date-range-picker.md b/docs/components/053-date-range-picker.md index f400753..9a50075 100644 --- a/docs/components/053-date-range-picker.md +++ b/docs/components/053-date-range-picker.md @@ -30,6 +30,7 @@ ui.Render(view, 400, 300, 1) | `DisableDate` | `func(Date) bool` | nil | 不可选日期;范围内包含此类日期时拒绝并提示。 | | `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日。 | | `Today` | `Date` | 零值(系统时钟) | 今日标记与空值时的起始月份。 | +| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 | | `Disabled` | `bool` | false | 禁用。 | | `Label` | `string` | 内置“日期范围” | 辅助技术名称。 | @@ -55,3 +56,4 @@ ui.Render(view, 400, 300, 1) - `DateRange` 为闭区间,`Days()` 计入两端。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 - `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 +- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/docs/components/054-time-picker.md b/docs/components/054-time-picker.md index 3740297..0014447 100644 --- a/docs/components/054-time-picker.md +++ b/docs/components/054-time-picker.md @@ -30,12 +30,13 @@ ui.Render(view, 400, 300, 1) | `SecondStep` | `int` | 0(即 1) | 秒步长,必须整除 60。 | | `Min` | `TimeOfDay` | 00:00:00 | 最早时间。 | | `Max` | `TimeOfDay` | 零值(即 23:59:59) | 最晚时间。 | +| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 | | `Disabled` | `bool` | false | 禁用。 | | `Label` | `string` | 空 | 组名;各段名称为“Label: 时/分/秒”。 | ## 状态 -默认、悬停、聚焦段(酒红填充)、错误(危险色边框 + 说明,草稿保留)、禁用。弹层列表中超出范围的项禁用。 +默认、悬停、聚焦段(酒红填充 + 键盘焦点环)、只读、错误(危险色边框 + 说明,草稿保留)、禁用。弹层列表中超出范围的项禁用。 ## 事件 @@ -52,8 +53,9 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 超出范围或不符合步长的时间会提示,不会被自动夹取或取整。 +- 超出范围或不符合步长的时间会提示,不会被自动夹取或取整;键入超出段范围的数字(如小时 29)会保留显示并报错。 - 段顺序固定为“时:分:秒 上午/下午”,zh-CN 下上午/下午也位于末尾。 - MyGo 的 `ui.TimeInput` 只有时分且无 12 小时制、步长和范围(ui/timeinput.go:18),因此本组件自绘分段。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 - `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 +- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/docs/components/055-time-range-picker.md b/docs/components/055-time-range-picker.md index 377fe2e..4aac9bf 100644 --- a/docs/components/055-time-range-picker.md +++ b/docs/components/055-time-range-picker.md @@ -31,6 +31,7 @@ ui.Render(view, 400, 300, 1) | `Min` | `TimeOfDay` | 00:00:00 | 两端最早时间。 | | `Max` | `TimeOfDay` | 零值(即 23:59:59) | 两端最晚时间。 | | `AllowOvernight` | `bool` | false | 允许结束早于开始,表示次日结束,并显示“次日”。 | +| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 | | `Disabled` | `bool` | false | 禁用。 | | `Label` | `string` | 空 | 整组名称。 | @@ -53,3 +54,4 @@ ui.Render(view, 400, 300, 1) - `TimeRange.Overnight()` 报告是否跨午夜。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 - `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 +- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/docs/components/056-date-time-picker.md b/docs/components/056-date-time-picker.md index b6dcb14..d2a7efa 100644 --- a/docs/components/056-date-time-picker.md +++ b/docs/components/056-date-time-picker.md @@ -29,6 +29,7 @@ ui.Render(view, 400, 300, 1) | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Location` | `*time.Location` | 必填 | 用户编辑所用时区;为 nil 时 panic。 | +| `Layout` | `string` | `2006-01-02` | 日期部分的 Go 时间布局,用于显示与错误说明。 | | `Min` | `time.Time` | 零值 | 最早时刻。 | | `Max` | `time.Time` | 零值 | 最晚时刻。 | | `Seconds` | `bool` | false | 显示秒。 | @@ -36,6 +37,7 @@ ui.Render(view, 400, 300, 1) | `MinuteStep` | `int` | 0(即 1) | 分钟步长。 | | `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日。 | | `Today` | `Date` | 零值(系统时钟) | 今日标记与空值时的默认日期。 | +| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 | | `Disabled` | `bool` | false | 禁用。 | | `Label` | `string` | 内置占位文字 | 辅助技术名称。 | @@ -60,3 +62,4 @@ ui.Render(view, 400, 300, 1) - 空值(零 `time.Time`)打开时默认今天 00:00。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 - `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 +- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/docs/components/057-month-picker.md b/docs/components/057-month-picker.md index c0eb9e5..1a701c1 100644 --- a/docs/components/057-month-picker.md +++ b/docs/components/057-month-picker.md @@ -29,6 +29,7 @@ ui.Render(view, 400, 300, 1) | `DisableMonth` | `func(YearMonth) bool` | nil | 不可选月份。 | | `Clearable` | `bool` | false | 有值时显示清空按钮。 | | `Today` | `Date` | 零值(系统时钟) | 标记当前月份。 | +| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 | | `Disabled` | `bool` | false | 禁用。 | | `Label` | `string` | 内置“选择月份” | 辅助技术名称。 | @@ -53,3 +54,4 @@ ui.Render(view, 400, 300, 1) - 键盘移到不可选月份时焦点保持在原处,可继续移动。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 - `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 +- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/docs/components/058-year-picker.md b/docs/components/058-year-picker.md index 2ff6c6f..2b10e5a 100644 --- a/docs/components/058-year-picker.md +++ b/docs/components/058-year-picker.md @@ -29,6 +29,7 @@ ui.Render(view, 400, 300, 1) | `DisableYear` | `func(int) bool` | nil | 不可选年份。 | | `Clearable` | `bool` | false | 有值时显示清空按钮。 | | `Today` | `Date` | 零值(系统时钟) | 标记当前年份。 | +| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 | | `Disabled` | `bool` | false | 禁用。 | | `Label` | `string` | 内置“选择年份” | 辅助技术名称。 | @@ -53,3 +54,4 @@ ui.Render(view, 400, 300, 1) - 只支持公元 1 年及以后;值为负数时 panic。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 - `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 +- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/examples/gallery/pages_datetime.go b/examples/gallery/pages_datetime.go index 07f6b8c..f547335 100644 --- a/examples/gallery/pages_datetime.go +++ b/examples/gallery/pages_datetime.go @@ -35,7 +35,7 @@ var datetimePages = []page{ 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) + ui.Text(c, title).Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)).TextColor(k.Text) fn() }) } @@ -46,7 +46,7 @@ func dtStates(c *ui.Context, fn func(state func(name string, body func()))) { 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) + ui.Text(c, name).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) body() }) }) @@ -128,6 +128,9 @@ func dtDatePickerDemo(c *ui.Context, log func(string)) { log("DatePicker (bounded) changed: " + s.b.String()) } }) + state("Read-only", func() { + mujica.DatePicker(c, &s.d, mujica.DatePickerOptions{Label: "Signed on", Layout: layout, ReadOnly: true}) + }) state("Disabled", func() { mujica.DatePicker(c, &s.d, mujica.DatePickerOptions{Label: "Locked", Layout: layout, Disabled: true}) }) @@ -202,6 +205,11 @@ func dtTimePickerDemo(c *ui.Context, log func(string)) { log("TimePicker (bounded) changed: " + s.b.String()) } }) + state("Read-only", func() { + o := base + o.Label, o.ReadOnly = "Logged at", true + mujica.TimePicker(c, &s.a, o) + }) state("Disabled", func() { o := base o.Label, o.Disabled = "Locked", true @@ -261,7 +269,7 @@ func dtDateTimeDemo(c *ui.Context, log func(string)) { } 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) + FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) }) } @@ -288,6 +296,9 @@ func dtMonthDemo(c *ui.Context, log func(string)) { log("MonthPicker changed: " + s.v.String()) } }) + state("Read-only", func() { + mujica.MonthPicker(c, &s.v, mujica.MonthPickerOptions{Label: "Closed month", ReadOnly: true}) + }) state("Disabled", func() { mujica.MonthPicker(c, &s.v, mujica.MonthPickerOptions{Label: "Locked", Disabled: true}) }) @@ -311,6 +322,9 @@ func dtYearDemo(c *ui.Context, log func(string)) { log(fmt.Sprintf("YearPicker changed: %d", s.v)) } }) + state("Read-only", func() { + mujica.YearPicker(c, &s.v, mujica.YearPickerOptions{Label: "Founded", ReadOnly: true}) + }) state("Disabled", func() { mujica.YearPicker(c, &s.v, mujica.YearPickerOptions{Label: "Locked", Disabled: true}) }) diff --git a/messages_datetime.go b/messages_datetime.go index 1d44b95..2b4dd2d 100644 --- a/messages_datetime.go +++ b/messages_datetime.go @@ -63,6 +63,8 @@ func init() { "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.segment": {"%[1]s, from %[2]d to %[3]d", "%[1]s,范围 %[2]d–%[3]d"}, + "time_picker.part_range": {"%[1]s must be from %[2]d to %[3]d.", "%[1]s须在 %[2]d 至 %[3]d 之间。"}, "time_picker.out_of_range": {"Choose a time from %s to %s.", "请选择 %s 至 %s 之间的时间。"}, "time_range_picker.start": {"Start time", "开始时间"}, diff --git a/month_picker.go b/month_picker.go index b88359a..766686d 100644 --- a/month_picker.go +++ b/month_picker.go @@ -44,11 +44,13 @@ type MonthPickerOptions struct { // Today marks the current month; zero uses the system clock. Today Date Disabled bool + // ReadOnly keeps the field focusable and copyable but blocks the popup and clearing. + ReadOnly 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. +// MonthPicker chooses a month into *value from a year page. 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) { @@ -75,7 +77,7 @@ func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateT } 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(), + clearable: opts.Clearable, hasValue: !value.IsZero(), disabled: opts.Disabled, readOnly: opts.ReadOnly, home: home.ordinal(), }, datetimeGridConfig{ pageOf: func(o int) (int, int) { return yearMonthOf(o).Year * 12, 12 }, cols: 3, diff --git a/testdata/snapshots/darwin/time_picker-dark.png b/testdata/snapshots/darwin/time_picker-dark.png index 12a4377..2486b12 100644 Binary files a/testdata/snapshots/darwin/time_picker-dark.png and b/testdata/snapshots/darwin/time_picker-dark.png differ diff --git a/testdata/snapshots/darwin/time_picker-light.png b/testdata/snapshots/darwin/time_picker-light.png index de6c31a..9f6887f 100644 Binary files a/testdata/snapshots/darwin/time_picker-light.png and b/testdata/snapshots/darwin/time_picker-light.png differ diff --git a/testdata/snapshots/darwin/time_range_picker-dark.png b/testdata/snapshots/darwin/time_range_picker-dark.png index d3f8038..ea34102 100644 Binary files a/testdata/snapshots/darwin/time_range_picker-dark.png and b/testdata/snapshots/darwin/time_range_picker-dark.png differ diff --git a/testdata/snapshots/darwin/time_range_picker-light.png b/testdata/snapshots/darwin/time_range_picker-light.png index e32d8f1..bb5b65b 100644 Binary files a/testdata/snapshots/darwin/time_range_picker-light.png and b/testdata/snapshots/darwin/time_range_picker-light.png differ diff --git a/time_picker.go b/time_picker.go index 12a4391..57f4eac 100644 --- a/time_picker.go +++ b/time_picker.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "strconv" + "strings" "github.com/egoist/mygo/ui" @@ -49,12 +50,13 @@ type TimePickerOptions struct { // Min and Max bound the time; a zero Max means 23:59:59. Min, Max TimeOfDay Disabled bool + // ReadOnly keeps the segments focusable and copyable but blocks edits and the popup. + ReadOnly 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. +// TimePicker edits *value in segments or a popup. func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTimeResult { cfg := timePickerConfig("TimePicker", opts) if !value.Valid() { @@ -65,17 +67,23 @@ func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTim 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, "" + *draft, *seen = *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 - } + cfg.problem = *problem + _, edited, bad := timeEditor(c, draft, cfg) + p := bad + if p == "" { + p = timePickerProblem(c, *draft, cfg) + } + if p != *problem { + *problem = p + c.Invalidate() + } + if edited && p == "" && *draft != *value { + slog.Debug("mujica: time_picker change", "from", *value, "to", *draft) + *value, *seen, changed = *draft, *draft, true } datetimeError(c, *problem) }) @@ -88,13 +96,15 @@ type timeConfig struct { minuteStep, secondStep int min, max TimeOfDay label string - invalid bool + // problem is the error shown last frame, attached to the segments. + problem string + readOnly 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} + secondStep: max(opts.SecondStep, 1), min: opts.Min, max: opts.Max, label: opts.Label, readOnly: opts.ReadOnly} 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)) @@ -174,15 +184,18 @@ func timeParts(cfg timeConfig) []timePart { 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) { +// timeEditor draws segments and popup; reports edits and invalid entry. +func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Element, edited bool, bad string) { k := Tokens(c) d := Density(c) - field = datetimeField(c, ui.Row(c), cfg.invalid).Gap(d.Unit() * 2) + field = datetimeField(c, ui.Row(c), cfg.problem != "", cfg.readOnly).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 := ui.Row(c).AlignItems(ui.Center).Gap(d.Unit() * 1.5).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(cfg.label) + if segs.Shortcut(ui.Cmd, ui.KeyC) { + c.WriteClipboard(timeText(c, *draft, cfg)) + } segs.Children(func() { focusNext := false for i, part := range timeParts(cfg) { @@ -192,84 +205,107 @@ func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Elem if part == timePeriodPart { ui.Box(c).Width(d.Unit()) } - seg, done := timeSegment(c, draft, part, cfg, &edited) + seg, done, segBad := timeSegment(c, draft, part, cfg, &edited) if focusNext { seg.Focus() - focusNext = false } focusNext = done + if bad == "" { + bad = segBad + } } }) - b := datetimeIconButton(c, "clock", msg(c, "time_picker.open")) + if cfg.readOnly { + return + } + b := datetimeIconButton(c, "clock", msg(c, "time_picker.open")).Role(ui.RolePopUpButton) if b.Clicked() { *open, *fresh = !*open, true } }) - if !*open && field.Shortcut(ui.Alt, ui.KeyDown) { + if !*open && !cfg.readOnly && field.Shortcut(ui.Alt, ui.KeyDown) { *open, *fresh = true, true } - datetimePopup(c, field, open, func() { + datetimePopup(c, field, open, cfg.readOnly, func() { if timeColumns(c, draft, cfg, *fresh) { edited = true } *fresh = false }) - return field, edited + return field, edited, bad } -// 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) { +// timeLimits returns the lowest and highest value a segment accepts. +func timeLimits(part timePart, cfg timeConfig) (lo, hi int) { + switch { + case part != timeHourPart: + return 0, 59 + case cfg.hour12: + return 1, 12 + } + return 0, 23 +} + +// timeSegment draws one segment; done reports a complete valid entry, bad an invalid one. +func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, edited *bool) (seg *ui.Element, done bool, bad string) { 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() + seg = ui.Box(c).Focusable().Role(ui.RoleStepper).Label(name). + Padding(0, d.Unit()).Radius(theme.ControlRadius).MinWidth(theme.BodySize * 1.6).Shrink(0).Center() + internal.Ring(seg, k.Focus, theme.ControlRadius) typed := ui.Local(seg, "time-typed", func() string { return "" }) + badText := ui.Local(seg, "time-bad", func() string { return "" }) + badFor := ui.Local(seg, "time-bad-for", func() TimeOfDay { return TimeOfDay{} }) if !seg.Focused() { *typed = "" } + if *badFor != *draft { + *badText = "" + } set := func(t TimeOfDay) { + *badText = "" 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 + lo, hi := timeLimits(part, cfg) + if !cfg.readOnly { + switch { + case seg.Shortcut(0, ui.KeyUp): + set(timeStepPart(t, part, cfg, 1)) + case seg.Shortcut(0, ui.KeyDown): + set(timeStepPart(t, part, cfg, -1)) } - 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) + t = *draft + 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)) { + continue + } + v, ok, complete := timeType(typed, n, lo, hi) + switch { + case ok: + set(timeWithPart(t, part, v, cfg)) + done = complete + case complete: + *badText, *badFor = fmt.Sprintf("%02d", v), *draft + slog.Debug("mujica: time_picker invalid segment", "part", int(part), "typed", v) + } + } } } + t = *draft text := "" switch part { case timeHourPart: @@ -284,16 +320,64 @@ func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, case timePeriodPart: text = timePeriod(c, t.Hour) } + if *badText != "" { + text = *badText + bad = fmt.Sprintf(msg(c, "time_picker.part_range"), msg(c, timePartNames[part]), lo, hi) + } + seg.Description(timeSegmentDescription(c, part, text, lo, hi)).Error(cfg.problem) fg := k.Text switch { - case seg.Focused(): + case *badText != "": + fg = k.Danger + case seg.Focused() && !cfg.readOnly: seg.Background(k.Accent) fg = k.OnAccent + case seg.Focused(): + seg.Background(k.Selection) case seg.Hovered(): seg.Background(k.SurfaceHover) } seg.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum").SingleLine() }) - return seg, done + return seg, done, bad +} + +// timeSegmentDescription gives assistive technology a segment's value and range. +func timeSegmentDescription(c *ui.Context, part timePart, text string, lo, hi int) string { + if part == timePeriodPart { + return text + } + return fmt.Sprintf(msg(c, "time_picker.segment"), strings.TrimSpace(text), lo, hi) +} + +// timeStepPart steps one segment of t by dir, wrapping round its cycle. +func timeStepPart(t TimeOfDay, part timePart, cfg timeConfig, dir int) TimeOfDay { + 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 + } + return t +} + +// timeWithPart sets one segment of t to v, keeping the period on a 12-hour clock. +func timeWithPart(t TimeOfDay, part timePart, v int, cfg timeConfig) TimeOfDay { + 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 + } + return t } // timeStep moves v to the next multiple of step in dir, wrapping within an hour. @@ -307,42 +391,22 @@ func timeStep(v, step, dir int) int { 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 - } +// timeType appends digit n; reports value, validity and completion. +func timeType(typed *string, n, lo, hi int) (v int, ok, complete bool) { + s := *typed + strconv.Itoa(n) + if len(s) > 2 { + s = strconv.Itoa(n) } - 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 + complete = len(s) == 2 || v*10 > hi + if complete { + s = "" } *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 + return v, v >= lo && v <= hi, complete } // timeColumns lists choosable values per segment and reports whether a choice edited *draft. diff --git a/time_picker_test.go b/time_picker_test.go index a5eb6a8..c6976d7 100644 --- a/time_picker_test.go +++ b/time_picker_test.go @@ -143,7 +143,7 @@ func TestTimePickerRejectsBadOptions(t *testing.T) { 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) { + snapshot(t, "time_picker", 520, 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}) diff --git a/time_range_picker.go b/time_range_picker.go index 4776748..3a03486 100644 --- a/time_range_picker.go +++ b/time_range_picker.go @@ -9,7 +9,7 @@ import ( "mujicaui/theme" ) -// TimeRange is a start and an end time of day. End before Start means it ends the next day. +// TimeRange is a span of day; End before Start means next day. type TimeRange struct { Start, End TimeOfDay } @@ -28,15 +28,16 @@ type TimeRangePickerOptions struct { // AllowOvernight accepts an end before the start, meaning the next day. AllowOvernight bool Disabled bool + // ReadOnly keeps both ends focusable and copyable but blocks edits. + ReadOnly 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. +// TimeRangePicker writes *value only when the range is valid. 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}) + MinuteStep: opts.MinuteStep, SecondStep: opts.SecondStep, Min: opts.Min, Max: opts.Max, ReadOnly: opts.ReadOnly}) if !value.Start.Valid() || !value.End.Valid() { panic(fmt.Sprintf("mujica: TimeRangePicker value %v is not a pair of times", *value)) } @@ -53,32 +54,37 @@ func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOption 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, "" + *draft, *seen = *value, *value } changed := false col.Children(func() { - edited := false + edited, bad := 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 - } + start.problem, end.problem = *problem, *problem + _, e1, b1 := timeEditor(c, &draft.Start, start) ui.Text(c, "–").TextColor(k.TextMuted).Role(ui.RoleNone) - if _, e := timeEditor(c, &draft.End, end); e { - edited = true + _, e2, b2 := timeEditor(c, &draft.End, end) + edited, bad = e1 || e2, b1 + if bad == "" { + bad = b2 } if draft.Overnight() && opts.AllowOvernight { - ui.Text(c, msg(c, "time_range_picker.next_day")).FontSize(theme.CaptionSize).TextColor(k.AccentText) + ui.Text(c, msg(c, "time_range_picker.next_day")).FontSize(FontSize(c, 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 - } + p := bad + if p == "" { + p = timeRangeProblem(c, *draft, cfg, opts.AllowOvernight) + } + if p != *problem { + *problem = p + c.Invalidate() + } + if edited && p == "" && *draft != *value { + slog.Debug("mujica: time_range_picker change", "from", *value, "to", *draft) + *value, *seen, changed = *draft, *draft, true } datetimeError(c, *problem) }) diff --git a/year_picker.go b/year_picker.go index b607152..f6b990d 100644 --- a/year_picker.go +++ b/year_picker.go @@ -17,12 +17,13 @@ type YearPickerOptions struct { // Today marks the current year; zero uses the system clock. Today Date Disabled bool + // ReadOnly keeps the field focusable and copyable but blocks the popup and clearing. + ReadOnly 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. +// YearPicker chooses a year into *value from a decade page; zero is none. 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)) @@ -43,7 +44,7 @@ func YearPicker(c *ui.Context, value *int, opts YearPickerOptions) DateTimeResul } 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, + clearable: opts.Clearable, hasValue: *value != 0, disabled: opts.Disabled, readOnly: opts.ReadOnly, home: home, }, datetimeGridConfig{ pageOf: func(o int) (int, int) { return decade(o) - 1, 12 }, cols: 3,