EditableTextView.Changed is a method like every other result's. AlertDialogResult, PopconfirmResult and DateTimeResult spell Canceled the American way, matching EditableTextView and LoadingOverlayResult. Callers, Gallery and docs updated.
201 lines
6.9 KiB
Go
201 lines
6.9 KiB
Go
package mujica
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// DateTimePickerOptions configure a DateTimePicker.
|
|
type DateTimePickerOptions struct {
|
|
// 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
|
|
Hour12 bool
|
|
MinuteStep int
|
|
WeekStart WeekStart
|
|
// 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 wall time in opts.Location.
|
|
func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) DateTimeResult {
|
|
loc := opts.Location
|
|
if loc == nil {
|
|
panic("mujica: DateTimePicker needs a Location")
|
|
}
|
|
if !opts.Min.IsZero() && !opts.Max.IsZero() && opts.Min.After(opts.Max) {
|
|
panic(fmt.Sprintf("mujica: DateTimePicker Min %v is after Max %v", opts.Min, opts.Max))
|
|
}
|
|
cfg := timePickerConfig("DateTimePicker", TimePickerOptions{Seconds: opts.Seconds, Hour12: opts.Hour12, MinuteStep: opts.MinuteStep})
|
|
cfg.label = msg(c, "date_time_picker.time")
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
today := datetimeToday(c, opts.Today)
|
|
var lo, hi Date
|
|
if !opts.Min.IsZero() {
|
|
lo = DateOf(opts.Min.In(loc))
|
|
}
|
|
if !opts.Max.IsZero() {
|
|
hi = DateOf(opts.Max.In(loc))
|
|
}
|
|
col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
|
|
open := ui.Local(col, "date-time-open", func() bool { return false })
|
|
wasOpen := ui.Local(col, "date-time-was-open", func() bool { return false })
|
|
day := ui.Local(col, "date-time-day", func() Date { return Date{} })
|
|
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 "" })
|
|
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, canceled, done := false, false, false
|
|
label := opts.Label
|
|
if label == "" {
|
|
label = msg(c, "date_time_picker.placeholder")
|
|
}
|
|
text := ""
|
|
if !value.IsZero() {
|
|
text = format(*value)
|
|
}
|
|
col.Children(func() {
|
|
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).Error(*problem)
|
|
if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
|
|
*open = !*open
|
|
if *open {
|
|
*day, *clock, *problem = today, TimeOfDay{}, ""
|
|
if !value.IsZero() {
|
|
w := value.In(loc)
|
|
*day, *clock = DateOf(w), TimeOfDay{w.Hour(), w.Minute(), w.Second()}
|
|
}
|
|
*cursor = *day
|
|
slog.Debug("mujica: date_time_picker open")
|
|
}
|
|
}
|
|
})
|
|
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),
|
|
today: today,
|
|
weekStart: opts.WeekStart,
|
|
autoFocus: true,
|
|
})
|
|
if !picked.IsZero() {
|
|
*day, *problem = picked, ""
|
|
}
|
|
bad := ""
|
|
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
|
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(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
|
})
|
|
datetimeError(c, *problem)
|
|
datetimeFooter(c, func() {
|
|
if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
|
|
*open = false
|
|
}
|
|
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) {
|
|
slog.Debug("mujica: date_time_picker change", "from", *value, "to", at)
|
|
*value, changed = at, true
|
|
}
|
|
*open, done = false, true
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
if *wasOpen && !*open && !done {
|
|
slog.Debug("mujica: date_time_picker cancel")
|
|
canceled = true
|
|
}
|
|
*wasOpen = *open
|
|
if !*open {
|
|
*problem = ""
|
|
}
|
|
return DateTimeResult{Element: col, changed: changed, canceled: canceled}
|
|
}
|
|
|
|
// dateTimeConfirm turns the draft into an instant, or explains why it cannot.
|
|
func dateTimeConfirm(c *ui.Context, d Date, t TimeOfDay, loc *time.Location, opts DateTimePickerOptions, cfg timeConfig) (time.Time, string) {
|
|
if p := timePickerProblem(c, t, cfg); p != "" {
|
|
return time.Time{}, p
|
|
}
|
|
wall := d.Format(opts.Layout) + " " + timeText(c, t, cfg)
|
|
at, n := dateTimeResolve(d, t, loc)
|
|
switch {
|
|
case n == 0:
|
|
slog.Debug("mujica: date_time_picker gap", "wall", wall, "zone", loc.String())
|
|
return at, fmt.Sprintf(msg(c, "date_time_picker.gap"), wall, loc.String())
|
|
case n > 1:
|
|
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), 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), opts.Layout, cfg))
|
|
}
|
|
return at, ""
|
|
}
|
|
|
|
// 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)
|
|
var hits []time.Time
|
|
for _, probe := range []time.Time{guess.Add(-24 * time.Hour), guess, guess.Add(24 * time.Hour)} {
|
|
_, off := probe.Zone()
|
|
u := wall.Add(-time.Duration(off) * time.Second).In(loc)
|
|
y, m, dd := u.Date()
|
|
if (Date{y, m, dd}) != d || u.Hour() != t.Hour || u.Minute() != t.Minute || u.Second() != t.Second {
|
|
continue
|
|
}
|
|
seen := false
|
|
for _, h := range hits {
|
|
seen = seen || h.Equal(u)
|
|
}
|
|
if !seen {
|
|
hits = append(hits, u)
|
|
}
|
|
}
|
|
if len(hits) == 0 {
|
|
return time.Time{}, 0
|
|
}
|
|
return hits[0], len(hits)
|
|
}
|
|
|
|
// 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")
|
|
}
|