200 lines
6.7 KiB
Go
200 lines
6.7 KiB
Go
package mujica
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
|
||
"github.com/egoist/mygo/ui"
|
||
|
||
"mujicaui/theme"
|
||
)
|
||
|
||
// DateRange is an inclusive span of days. The zero value means no range.
|
||
type DateRange struct {
|
||
Start, End Date
|
||
}
|
||
|
||
// IsZero reports whether r is the empty range.
|
||
func (r DateRange) IsZero() bool { return r == DateRange{} }
|
||
|
||
// Days returns how many days r covers, counting both ends.
|
||
func (r DateRange) Days() int {
|
||
if r.Start.IsZero() || r.End.IsZero() {
|
||
return 0
|
||
}
|
||
return int(r.End.civil().Sub(r.Start.civil()).Hours()/24) + 1
|
||
}
|
||
|
||
// DateRangePickerOptions configure a DateRangePicker.
|
||
type DateRangePickerOptions struct {
|
||
// Layout is the Go time layout shown; empty uses 2006-01-02.
|
||
Layout string
|
||
// Min and Max bound both ends; zero leaves a side open.
|
||
Min, Max Date
|
||
// DisableDate marks days that neither end nor the span may include.
|
||
DisableDate func(Date) bool
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
if value.Start.IsZero() != value.End.IsZero() || !value.IsZero() && value.End.Compare(value.Start) < 0 {
|
||
panic(fmt.Sprintf("mujica: DateRangePicker value %v..%v is not a range", value.Start, value.End))
|
||
}
|
||
datetimeCheckBounds("DateRangePicker", opts.Min, opts.Max)
|
||
layout := opts.Layout
|
||
if layout == "" {
|
||
layout = "2006-01-02"
|
||
}
|
||
d := Density(c)
|
||
k := Tokens(c)
|
||
today := datetimeToday(c, opts.Today)
|
||
enabled := calendarEnabled(opts.Min, opts.Max, opts.DisableDate)
|
||
col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled)
|
||
open := ui.Local(col, "date-range-open", func() bool { return false })
|
||
wasOpen := ui.Local(col, "date-range-was-open", func() bool { return false })
|
||
draft := ui.Local(col, "date-range-draft", func() DateRange { return DateRange{} })
|
||
cursor := ui.Local(col, "date-range-cursor", func() Date { return Date{} })
|
||
hover := ui.Local(col, "date-range-hover", func() Date { return Date{} })
|
||
problem := ui.Local(col, "date-range-error", func() string { return "" })
|
||
changed, cancelled, done := false, false, false
|
||
finish := func(to DateRange, how string) {
|
||
if to != *value {
|
||
slog.Debug("mujica: date_range_picker change", "from", *value, "to", to, "by", how)
|
||
*value = to
|
||
changed = true
|
||
}
|
||
*open, done = false, true
|
||
}
|
||
label := opts.Label
|
||
if label == "" {
|
||
label = msg(c, "date_range_picker.label")
|
||
}
|
||
text := ""
|
||
if !value.IsZero() {
|
||
text = value.Start.Format(layout) + " – " + value.End.Format(layout)
|
||
}
|
||
var field *ui.Element
|
||
col.Children(func() {
|
||
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).Error(*problem)
|
||
if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
|
||
*open = !*open
|
||
if *open {
|
||
*draft, *problem, *hover = *value, "", Date{}
|
||
*cursor = value.Start
|
||
if cursor.IsZero() {
|
||
*cursor = today
|
||
}
|
||
slog.Debug("mujica: date_range_picker open")
|
||
}
|
||
}
|
||
})
|
||
datetimePopup(c, field, open, opts.ReadOnly, func() {
|
||
ui.Text(c, dateRangeSummary(c, *draft, layout)).TextColor(k.TextMuted).
|
||
FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").Role(ui.RoleStatus)
|
||
end := draft.End
|
||
if end.IsZero() && !draft.Start.IsZero() {
|
||
end = *hover
|
||
if end.IsZero() {
|
||
end = *cursor
|
||
}
|
||
}
|
||
picked, hovered := calendarMonth(c, cursor, calendarConfig{
|
||
chosen: func(x Date) bool { return x == draft.Start || x == draft.End },
|
||
inRange: func(x Date) bool { return !draft.Start.IsZero() && x.within(draft.Start, end) },
|
||
enabled: enabled,
|
||
today: today,
|
||
weekStart: opts.WeekStart,
|
||
autoFocus: true,
|
||
})
|
||
if hovered != *hover {
|
||
*hover = hovered
|
||
c.Invalidate()
|
||
}
|
||
if !picked.IsZero() {
|
||
*problem = dateRangePick(c, draft, picked, enabled, layout)
|
||
}
|
||
datetimeError(c, *problem)
|
||
datetimeFooter(c, func() {
|
||
if Button(c, msg(c, "calendar.clear"), ButtonOptions{Variant: Ghost, Disabled: value.IsZero() && draft.IsZero()}).Clicked() {
|
||
finish(DateRange{}, "clear")
|
||
}
|
||
if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
|
||
*open = false
|
||
}
|
||
ready := !draft.Start.IsZero() && !draft.End.IsZero()
|
||
if Button(c, msg(c, "calendar.confirm"), ButtonOptions{Disabled: !ready}).Clicked() {
|
||
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")
|
||
}
|
||
}
|
||
})
|
||
})
|
||
})
|
||
if *wasOpen && !*open && !done {
|
||
slog.Debug("mujica: date_range_picker cancel")
|
||
cancelled = true
|
||
}
|
||
*wasOpen = *open
|
||
if !*open {
|
||
*problem = ""
|
||
}
|
||
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 {
|
||
*draft = DateRange{Start: day}
|
||
return ""
|
||
}
|
||
for x := draft.Start; x.Compare(day) <= 0; x = x.AddDays(1) {
|
||
if !enabled(x) {
|
||
return fmt.Sprintf(msg(c, "date_range_picker.blocked"), x.Format(layout))
|
||
}
|
||
}
|
||
draft.End = day
|
||
return ""
|
||
}
|
||
|
||
// dateRangeSummary describes the draft: what to pick next, or the span chosen.
|
||
func dateRangeSummary(c *ui.Context, r DateRange, layout string) string {
|
||
switch {
|
||
case r.Start.IsZero():
|
||
return msg(c, "date_range_picker.pick_start")
|
||
case r.End.IsZero():
|
||
return fmt.Sprintf(msg(c, "date_range_picker.pick_end"), r.Start.Format(layout))
|
||
}
|
||
return fmt.Sprintf(msg(c, "date_range_picker.summary"), r.Start.Format(layout), r.End.Format(layout), r.Days())
|
||
}
|