diff --git a/calendar.go b/calendar.go index 7c96a59..2a8e691 100644 --- a/calendar.go +++ b/calendar.go @@ -245,6 +245,9 @@ func calendarDay(c *ui.Context, day, cur Date, cfg calendarConfig, size float32, } ring := cell.FocusVisible() || cell.Focused() && keyed cell.DrawOver(func(p *ui.Painter, r ui.Rect) { + if chosen { + datetimeSelectedMark(p, r, k.OnAccent) + } if day == cfg.today { p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H-5, 5), fg) } diff --git a/date_picker.go b/date_picker.go index bec7d78..57ae24a 100644 --- a/date_picker.go +++ b/date_picker.go @@ -102,11 +102,12 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu if placeholder == "" { placeholder = msg(c, "date_picker.placeholder") } - var field *ui.Element + var field, input *ui.Element col.Children(func() { field = datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly) field.Children(func() { in := textInputEditor(c, field, text, opts.ReadOnly, ui.TextInputBase).Grow(1).Shrink(1).Placeholder(placeholder).FontFeatures("tnum") + input = in label := opts.Label if label == "" { label = placeholder @@ -149,7 +150,7 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu }) datetimeError(c, *problem) }) - return DateTimeResult{Element: col, changed: changed} + return DateTimeResult{Element: col, Input: input, changed: changed} } // datePickerProblem explains why an existing day d cannot be chosen, or returns "". diff --git a/datetime.go b/datetime.go index e258df2..6a9bf2c 100644 --- a/datetime.go +++ b/datetime.go @@ -69,6 +69,8 @@ func (d Date) within(lo, hi Date) bool { // DateTimeResult is a date or time component and what happened to it this frame. type DateTimeResult struct { *ui.Element + // Input is the text field inside, for labels and focus; nil without one. + Input *ui.Element changed bool cancelled bool } @@ -196,6 +198,12 @@ func datetimePopup(c *ui.Context, anchor *ui.Element, open *bool, readOnly bool, }) } +// datetimeSelectedMark strokes the 1 DIP inset frame that marks a chosen cell. +func datetimeSelectedMark(p *ui.Painter, r ui.Rect, c ui.Color) { + const inset = 2 + p.Stroke(ui.Rect{X: r.X + inset, Y: r.Y + inset, W: r.W - 2*inset, H: r.H - 2*inset}, c, theme.ControlRadius/2, theme.BorderWidth) +} + // datetimeError shows a field error in Danger beneath a control. func datetimeError(c *ui.Context, text string) { if text == "" { diff --git a/datetime_grid.go b/datetime_grid.go index 7c35d05..f4e3136 100644 --- a/datetime_grid.go +++ b/datetime_grid.go @@ -125,6 +125,9 @@ func datetimeGridCell(c *ui.Context, o, start, cursor int, cfg datetimeGridConfi current := cfg.current(o) ring := cell.FocusVisible() || cell.Focused() && keyed cell.DrawOver(func(p *ui.Painter, r ui.Rect) { + if chosen { + datetimeSelectedMark(p, r, k.OnAccent) + } if current { p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H-6, 5), fg) } diff --git a/datetime_review2_test.go b/datetime_review2_test.go index fbc1241..5654eb2 100644 --- a/datetime_review2_test.go +++ b/datetime_review2_test.go @@ -5,6 +5,8 @@ import ( "time" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) func TestTimePickerDisabledIgnoresSegmentKeys(t *testing.T) { @@ -140,3 +142,80 @@ func TestDateRangePickerConfirmChecksCurrentBounds(t *testing.T) { t.Fatalf("valid draft not committed: %v", v) } } + +func TestTimePickerPopupPickClearsSegmentError(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}) || !tt.HasText("Hours must be from 0 to 23.") { + t.Fatalf("setup: %v %v", v, tt.Texts()) + } + n = 0 + _ = tt.Click("Choose time") + if err := tt.Click("03"); err != nil { + t.Fatal(err) + } + if v != (TimeOfDay{3, 30, 0}) || n != 1 || tt.HasText("Hours must be") { + t.Fatalf("popup pick: value %v, %d changes, %v", v, n, tt.Texts()) + } +} + +func TestFormFocusesDatePickerInput(t *testing.T) { + var st FormState + day := Date{} + var input *ui.Element + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Form(c, &st, FormOptions{}, func(f *FormScope) { + FormItem(f, "debut", &day, FormItemOptions[Date]{Label: "Debut date", Required: true}, func() *ui.Element { + r := DatePicker(c, &day, DatePickerOptions{Today: calendarToday}) + input = r.Input + return r.Input + }) + }) + }, 600, 500) + if err := tt.Click("Submit"); err != nil { + t.Fatal(err) + } + if input == nil || !input.Focused() || !tt.Focused("Debut date") { + t.Fatal("a failed submit should focus the DatePicker's text box") + } + tt.Type("2026-10-12") + tt.Key(0, ui.KeyEnter) + if day != oct(12) { + t.Fatalf("typing after the focus jump gave %v", day) + } +} + +func TestSelectedCellsHaveTheirOwnMark(t *testing.T) { + k := theme.Light() + check := func(name string, tt *ui.Tester, label string, shift int) { + t.Helper() + r, ok := tt.Find(label) + if !ok { + t.Fatalf("%s: no cell %q", name, label) + } + r.X += float32(shift) * r.W + img := tt.Image() + y := int(r.Y + r.H/2) + frame, fill := img.RGBAAt(int(r.X)+2, y), img.RGBAAt(int(r.X)+5, y) + if !datetimeNear(frame, k.OnAccent) || !datetimeNear(fill, k.Accent) { + t.Fatalf("%s: selected cell frame %v fill %v", name, frame, fill) + } + } + v := oct(14) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + Calendar(c, &v, CalendarOptions{Today: calendarToday}) + }, 400, 400) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + check("calendar", tt, datetimeTestLabel(oct(14)), 0) + y := 2024 + ty := yearPickerTester(&y, YearPickerOptions{}, new(int)) + ty.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + _ = ty.Click("Vintage") + // The field also shows "2024", so locate the cell beside 2023. + check("year", ty, "2023", 1) +} diff --git a/docs/components/051-calendar.md b/docs/components/051-calendar.md index c891a73..e30d0cd 100644 --- a/docs/components/051-calendar.md +++ b/docs/components/051-calendar.md @@ -33,7 +33,7 @@ ui.Render(view, 400, 300, 1) ## 状态 -默认、悬停、按下、聚焦(键盘焦点环)、选中(酒红填充 + 单选语义)、今日(加粗 + 菱形)、不可选(删除线并由 MyGo 置灰)、非本月(弱化文字)、禁用。 +默认、悬停、按下、聚焦(键盘焦点环)、选中(酒红填充 + 内嵌细框 + 单选语义)、今日(加粗 + 菱形)、不可选(删除线并由 MyGo 置灰)、非本月(弱化文字)、禁用。 ## 事件 diff --git a/docs/components/057-month-picker.md b/docs/components/057-month-picker.md index 1a701c1..562bf37 100644 --- a/docs/components/057-month-picker.md +++ b/docs/components/057-month-picker.md @@ -35,7 +35,7 @@ ui.Render(view, 400, 300, 1) ## 状态 -默认、悬停、聚焦、选中(酒红填充)、当前月(加粗 + 菱形)、不可选(删除线)、禁用。 +默认、悬停、聚焦、选中(酒红填充 + 内嵌细框)、当前月(加粗 + 菱形)、不可选(删除线)、禁用。 ## 事件 diff --git a/docs/components/058-year-picker.md b/docs/components/058-year-picker.md index 2b10e5a..d832172 100644 --- a/docs/components/058-year-picker.md +++ b/docs/components/058-year-picker.md @@ -35,7 +35,7 @@ ui.Render(view, 400, 300, 1) ## 状态 -默认、悬停、聚焦、选中、当前年、不可选、相邻十年(弱化)、禁用。 +默认、悬停、聚焦、选中(酒红填充 + 内嵌细框)、当前年(加粗 + 菱形)、不可选、相邻十年(弱化)、禁用。 ## 事件 diff --git a/examples/gallery/composite_form.go b/examples/gallery/composite_form.go index 2894c3b..cb66bd3 100644 --- a/examples/gallery/composite_form.go +++ b/examples/gallery/composite_form.go @@ -152,7 +152,7 @@ func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string if r.Changed() { log("debut " + s.debut.Format("2006-01-02")) } - return r.Element + return r.Input }) mujica.FormItem(f, "roles", &s.tags, mujica.FormItemOptions[[]string]{Label: g.t("Roles", "担当"), Required: true, Description: g.t("Up to 4; press Enter after each.", "最多 4 个;每个后按 Enter。")}, func() *ui.Element { diff --git a/examples/gallery/composite_test.go b/examples/gallery/composite_test.go index 4b2be4e..1526d8e 100644 --- a/examples/gallery/composite_test.go +++ b/examples/gallery/composite_test.go @@ -102,7 +102,7 @@ func TestCompositeFormInvalidThenValid(t *testing.T) { tt.Type("Mortis") click(t, tt, "Password") tt.Type("hunter22secret") - click(t, tt, "Select date") // the date field itself; its FormItem label focuses the picker's column + click(t, tt, "Debut date") // the FormItem label now reaches the picker's text box tt.Type("2026-12-24") tt.Key(0, ui.KeyEnter) click(t, tt, "Roles") diff --git a/testdata/snapshots/darwin/calendar-dark.png b/testdata/snapshots/darwin/calendar-dark.png index 8a00542..164bbe6 100644 Binary files a/testdata/snapshots/darwin/calendar-dark.png and b/testdata/snapshots/darwin/calendar-dark.png differ diff --git a/testdata/snapshots/darwin/calendar-light.png b/testdata/snapshots/darwin/calendar-light.png index 8a54f5a..182df92 100644 Binary files a/testdata/snapshots/darwin/calendar-light.png and b/testdata/snapshots/darwin/calendar-light.png differ diff --git a/time_picker.go b/time_picker.go index b8dd80a..5ab13b3 100644 --- a/time_picker.go +++ b/time_picker.go @@ -66,8 +66,9 @@ func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTim draft := ui.Local(col, "time-picker-draft", func() TimeOfDay { return *value }) seen := ui.Local(col, "time-picker-seen", func() TimeOfDay { return *value }) problem := ui.Local(col, "time-picker-error", func() string { return "" }) + pending := ui.Local(col, "time-picker-pending", func() bool { return false }) if *seen != *value { - *draft, *seen = *value, *value + *draft, *seen, *pending = *value, *value, false } changed := false col.Children(func() { @@ -81,9 +82,13 @@ func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTim *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 + *pending = *pending || edited + if *pending && p == "" { + if *draft != *value { + slog.Debug("mujica: time_picker change", "from", *value, "to", *draft) + *value, *seen, changed = *draft, *draft, true + } + *pending = false } datetimeError(c, *problem) }) @@ -191,6 +196,7 @@ func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Elem 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 }) + bads := ui.Local(field, "time-bads", func() [4]timeBad { return [4]timeBad{} }) field.Children(func() { 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) { @@ -205,14 +211,11 @@ func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Elem if part == timePeriodPart { ui.Box(c).Width(d.Unit()) } - seg, done, segBad := timeSegment(c, draft, part, cfg, &edited) + seg, done := timeSegment(c, draft, part, cfg, &bads[part], &edited) if focusNext { seg.Focus() } focusNext = done - if bad == "" { - bad = segBad - } } }) if cfg.readOnly { @@ -227,14 +230,30 @@ func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Elem *open, *fresh = true, true } datetimePopup(c, field, open, cfg.readOnly, func() { - if timeColumns(c, draft, cfg, *fresh) { + if picked, ok := timeColumns(c, draft, cfg, *fresh); ok { + bads[picked] = timeBad{} edited = true } *fresh = false }) + for _, part := range timeParts(cfg) { + if b := &bads[part]; b.text != "" && b.value != timePartValue(*draft, part, cfg) { + *b = timeBad{} + } + if bads[part].text != "" && bad == "" { + lo, hi := timeLimits(part, cfg) + bad = fmt.Sprintf(msg(c, "time_picker.part_range"), msg(c, timePartNames[part]), lo, hi) + } + } return field, edited, bad } +// timeBad is a segment's invalid typed text and the value it was typed over. +type timeBad struct { + text string + value int +} + // timeLimits returns the lowest and highest value a segment accepts. func timeLimits(part timePart, cfg timeConfig) (lo, hi int) { switch { @@ -247,7 +266,7 @@ func timeLimits(part timePart, cfg timeConfig) (lo, hi int) { } // 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) { +func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, bad *timeBad, edited *bool) (seg *ui.Element, done bool) { k := Tokens(c) d := Density(c) name := msg(c, timePartNames[part]) @@ -258,16 +277,14 @@ func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, 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() int { return 0 }) if !seg.Focused() { *typed = "" } - if *badFor != timePartValue(*draft, part, cfg) { - *badText = "" + if bad.value != timePartValue(*draft, part, cfg) { + *bad = timeBad{} } set := func(t TimeOfDay) { - *badText = "" + *bad = timeBad{} if t != *draft { *draft = t *edited = true @@ -299,7 +316,7 @@ func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, set(timeWithPart(t, part, v, cfg)) done = complete case complete: - *badText, *badFor = fmt.Sprintf("%02d", v), timePartValue(*draft, part, cfg) + *bad = timeBad{fmt.Sprintf("%02d", v), timePartValue(*draft, part, cfg)} slog.Debug("mujica: time_picker invalid segment", "part", int(part), "typed", v) } } @@ -320,14 +337,13 @@ 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) + if bad.text != "" { + text = bad.text } seg.Description(timeSegmentDescription(c, part, text, lo, hi)).Error(cfg.problem) fg := k.Text switch { - case *badText != "": + case bad.text != "": fg = k.Danger case seg.Focused() && !cfg.readOnly: seg.Background(k.Accent) @@ -338,7 +354,7 @@ func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, seg.Background(k.SurfaceHover) } seg.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum").SingleLine() }) - return seg, done, bad + return seg, done } // timeSegmentDescription gives assistive technology a segment's value and range. diff --git a/time_picker_popup.go b/time_picker_popup.go index e08dc9e..463afe7 100644 --- a/time_picker_popup.go +++ b/time_picker_popup.go @@ -10,7 +10,7 @@ import ( ) // timeColumns lists choosable values per segment and reports whether a choice edited *draft. -func timeColumns(c *ui.Context, draft *TimeOfDay, cfg timeConfig, fresh bool) (edited bool) { +func timeColumns(c *ui.Context, draft *TimeOfDay, cfg timeConfig, fresh bool) (picked timePart, edited bool) { k := Tokens(c) d := Density(c) h := d.ControlHeight() - 4 @@ -77,14 +77,17 @@ func timeColumns(c *ui.Context, draft *TimeOfDay, cfg timeConfig, fresh bool) (e item.Background(k.SurfaceHover) } internal.Ring(item, k.Focus, theme.ControlRadius) + if o == t { + item.Draw(func(p *ui.Painter, r ui.Rect) { datetimeSelectedMark(p, r, k.OnAccent) }) + } item.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum") }) - if item.Clicked() && o != *draft { - *draft, edited = o, true + if item.Clicked() { + *draft, picked, edited = o, part, true } } }) }) } }) - return edited + return picked, edited } diff --git a/time_range_picker.go b/time_range_picker.go index 3a03486..66b8a61 100644 --- a/time_range_picker.go +++ b/time_range_picker.go @@ -53,8 +53,9 @@ func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOption draft := ui.Local(col, "time-range-draft", func() TimeRange { return *value }) seen := ui.Local(col, "time-range-seen", func() TimeRange { return *value }) problem := ui.Local(col, "time-range-error", func() string { return "" }) + pending := ui.Local(col, "time-range-pending", func() bool { return false }) if *seen != *value { - *draft, *seen = *value, *value + *draft, *seen, *pending = *value, *value, false } changed := false col.Children(func() { @@ -82,9 +83,13 @@ func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOption *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 + *pending = *pending || edited + if *pending && p == "" { + if *draft != *value { + slog.Debug("mujica: time_range_picker change", "from", *value, "to", *draft) + *value, *seen, changed = *draft, *draft, true + } + *pending = false } datetimeError(c, *problem) })