Merge branch 'worktree-agent-ad7403b5194d75449'
This commit is contained in:
15 files changed
+154
-36
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 "".
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、按下、聚焦(键盘焦点环)、选中(酒红填充 + 单选语义)、今日(加粗 + 菱形)、不可选(删除线并由 MyGo 置灰)、非本月(弱化文字)、禁用。
|
||||
默认、悬停、按下、聚焦(键盘焦点环)、选中(酒红填充 + 内嵌细框 + 单选语义)、今日(加粗 + 菱形)、不可选(删除线并由 MyGo 置灰)、非本月(弱化文字)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、聚焦、选中(酒红填充)、当前月(加粗 + 菱形)、不可选(删除线)、禁用。
|
||||
默认、悬停、聚焦、选中(酒红填充 + 内嵌细框)、当前月(加粗 + 菱形)、不可选(删除线)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、聚焦、选中、当前年、不可选、相邻十年(弱化)、禁用。
|
||||
默认、悬停、聚焦、选中(酒红填充 + 内嵌细框)、当前年(加粗 + 菱形)、不可选、相邻十年(弱化)、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
+37
-21
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user