Files
MujicaUI/date_range_picker.go
T

200 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}