Merge branch 'worktree-agent-ad7403b5194d75449'

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 13:01:58 -04:00
commit deddc7bd34
5 files changed
+275 -86

No files matched your search

+1 -1
View File
@@ -113,7 +113,7 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu
}
in.Label(label).Error(*problem)
focused := in.Focused()
if !opts.ReadOnly && (in.Submitted() || *wasFocused && !focused && *text != format(*value)) {
if !opts.ReadOnly && !in.IsDisabled() && (in.Submitted() || *wasFocused && !focused && *text != format(*value)) {
submit()
}
*wasFocused = focused
+22 -1
View File
@@ -135,7 +135,12 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption
}
ready := !draft.Start.IsZero() && !draft.End.IsZero()
if Button(c, msg(c, "calendar.confirm"), ButtonOptions{Disabled: !ready}).Clicked() {
finish(*draft, "confirm")
if p := dateRangeProblem(c, *draft, opts.Min, opts.Max, enabled, layout); p != "" {
slog.Debug("mujica: date_range_picker invalid confirm", "draft", *draft)
*problem = p
} else {
finish(*draft, "confirm")
}
}
})
})
@@ -151,6 +156,22 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption
return DateTimeResult{Element: col, changed: changed, cancelled: cancelled}
}
// dateRangeProblem checks a whole draft against the current bounds and filter.
func dateRangeProblem(c *ui.Context, r DateRange, lo, hi Date, enabled func(Date) bool, layout string) string {
format := func(d Date) string { return d.Format(layout) }
for _, d := range []Date{r.Start, r.End} {
if p := datePickerProblem(c, d, lo, hi, enabled, format); p != "" {
return p
}
}
for x := r.Start; x.Compare(r.End) <= 0; x = x.AddDays(1) {
if !enabled(x) {
return fmt.Sprintf(msg(c, "date_range_picker.blocked"), format(x))
}
}
return ""
}
// dateRangePick applies a picked day to the draft and returns any problem.
func dateRangePick(c *ui.Context, draft *DateRange, day Date, enabled func(Date) bool, layout string) string {
if draft.Start.IsZero() || !draft.End.IsZero() || day.Compare(draft.Start) < 0 {
+142
View File
@@ -0,0 +1,142 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestTimePickerDisabledIgnoresSegmentKeys(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
opts := TimePickerOptions{Label: "Alarm"}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
if TimePicker(c, &v, opts).Changed() {
n++
}
}, 420, 300)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Alarm: Hours") {
t.Fatal("setup: hours not focused")
}
opts.Disabled = true
tt.Frame()
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key5)
if v != (TimeOfDay{9, 30, 0}) || n != 0 {
t.Fatalf("disabled picker took keys: %v (%d)", v, n)
}
}
func TestTimeRangePickerDisabledIgnoresSegmentKeys(t *testing.T) {
v, n := TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 0, 0}}, 0
opts := TimeRangePickerOptions{}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
if TimeRangePicker(c, &v, opts).Changed() {
n++
}
}, 520, 300)
_ = tt.Click("End time: Hours")
opts.Disabled = true
tt.Frame()
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if v != (TimeRange{TimeOfDay{9, 0, 0}, TimeOfDay{17, 0, 0}}) || n != 0 {
t.Fatalf("disabled range took keys: %v (%d)", v, n)
}
}
func TestDatePickerDisabledBlurDoesNotCommit(t *testing.T) {
v, n := oct(5), 0
opts := DatePickerOptions{}
tt := datePickerLive(&v, &opts, &n)
datePickerType(tt, "Due", "2026-10-20")
opts.Disabled = true
tt.Frame()
_ = tt.Click("Open calendar")
tt.Key(0, ui.KeyTab)
tt.Frame()
if v != oct(5) || n != 0 {
t.Fatalf("disabled picker committed its draft on blur: %v (%d)", v, n)
}
}
func TestTimePickerSegmentErrorSurvivesOtherSegments(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)
_ = tt.Click("Alarm: Minutes")
tt.Key(0, ui.KeyUp)
if !tt.HasText("29") || !tt.HasText("Hours must be from 0 to 23.") {
t.Fatalf("editing minutes cleared the hour error: %v", tt.Texts())
}
if v != (TimeOfDay{2, 30, 0}) {
t.Fatalf("value written while a segment is invalid: %v", v)
}
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.Key0)
tt.Key(0, ui.Key8)
if v != (TimeOfDay{8, 31, 0}) || tt.HasText("Hours must be") {
t.Fatalf("fixing the hour should commit both: %v", v)
}
}
func TestDateTimePickerBlocksConfirmWithInvalidSegment(t *testing.T) {
v := time.Date(2026, time.October, 5, 9, 30, 0, 0, time.UTC)
n, x := 0, 0
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: time.UTC}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click("Time: Hours")
tt.Key(0, ui.Key2)
tt.Key(0, ui.Key9)
_ = tt.Click("Time: Minutes")
tt.Key(0, ui.KeyUp)
_ = tt.Click("Confirm")
if n != 0 || !v.Equal(time.Date(2026, time.October, 5, 9, 30, 0, 0, time.UTC)) {
t.Fatalf("confirmed with an invalid hour: %v", v)
}
if !tt.HasText("Hours must be from 0 to 23.") || !tt.HasText("Confirm") {
t.Fatalf("expected the popup to stay with the error: %v", tt.Texts())
}
}
func TestDateRangePickerConfirmChecksCurrentBounds(t *testing.T) {
v, n := DateRange{}, 0
opts := DateRangePickerOptions{}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
o := opts
o.Today, o.Label = calendarToday, "Stay"
if DateRangePicker(c, &v, o).Changed() {
n++
}
}, 420, 520)
_ = tt.Click("Stay")
_ = tt.Click(datetimeTestLabel(oct(10)))
_ = tt.Click(datetimeTestLabel(oct(15)))
opts.Max = oct(12)
tt.Frame()
_ = tt.Click("Confirm")
if n != 0 || !v.IsZero() || !tt.HasText("Choose 2026-10-12 or earlier.") {
t.Fatalf("out-of-range draft committed: %v %v", v, tt.Texts())
}
opts.Max = Date{}
opts.DisableDate = func(d Date) bool { return d == oct(11) }
tt.Frame()
_ = tt.Click("Confirm")
if n != 0 || !tt.HasText("The range cannot include 2026-10-11.") {
t.Fatalf("draft over a newly disabled day committed: %v", v)
}
opts.DisableDate = nil
tt.Frame()
_ = tt.Click("Confirm")
if n != 1 || v != (DateRange{oct(10), oct(15)}) {
t.Fatalf("valid draft not committed: %v", v)
}
}
+20 -84
View File
@@ -259,11 +259,11 @@ func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig,
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{} })
badFor := ui.Local(seg, "time-bad-for", func() int { return 0 })
if !seg.Focused() {
*typed = ""
}
if *badFor != *draft {
if *badFor != timePartValue(*draft, part, cfg) {
*badText = ""
}
set := func(t TimeOfDay) {
@@ -275,7 +275,7 @@ func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig,
}
t := *draft
lo, hi := timeLimits(part, cfg)
if !cfg.readOnly {
if !cfg.readOnly && !seg.IsDisabled() {
switch {
case seg.Shortcut(0, ui.KeyUp):
set(timeStepPart(t, part, cfg, 1))
@@ -299,7 +299,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), *draft
*badText, *badFor = fmt.Sprintf("%02d", v), timePartValue(*draft, part, cfg)
slog.Debug("mujica: time_picker invalid segment", "part", int(part), "typed", v)
}
}
@@ -349,6 +349,22 @@ func timeSegmentDescription(c *ui.Context, part timePart, text string, lo, hi in
return fmt.Sprintf(msg(c, "time_picker.segment"), strings.TrimSpace(text), lo, hi)
}
// timePartValue is the value a segment shows, so other segments' edits keep its error.
func timePartValue(t TimeOfDay, part timePart, cfg timeConfig) int {
switch part {
case timeHourPart:
if cfg.hour12 {
return timeHour12(t.Hour)
}
return t.Hour
case timeMinutePart:
return t.Minute
case timeSecondPart:
return t.Second
}
return t.Hour / 12
}
// 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 {
@@ -408,83 +424,3 @@ func timeType(typed *string, n, lo, hi int) (v int, ok, complete bool) {
*typed = s
return v, v >= lo && v <= hi, complete
}
// 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) {
k := Tokens(c)
d := Density(c)
h := d.ControlHeight() - 4
within := func(lo, hi TimeOfDay) bool { return hi.Compare(cfg.min) >= 0 && lo.Compare(cfg.max) <= 0 }
ui.Row(c).Gap(d.Unit()).Children(func() {
for _, part := range timeParts(cfg) {
t := *draft
var options []TimeOfDay
switch part {
case timeHourPart:
for v := range 24 {
if !cfg.hour12 || v/12 == t.Hour/12 {
options = append(options, TimeOfDay{v, t.Minute, t.Second})
}
}
case timeMinutePart:
for v := 0; v < 60; v += cfg.minuteStep {
options = append(options, TimeOfDay{t.Hour, v, t.Second})
}
case timeSecondPart:
for v := 0; v < 60; v += cfg.secondStep {
options = append(options, TimeOfDay{t.Hour, t.Minute, v})
}
case timePeriodPart:
options = []TimeOfDay{{t.Hour % 12, t.Minute, t.Second}, {t.Hour%12 + 12, t.Minute, t.Second}}
}
name := msg(c, timePartNames[part])
ui.Scroll(c).Height(h * 6).Width(d.ControlHeight() * 1.5).Children(func() {
ui.Column(c).FocusGroup(ui.Vertical).Role(ui.RoleRadioGroup).Label(name).Children(func() {
for _, o := range options {
var lo, hi TimeOfDay
text := ""
switch part {
case timeHourPart:
lo, hi = TimeOfDay{o.Hour, 0, 0}, TimeOfDay{o.Hour, 59, 59}
text = fmt.Sprintf("%02d", o.Hour)
if cfg.hour12 {
text = fmt.Sprintf("%d", timeHour12(o.Hour))
}
case timeMinutePart:
lo, hi = TimeOfDay{o.Hour, o.Minute, 0}, TimeOfDay{o.Hour, o.Minute, 59}
text = fmt.Sprintf("%02d", o.Minute)
case timeSecondPart:
lo, hi = o, o
text = fmt.Sprintf("%02d", o.Second)
case timePeriodPart:
lo, hi = TimeOfDay{o.Hour / 12 * 12, 0, 0}, TimeOfDay{o.Hour/12*12 + 11, 59, 59}
text = timePeriod(c, o.Hour)
}
sel := t
item := ui.RadioBase(c, &sel, o).Height(h).FillWidth().Center().Radius(theme.ControlRadius).
Disabled(!within(lo, hi)).Label(text)
fg := k.Text
switch {
case o == t:
item.Background(k.Accent)
fg = k.OnAccent
if fresh {
item.ScrollIntoView()
}
case item.Pressed():
item.Background(k.Selection)
case item.Hovered():
item.Background(k.SurfaceHover)
}
internal.Ring(item, k.Focus, theme.ControlRadius)
item.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum") })
if item.Clicked() && o != *draft {
*draft, edited = o, true
}
}
})
})
}
})
return edited
}
+90
View File
@@ -0,0 +1,90 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// 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) {
k := Tokens(c)
d := Density(c)
h := d.ControlHeight() - 4
within := func(lo, hi TimeOfDay) bool { return hi.Compare(cfg.min) >= 0 && lo.Compare(cfg.max) <= 0 }
ui.Row(c).Gap(d.Unit()).Children(func() {
for _, part := range timeParts(cfg) {
t := *draft
var options []TimeOfDay
switch part {
case timeHourPart:
for v := range 24 {
if !cfg.hour12 || v/12 == t.Hour/12 {
options = append(options, TimeOfDay{v, t.Minute, t.Second})
}
}
case timeMinutePart:
for v := 0; v < 60; v += cfg.minuteStep {
options = append(options, TimeOfDay{t.Hour, v, t.Second})
}
case timeSecondPart:
for v := 0; v < 60; v += cfg.secondStep {
options = append(options, TimeOfDay{t.Hour, t.Minute, v})
}
case timePeriodPart:
options = []TimeOfDay{{t.Hour % 12, t.Minute, t.Second}, {t.Hour%12 + 12, t.Minute, t.Second}}
}
name := msg(c, timePartNames[part])
ui.Scroll(c).Height(h * 6).Width(d.ControlHeight() * 1.5).Children(func() {
ui.Column(c).FocusGroup(ui.Vertical).Role(ui.RoleRadioGroup).Label(name).Children(func() {
for _, o := range options {
var lo, hi TimeOfDay
text := ""
switch part {
case timeHourPart:
lo, hi = TimeOfDay{o.Hour, 0, 0}, TimeOfDay{o.Hour, 59, 59}
text = fmt.Sprintf("%02d", o.Hour)
if cfg.hour12 {
text = fmt.Sprintf("%d", timeHour12(o.Hour))
}
case timeMinutePart:
lo, hi = TimeOfDay{o.Hour, o.Minute, 0}, TimeOfDay{o.Hour, o.Minute, 59}
text = fmt.Sprintf("%02d", o.Minute)
case timeSecondPart:
lo, hi = o, o
text = fmt.Sprintf("%02d", o.Second)
case timePeriodPart:
lo, hi = TimeOfDay{o.Hour / 12 * 12, 0, 0}, TimeOfDay{o.Hour/12*12 + 11, 59, 59}
text = timePeriod(c, o.Hour)
}
sel := t
item := ui.RadioBase(c, &sel, o).Height(h).FillWidth().Center().Radius(theme.ControlRadius).
Disabled(!within(lo, hi)).Label(text)
fg := k.Text
switch {
case o == t:
item.Background(k.Accent)
fg = k.OnAccent
if fresh {
item.ScrollIntoView()
}
case item.Pressed():
item.Background(k.Selection)
case item.Hovered():
item.Background(k.SurfaceHover)
}
internal.Ring(item, k.Focus, theme.ControlRadius)
item.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum") })
if item.Clicked() && o != *draft {
*draft, edited = o, true
}
}
})
})
}
})
return edited
}