Merge branch 'worktree-agent-ad7403b5194d75449'

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:40:27 -04:00
commit fd43666995
26 files changed
+570 -176

No files matched your search

+5 -7
View File
@@ -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)))
}
})
+19 -8
View File
@@ -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,
+11 -7
View File
@@ -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}
}
+37 -18
View File
@@ -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")
}
+19 -10
View File
@@ -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.
+22 -10
View File
@@ -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
+236
View File
@@ -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())
}
}
+1
View File
@@ -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()` 关联。
+4 -2
View File
@@ -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()` 关联。
+2
View File
@@ -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()` 关联。
+4 -2
View File
@@ -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()` 关联。
+2
View File
@@ -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()` 关联。
+3
View File
@@ -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()` 关联。
+2
View File
@@ -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()` 关联。
+2
View File
@@ -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()` 关联。
+17 -3
View File
@@ -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})
})
+2
View File
@@ -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", "开始时间"},
+4 -2
View File
@@ -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,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

+148 -84
View File
@@ -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.
+1 -1
View File
@@ -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})
+25 -19
View File
@@ -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)
})
+4 -3
View File
@@ -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,