From 67be3e93849f062040bd1f6215b5ca0be4bbb8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 12:40:11 -0400 Subject: [PATCH] Date & Time: review r1 fixes --- calendar.go | 12 +- date_picker.go | 27 +- date_range_picker.go | 18 +- date_time_picker.go | 55 ++-- datetime.go | 29 ++- datetime_grid.go | 32 ++- datetime_review_test.go | 236 ++++++++++++++++++ docs/components/051-calendar.md | 1 + docs/components/052-date-picker.md | 6 +- docs/components/053-date-range-picker.md | 2 + docs/components/054-time-picker.md | 6 +- docs/components/055-time-range-picker.md | 2 + docs/components/056-date-time-picker.md | 3 + docs/components/057-month-picker.md | 2 + docs/components/058-year-picker.md | 2 + examples/gallery/pages_datetime.go | 20 +- messages_datetime.go | 2 + month_picker.go | 6 +- .../snapshots/darwin/time_picker-dark.png | Bin 5336 -> 5251 bytes .../snapshots/darwin/time_picker-light.png | Bin 5060 -> 5066 bytes .../darwin/time_range_picker-dark.png | Bin 7143 -> 7150 bytes .../darwin/time_range_picker-light.png | Bin 6724 -> 6733 bytes time_picker.go | 232 ++++++++++------- time_picker_test.go | 2 +- time_range_picker.go | 44 ++-- year_picker.go | 7 +- 26 files changed, 570 insertions(+), 176 deletions(-) create mode 100644 datetime_review_test.go 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 12a4377873f02a260da347ac94256addf6b266cb..2486b12ad70c07bc42b35b018b1407d10b8f2458 100644 GIT binary patch literal 5251 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 literal 5336 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 F{{z*kaeV*) literal 5060 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 diff --git a/testdata/snapshots/darwin/time_range_picker-dark.png b/testdata/snapshots/darwin/time_range_picker-dark.png index d3f8038f0e4c69c5aedbdd29ac629054c7fc1a68..ea34102148732346cf0de9b6503be6e4d986314b 100644 GIT binary patch literal 7150 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 Z^W%E=bINl1X^e-4CMT^dRUv5<^k2r!1=j!o literal 7143 zcmdU!Wl$X5m&Qp#$RI-?Xo9;03+@hsPH=*2aCaGoAVEWb5JCtR+y{5Jz+l1MT?QE# z+?MyhwIBDx?$*}Uc6FV)x4OFP+|zyU^Zfcos)0d3tT$L_XlOu11z8O=v?n=_Z6CnX z$H<+FAB=`Z22+%k((*|~r21-UTEF?zmZGMBM$&0c{M7P`7T(i(YU*eVx#}s$2|rN| z9v=h?$iR`Jw^mcGxMp2TOt_YVUE^RRO4;7Nv^GlXiIiG918W2x0l=V>_Ng8_F<|f&Mf|lCx3a64;Jw`l9^V%o7Bf7n;Z?ivP3j zuts@RhqGTX@=12^GYE#rT!I3cQgo0;=_oHR|K=h`e}nIf?Q8mS_!__70^O&ynl28( z_U+EZ=1L_N@FzCvVCos1>781&q_J*K6asav?qtx@~ zT56L2z3?IMd5a0398bD(PFT;VMW?ujNlJ(2xbOO(1o#ku;0OS43luiG zXy}ToJ*hB0R>fWEzUo>T){7}x35`Ybn!*+FXJv3gN=r~34Hi#pv`QH1s7K-tI!NNQfG z%xoMD-`oRKZ%-btw<(BnkKEwWzgiWrvvhY?RxDIl_9nezO_I)CIi+ZH&o8g30aA&{ z)z;Rwwk}aRX{ zpWQT!0@I_BtTjglo1|x%lcNWe*KPjp{BU+}bNB1q*-DVhqzKid!bd=6yJTB{f1NtB zm)`{e|B>Lu{?VoQabG>C{Ehu&afQ83*@~M`__wda2IXtFp9d1M>9&T`^KO=3Q#L#7 z1~=atoG$ygkA6$cl}0w!IJ<`Xuim6*xvoD=;z-U&3bbF%x_n=ENmf2bh;?teEq&n96xgWFcbaVmm+F*3y}M2Xrgf(K z#32u9hg#2L8~xDBtGs4ZV>hwUKi}Yzvk*nqK`NVC??>&&ndm~LW&Y5H^3y|fG+0^L zAmwKXiA#lmD4$g<^%X&BpGF6o5%FcdL z7E9h0$rNSK+@7w~;<;@+%_0;!()tX9Cmn(zBrGDrZor)(OFPEnK^Jh>&M$C*QCs#~ z4F2aL2qZ5WoSvqAftYdl;dIbegu$eyo%mgzY|r#PJ2M`2sQ8CAyS^1E7;Jcd>JMFL z7|_4cLXt|??S#X66L3hhDjPoIkk=7A$*`ErsNao=%`9+a6>WGA5A!VRIfh$ugI}78 z*+CqBd8o%{iUwvJeNDHs;X+K&gn~1a#r#*jpN)CS?>YBE1>*(nwj| zghx3+l47+!d-Rp1OTRg8fUoCg`nRA4zDz2)GJWZUC#IrB}#pr}s;+n83*BD*)~ zOg7i(a-5~oLhXrK1>?Im0c{ zLQ+{#5gBN2Yh=_lY9X8b1@4s$(e2E(Y>0^%{l%s6I4<4@1s(;|26XjQ4+?DU*HSrH53gf^@!1&qry1QiQaS?IC-`I`1( zLoA7^xz6n~_7KbJxKXK*--uhon%~k;a#=5PV!uHhHdAQKDcRW`r!}WtjegK?uD3VW zDBDpDTSs^Ifl7u`WhJ>nl5u3|%4n4@Iyd_X^$XB#t8Nq)?jT2fbgW--R@N(~xsig1 zI2)*-6yD7_mEz4EmUICOmf0vz%)`KHrKE-Sx2|AEs&k8iJ9!n-AF*mo*V1q$;5>cn z>a6=Hy1u?2`!3&&n=|?PR&p8*Upw@Q*XUbh^|UIul#L`Dht(DEtN>S2QrPEJ5u!% z^~a>#XFvGFuyQ*zhlug9(-~x6b@3(Z!X70|HJMyUNVqd(s5$Oeh3EWXEx%EzvhlrR zU|W2JCezoKxUa<7=21xSUEKM{V*n~uj%#5jOemqkY;xI)A&u8l?ys4(b+08ZL1aIW z5`&D4j5uLwV!_a0eq9ACQkJ{E*xsjH&+`npxc$w~Zd5pWa5*}qZu7M3vf%w*w5pY| zMA*TuOnFT}kL?bgo@2oDx7M#lbR4|gky=J{JTrVJ=KkniGbHY$#V>O;Y^&vKN=pw4 z$H($)CpG09-?RT>FV#s&PfUnU!#1IsxZ4j=2?Vfe+rJmwUrKrrdkv@iwCEnZh)wWl z8ublcG-;Ij>azM+-ch!EDz3nLJOcgD{wtPQPXaOV1_&9j4}=W7mBO>bK7e4PT*Da$ z{#|$Zc=;<{!@~3;TAxedQR38v{b5YqDQ;(sp0{+3HPx)Bab_KEG9skl_lwAG87-p; z;^Lfmqbg&expY9go{#@D<=!KO?GQh7bL|0=RPG68aRfpc ze`m74X0Ihm*|lp-sdUY))WDU6W3qS(dRNpkWk=Fdntu+Ns+F#BilFYJjbKK9^$o2u zgm5+iV*M&C3=q#?W1jIa?%pSQG}tV5>J3fyUG`<9(Rn+(^P+E(^G8KW;J)=d&BW6# zE)ILcpKal_VES^!eSX_Tx`=J^ko!3*nfwlxoF2`BZ4kJJXwqW$k65x9#!QZ9O7$Ur zJ9ygcMV;JSBm0|C3vB+FI8_%@&`!H}INCO9QV0*g%ltnfgB|Ge9uq?)Gg7NzEQKA=J^OvK4L6}tz!+u_sPX7Afqb)Yvk!3@-ZteD<79%XQVY_{AZ{ zpZ>XNXEU54Mm}xhP!!s%>RQ1ivTY0kF^vYZvG@9dy|T&1A6?EBgeIP{-~(IqZ%LEL z9MSb*qe3mE9Q-gdCzvP`Q~3PutaRp2g_r82VR9h$@{HsD`}bclgupqRaTiK|>xj$ z^tlIj4PPnG2@7vg+{X144mhB8o+&S-dmnd=?J3c|sH*T2Bx^cJV<3OKReKrRnLqS! z*dKVyN=E`qy^GzUZCy#LBS}f&!4M?d-{iA}ulefD`eGCJ z9L_%!HV9d4p9A}6XKGyEd3n{Eq~AI@Sq|n72y}J@-@m)@G;fl^?kXQKefU1_?Khm2 zFT?yTXQ7qg4IjM&Gju!+8b~$kuO>}OP5{wg>sWy)$IVlPy1pyyBrGhZmCg*VGl7>?;C*nogvX`3 zg~eQPM=KyxuPU#uuFm<+)WpeH>TyaqEuPoxd-IBU4#3^ZQ`T1H+3pJOn@2{njr~zp zE;Fzzd^q7`+K z7@@Zi(M`98l|p2VZDvP&)dQ^AQ7bV9nBSaL*-W0nED6Z9wtSmlWtHdA=qkTuv^g@E zz{GxmjpgVd*uqb#!kl5MHmp$4Kfb6<)X`7${q26*_Yj=due~EflCu@u@TF_{7sfQ; zC!5%>0>u(Mkb}Q2bVPmHo$c%x`44nFv^GxUPQQGS$Hx@q^~ucaO@e2KQh`kd+UDQN zF?WtL2|HYKCfiayKsXi_30N%c;rbHphnou}p;WDWRAN@1-KotQ9Um;#O_^?^EwhM{ zdMn_>$B_Z;KhwU(%$|kD@NM4(wixJ6yWB%)xdRSk4$@EQ9PCX=nsq*C4hr5l>Cbm;!V_2LE@om9HJ zBlaCigS>c`y<4RDzMjIc_+Kwu+|Sa|W=CKYbNftC`OnU~GU0WHev+iAyHVMaESMxH zX(Ie0meEKO;Hi}BPb{~ti#C}^Ge7O^;gvkHTlo1RU*+e_*eJAW#W*7R=Cep4&G2Dx z5$G1Q*)5LdC5$OTkWlg9P9LPg*jqXe!$?C5EW3Ch8ra5uNiM3iasR!(#1H~|>1|*z zr`1+nsCDaZZ*Nd+zO7|f#+a-!wR9s&UjHZ?$kQJ~etn!YZp;F0rUt>(yfYFr%lUDf z%)~$3W=ql&Yl>x>yhmbeyEW=sZ;-cV-+9e#>+NiNUcULPqz;ZWCza2QGthFFtM|Pa zO1WGZ*it=`8&2EZ+WVP2xu1@-(#Jp|-`gIqC5!DPfh=Nn{g(}cVr3I>xNtO~OXtg) z(>DzzG622UDwVgaYS1m+@2i1$S(*hRTbxKw7jEwE_wO^!TWQ}ht!EdI{=)Yy8pz2> z73s$PR{^+_AsfdWxZySI(1*n(&RQ>z>iITj14pB~U!KU2>pG5CNkZUix*u^wDUnQa zAHP*%H5kGS>3^{e47_b>Y>en$(K1+8(=+1f=~6wD?d+JFI^WMNZZqC%1!kgXA1 z8Z1jz+pA7%YQQZk^)(FhhRVDv0Lv*vDTU3I{5(8-+}y7skv!#DS>J}NSQW$Z8X-bL zZigx$kU!td*=ut}-@wO(T%Ag+$ajIXoDY|V@Kiq(`y#PEAp@WNuBfsi{o`1G%#is{ zmiw$^W2pFfGth*hiHTVLd3bokYL~d}=jV{K(kb~gz41ilpv=dgU$S-3FHDDBHs#|YzYxOG*I78|$w;feW$U&u~ z&)8d+mp-Z2*A902jM4>HiUTNHO z?Pvy~7cVIil#oZUfy0rHzYd*jZGotZuSN~k4GmYv@#}V7?)$nugQ>(nQh#wIz$3`V zRz8POMyj5T(dA*vFeWXs9P)J|>?{V>KV-CcrYWq%bF$R%*G1r_+`nI5A%7AmnhPhR+Oem=YO#KfdciyEJ1vZdXz1ibhMjO>14FGN07 zH1lyWgvIwc2|*c`imC(AG`6P9ZGPCLw+VK2wV2O{eIm25MVdPbU_$^tK6>LR5D1vD=V7=R9=Ar>AEnkLrAjMBY4?>-Lg~H{_7;y}sEg zc%HImuIaQ4AB1bN%VD9;`UBNGPEZ%ZZg8$@g&B9t<#hIeAM)m;I^84)CBc&J*BP7( zt?TVShKjwr%X+Ov8HmUP7??9Kbtjo+WS%{H<+ry64k5Wado&G3gW#i@k_aBS?l8Rx z&5uTg1o&E;NaAr<9R|IIJ*f8wj|uCT&1kRxE}@-)H7Ke7OBBU;QA^dSn-g(78W2o6 z5L}o(PKrEY7lE9RSDXK6#)Mf1<@f@XuG&UT%T{qer_Kkc z92-!%s{+1|Y9>jmtv&rJ(kbT6N1Gn2+8XZyaA}c}@5}jRsgV`AK(W>ZN}y8sk5U{j zw;MKOei`geeq*H{%v6R5qxxmb@B<~LwY;<>ep~cG8@_q6F@Z|4ea(s751IIb=a*Gg zRjid`doFqU@6QSbiBVEqUG+ZO{;R{s|3&Ksn#f{M8d#AdX?kMv@r-p4_-(esaH^o8 zd-{dXlwSvAWkEwtO>OM(Un0$2aYPRHY((qk87CQOnBvFEAQ+Ip1&~s`|1YGOo|zc@ z7^C@D8i$h$r^-r?m!HpGAJf4v^FOc%S|GS@Z1(eEqryW!E;^%Xu={>j@a@&6nk zK!?uX;_?q2b-RC+n*Jj#DJi*Q5x3%Eck1%=Kfz%b=S)W;h2iY~@6a;yw&{TijfI0H VPJ?@r=WzoMO;HXk3zIet{x=vYF`_%s>O$o#tNniw}*0cu3p^ygA%`92kzo8|E($ zEs6fmrX?h>82^_#nVCySuV1Q4L5wPt5JHBB&$45-_-6n@7VRnpkTb)3d`jO`7eeso zc8Fo%IAG2uP=p6 zP+aD@yFtm^eQQ-pRwdD*_66K{Vh(A>O{~q$Z*tPB)%x;J;w3Q9IzgfOP=P=-b?lRnVDH<_*;>R-SDMVyMxBY zsZ(?B`KR-_>^yE}g6g1zgPY83?X+g`7`U_YF4J z`tFI17BHuzU-f=w?e=ZhzFW=pXl+~Ry>Zk`)NB#IjC?F|<1Li028D9-BhwnVv~DU| zs1&pU#6O4PHeV*C#F8X?Y|{zz32_;OX%MTbxeFN6zt8ktm`IDXiEpwQn0fau?7VXe zMb}`6ex?31@8>U#tLu%vrKy(ELZR&gmAQa@L&cfO!$2tSk1*H3?hoPuv@lBs|KIh zwg*(MOicSSNy!Mm`d`D@te!HNmn9`7+}$5cW@?)VX6Bo>)nC2Z*pO~&46riy9J?fQ z5fRbg<*KbxEXy}GF$P#S!jm68Bo!25WsNgjT9}By3;ejTVL|YF%lx}Edd)G}CVpdo zLv%VQ{={yeU4WbW`mTLO^3Rlxf4AziuHNe9FR-Z&m!IEkOUveGA3Om8@uDIUBYu6e z5GgrjZff&RW2AtXebqTN9o=?k;eH)Oe0+S}dgvPZiS)hw*Y_mS01Cy=9k`U3UW|?= z$yt%fIp)nnBA#53Nxx$M#@rB!5>b`pNgUwzHO&NM`0Renaw_H)gFHh|Q?lO-~4VAlJnW zL-mhOxEfrXDTi!>O>>ajq zyT+9yh&-&wP}ZHjtF5j5EY)KSDk*OC*C@1ibw#2JpKYT;SMR)U>Gf@FbQJsJ$QWm( zZcZm5AE8BRa7E;0{1%@$zAQ=k1z~XLQ9=k$keQhowfoG>7~V5QiY zj*hPW)Aui(Viyj`f&sRUh84N7jCEwO6WWiAjlJ2}zIv@uc>m6N3kFSeWpq-;0j}iS zHy6hXKXMY|`(Pu{7RV!;qW-Z5Z6dyb4S-X6n7uuEcw{7(A=eWJ1d0!6qY4z$vYomN z=$F2qbaXmmvPJ42Yr^A$&4N;x=Wm@=rI2KtGyhJ_G4h! z-L1y#?1ZN%c{Y6LFwV*jkb9D4GlZtVpI=(4k#sBPuBueAc9D?P2#Ip4VbQRmkP8k% z>?gnj;Vv*=8fw$F_HFhk_f^GNI6633RZ}zR01adPGfIdxx$$i{t~(3W zBxO3G6jXltYs)tPI5`gc5F++b*Vq`gE(e{GV7&a8o|92CjEHy#J)Z5v8ntXPHF3 z;GpYrCB?eCjRc|eH0jp1t%JGGe=fEQ_da{|ufUtYTamTf_7;{vGGu3Y|KWVNMCIho z+0}@(%68-^?s2T0wRKub1D_ad_A7yvq9Q6v$gIh_5BQHNq=YtD;KjltR|wM~o$%1m zKzUe>mR4wZ?!dQcm)UpIli?W|H+6NRPoMtwe8Ik)shrbCnFhEz$g1XTY&4Nh>vKy= zN>wfE@9$TGN4qVwXf@&$ov$XlS9qAV!|+tJ3kp~eejWq)Uk09DLG(#V&3tw3NPArZ^7s`2a> zw<`NviH9L?U6RWN8q*Yc=<*$J8_DsXri%J})_;TuJ2W@(^J~w{#86?_^X3!lL=Y=r z5mBa7RpNA~4}CQ6pQb{+%D=iFU9n)(zps@ry@);0B@yA5l5o)g(J@UKTF&%~C7+p3 zXW&Ld^fBjO$P)G+y(L6&RSDCRipBQEls&j?7R3!_>Xm-DN=8h6AD_YK-=dz0p>tR| zc=UQEHSjr~f|fd`TXpo8!PKTSb5>kfM?}7Of*xPZH_+HoTye$1F0s%1=#R`_2DO~9 zTu^Sjf%IbAE)DK6I-h>NxTs9YVg;#57PbYFjTq6UBZEbq$@G)-v6S%mfshoYdp|$G zlxmiCfa{y~3w!{7V-sIBe|#}NG4I>r55?9dvkT?YdlAHQ8~UC_nH`qPj(yv|!$}OC zO^z>&37o%DX4DR{-A}_OmZhD4fSVRjNG?dAX8lH*wuJz~q%pm&p03RLN^^ z8>`WWTl<#(h?O|;{|y%2nb^R1lz?;M!{Q*L>JmkVONvUsnW}XwH!)oNFqeX_|1cqw z5}OCO1m^5yRDCS=OB^5+1TQ?4uMP0V6VPaD4&SslnZ4F!-$J6SYFc6%6aV;n1|rud zOSV~sr@=t?iu%1c(#HRO1%FP31SY&0kp7aT@=V51{GGnscK5|7oN+Vs3Hg11PQ-u8 zud%e;|4M}Wg*EZO@j_0{!mcxEuX0WffQ;;3_!|aUAp4P*?ce(i>BReJS7oJ;p|9DS-sc#l`kp*>#>N(Y6tvemz5db%K>)fRT}Y z;N?j5tT(HSdAn0daT?_U)9HIG#eOXPa52wJN9Q+sKpczA+(+=iAwFVa;s8k&VrBDa z0#r_JuGTLhFGwB|Sx$N3#5BOpE=VK8ce2=iUgGQWYIr1`m;^i2hexE1+h;shS2i3J zQYqk`OiDrDtFV`v+K^^#a@O#~HcGA3q3nLQJkvA+&7M3Y@X^+DQ9={P^4#8P0CRG7 z_^2&cY|^1`x3VqC0Toq3`>wC*)U?HQbe|xhGni@L!NJbHgf@=nSt`&kDHc9UeD%2b z=H{M+goG9eY=2G#JMDyrLMDHJIG@u-6IJRGgJih;5Z#4csaAeXA%TjpK*Bq!m>tH) z?&s%2s2{J&G{;$*%CQy)Rmouj9F8hyx_`G7ZA(&$AYV5t6ipY@*o;80H9TH-k{T2TGTOW?y6^{pZ9ZUt; znGB!&Q1i{}y3X-9Fp0LS%Iaz^z)BhCa(iRrN|T*~2R)e?>GR1|p)seAFXc9t8yd!@ zrdq~qxEck`5<9$Zj8D66g1D!GSi_J&2xN1;_jSwan`ktIL%OFYKp*w^p z0%hTo60o1CpvQR~ijgt5&Z65};(!PQYIR$LwYD6g_z3fN5WQjXwIlfwGW}fFvr9{- zg}Lou5QyZaE?h0kf-On)V~DGVUGrY9CRxyl&({$*CEf1~z{`Q(bzhR6aM6)BO#b*` zHB68BvuD7^jV#Q~9VmQk)t;S`9Zd1FzPkD)r%M=eu)M457S-h4Qm6?6iA_|fFk3HG z5A-eT>9d6V9K$s;%MUoHD|B0?W@hH2$6x;BCMTu*Zcmr*ZfXGiHMHmem6dd^3QpC3`49NZX7tx874({Mx?!fH zUymYoWGP?sLo$oP^6iStV)R9>kNaJcwIOsV@@-jHdH(G360D`Ht?R@|<(u|uk{Hy0 zowoUp@@@SON0T@CQI_piy$g?IjK}{_3=MyGcR?E(L%#~EYiT_N0Q@HaKB0IqWq|U^ zs;b@HoewurywP%rN$+@vQA)T)yhW2-(7Y#Hvo~SR*4D%8o`yB5EXf15@3V90OURz- z=-52Z4|m3UZ|NJc7ANmT5!wiTCJV)8|M(+w7ksM z(hFW_@A`TeW+h;ZrZ=a0k21-kpewWyHGh-0HIs1|HYNtzcSH+M{Dr?K)b};A@9N6u zxJ*hF688dUXgayNE+Ed=u>Q!)x9EH^uFYzWfZ4fI0RuFI7}_j_88I5)r+a!E2L{tTH=n zp~TB;qxojFFal~bmd(TCG+RI5&;Q!byD2#y*%dUq?Z~HfV%)XlFtR&aw@JPZe_N8K z!0N?P&o9!A9d#{D7`wfhqz}12ur4B z!ImqMv#l-$MOoaH>BD%GVUA<%D&$Og*n}Q2{eW}Jo;$|q zRl&90&)JN>Q5i(kR3akm=s1j>ofM;5o2jH<;{m3+KI-)3tf`^tMQf3&QNZgUiP#~` zpQWZh2Q6?Ri(A-XTLH`6`+>3V!jPYi0Req~k6Ax-UcsX?4Z(D5*OVc;z-=3%+}}fV z<-(+Ut{&UI-UNm4sLLHF%`SRK`+ZT!c~G*7;xZn)dytZ~p{T~ot~Z5(bLny2CrU&CP{x)#T0n#Wh-t%X;INDPKUDcb7_^1nB901 zq$Hhx0BpLU=Jd52FuSxUe1YTRkGAw68#7H&QI3n3gqN45g7sBZs-BVW(2#^bUq4h& z_U{Eh;Zh<`dDz<^?voc8*?>e|&whmt|0}LJ5~K%{^_N(}9`rU0|3uU*S))q2DikVo zTU1_63n7&1g`5$y#vG+IE9C-rVkxff86m&h%g?o+kzRcnG&EN!%F5{(6fmdUjqO^? zPr=rVqQ02H>8z}|$yJv2wgVrI!=FFr+I+!DHWU=wG&D4thStCw%PjbL3ldpe9kYy} zt1pt(*3+X9T}Bknb-v9QfvT!z3-{dr1-7KPk530SU;xD#$tg7&^zj!>R;UQN*HJnI z&W?q4BvcRa-8rSUUe3R60Ez;!bmIJw%hIe^J58J^>BFS@JFeRc2Zf*@iNK7WH4EL- z)0oTj&Uj%gl4kN>LhkRViwz-ORtEO3iM9ePu5qP?-Pi}a!TRteW)YRGhXBo`)W)vZ7RaMP?_h)!;FcKuHENj8SBN!UGHzU*@_8E=vaWNp-+PgB+ zw?Fe+htt2C=#A~NZ(bC0zb-|M;$@U1G)HG*KBt#oN@-+qoceHS7#SKp?|_tKy;CtU zalyn6^$N)YILhoDWFC@9Vb&nMqwc&hR)wyHk!Z>8<#19(C)gP}lIY>y?rv5VL-!?3 z7+-s_jbMRzf^#uo7@OBPCZ&_c*-}aSOLDl41PW}#P{#`~c-u?h%AMC5E;$_w)hR05 zTW%f52@SKu`YRV7zj8(W)`s#9cEhOt-Ee_!$uLR4+Sm{2r_WjfCeMf-UDkZTwhtuy zZmDyZ?QNu(`#NZ7&qml#1Qj#gjw(MIXX_5JeZ(4^8PPCxG1}c1Y*TWQPepNYY+0tcToHbkHu!z>6p?U zqxq>R&9#KK){bBqax0un_!>o}4-F2MbhfHAn4h1oZeN+2T2vYX(0=uV6*gDoK;g^G%v?X*d4d89 z!l4coRaMUMQU=_Huh~M|-yW&WX(hU{5|ys@vfceXzTG!-DX;NVdglM)X*3aangAXf za~Q*-CTqxkiB^dsSzu`Ss@xOuJbMJB1CqREfLB?atiXEpRv7@zgWyhon|Ogxh_d*a zL>#<->=lm+Mu5x*b8I;7rg0>8dJY8W!e5MG+0ock#sTV0X${4e4eH&BgyAM(GZi(r z=7;abxc^T7Kj_Lz36K;)SN8Bgq4FhN5?a3an?6Z+X0KhDEw(FkxBe?b44>x$q+58YWskjste;XG3p=16gge+0tMdh~xjoj`Ogova} UoQDKqkBM+p6rl2Ta+VSQ1(R|uvj6}9 literal 6724 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 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,