Date & Time: review r1 fixes
No files matched your search
@@ -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)))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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})
|
||||
})
|
||||
|
||||
@@ -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", "开始时间"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||