Files
MujicaUI/calendar_month_view.go
T

249 lines
8.4 KiB
Go

package mujica
import (
"fmt"
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CalendarMonthViewOptions configure a CalendarMonthView.
type CalendarMonthViewOptions struct {
// Location is the zone days are cut in; required.
Location *time.Location
// Version must change when events change in place.
Version int
WeekStart WeekStart
// MaxPerDay is rows shown before "+N"; zero means 3.
MaxPerDay int
// Today is marked; zero uses the system clock.
Today Date
Hour12 bool
Disabled bool
Label string
}
// monthDrag is the event id a month view chip drags.
type monthDrag string
// monthDay is one cell's rows of side-by-side events.
type monthDay struct {
rows [][]int
n int
}
// CalendarMonthView shows a month of events; drags reschedule them.
func CalendarMonthView(c *ui.Context, month *YearMonth, events []CalendarEvent, opts CalendarMonthViewOptions) CalendarViewResult {
loc := eventLocation("CalendarMonthView", opts.Location)
if month.IsZero() || month.Month < time.January || month.Month > time.December {
panic(fmt.Sprintf("mujica: CalendarMonthView month %v is not valid", *month))
}
if opts.MaxPerDay < 0 {
panic("mujica: CalendarMonthView MaxPerDay is negative")
}
maxRows := opts.MaxPerDay
if maxRows == 0 {
maxRows = 3
}
eventsCheck("CalendarMonthView", events)
k := Tokens(c)
d := Density(c)
today := datetimeToday(c, opts.Today)
first := calendarFirstWeekday(c, opts.WeekStart)
start := Date{month.Year, month.Month, 1}
start = start.AddDays(-((int(start.Weekday()) - int(first) + 7) % 7))
label := opts.Label
if label == "" {
label = datetimeMonthTitle(c, month.Year, month.Month)
}
r := CalendarViewResult{}
root := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
problem := ui.Local(root, "month-view-problem", func() string { return "" })
more := ui.Local(root, "month-view-more", func() Date { return Date{} })
days := eventsDerived(root, "month-view-days", events, opts.Version, eventDayStart(start, loc), eventDayStart(start.AddDays(42), loc),
func(hits []int) []monthDay { return monthLayout(events, hits, start, loc) })
disabled := func() bool { return root.IsDisabled() }
apply := func(ch CalendarChange, p string) {
*problem = p
if p == "" && !disabled() {
slog.Debug("mujica: calendar_month_view move", "id", ch.ID, "start", ch.Start)
r.change = &ch
}
}
root.Children(func() {
ui.Row(c).AlignItems(ui.Center).Gap(d.Unit()).Children(func() {
if iconAction(c, icons.Must("chevron-left"), msg(c, "calendar.prev_month")).Clicked() && !disabled() {
*month = YearMonth{start.AddDays(7).AddMonths(-1).Year, start.AddDays(7).AddMonths(-1).Month}
}
ui.Text(c, datetimeMonthTitle(c, month.Year, month.Month)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).
TextColor(k.Text).Grow(1).TextAlign(ui.Center)
if iconAction(c, icons.Must("chevron-right"), msg(c, "calendar.next_month")).Clicked() && !disabled() {
*month = YearMonth{start.AddDays(7).AddMonths(1).Year, start.AddDays(7).AddMonths(1).Month}
}
})
ui.Row(c).Children(func() {
for i := range 7 {
wd := (int(first) + i) % 7
ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Grow(1).Basis(0).TextAlign(ui.Center).
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Label(msg(c, "calendar.weekday_long_"+strconv.Itoa(wd)))
}
})
ui.Column(c).Border(theme.BorderWidth, k.Border).Dividers(theme.BorderWidth, k.Border).FocusGroup(ui.Horizontal | ui.Vertical).Children(func() {
for w := range 6 {
ui.Row(c).Dividers(theme.BorderWidth, k.Border).Children(func() {
for i := range 7 {
at := w*7 + i
day := start.AddDays(at)
monthCell(c, day, *month, today, days[at], events, maxRows, loc, opts, &r, more, apply)
}
})
}
})
datetimeError(c, *problem)
})
return r
}
// monthLayout buckets events per day, concurrent ones side by side.
func monthLayout(events []CalendarEvent, hits []int, start Date, loc *time.Location) []monthDay {
out := make([]monthDay, 42)
for i := range out {
from, to := eventDayStart(start.AddDays(i), loc), eventDayStart(start.AddDays(i+1), loc)
var idx []int
var spans []eventSpan
for _, h := range hits {
ev := events[h]
if ev.Start.Before(to) && ev.End.After(from) {
idx = append(idx, h)
spans = append(spans, eventSpan{float64(max(ev.Start.Unix(), from.Unix())), float64(min(ev.End.Unix(), to.Unix()))})
}
}
col, _ := eventColumns(spans)
for j, h := range idx {
if col[j] == 0 || len(out[i].rows) == 0 {
out[i].rows = append(out[i].rows, []int{h})
} else {
last := len(out[i].rows) - 1
out[i].rows[last] = append(out[i].rows[last], h)
}
}
out[i].n = len(idx)
}
return out
}
// monthCell draws one day: number, chips and "+N".
func monthCell(c *ui.Context, day Date, month YearMonth, today Date, lay monthDay, events []CalendarEvent, maxRows int,
loc *time.Location, opts CalendarMonthViewOptions, r *CalendarViewResult, more *Date, apply func(CalendarChange, string)) {
k := Tokens(c)
d := Density(c)
cell := ui.Column(c).Grow(1).Basis(0).MinWidth(0).MinHeight(d.ControlHeight() * 3.5).Padding(d.Unit()).Gap(d.Unit()).Clip()
if id, ok := ui.Drop[monthDrag](cell); ok && !cell.IsDisabled() {
for _, ev := range events {
if ev.ID == string(id) {
apply(eventShift(c, ev, monthDays(ev.Start.In(loc), day), loc))
}
}
}
if _, over := ui.DragOver[monthDrag](cell); over {
cell.Background(k.Selection)
cell.DrawOver(func(p *ui.Painter, rr ui.Rect) { datetimeSelectedMark(p, rr, k.Focus) })
}
cell.Children(func() {
fg := k.Text
if day.Month != month.Month {
fg = k.TextMuted
}
num := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.MinHitSize / 2).Label(datetimeDayLabel(c, day))
internal.Ring(num, k.Focus, theme.MinHitSize/2)
if day == today {
num.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
} else if num.Hovered() {
num.Background(k.SurfaceHover)
}
num.Children(func() {
t := ui.Text(c, strconv.Itoa(day.Day)).TextColor(fg).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
if day == today {
t.Bold()
}
})
if num.Clicked() && !num.IsDisabled() {
r.day = day
}
shown := 0
for i, row := range lay.rows {
if i >= maxRows {
break
}
ui.Row(c).Gap(d.Unit() / 2).Children(func() {
for _, h := range row {
monthChip(c, events[h], loc, opts, r, apply)
shown++
}
})
}
if hidden := lay.n - shown; hidden > 0 {
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Justify(ui.Start).PaddingX(d.Unit()).Radius(theme.ControlRadius).
Label(calendarCount(c, "calendar_month_view.more_label", hidden))
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "calendar_month_view.more"), hidden)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
})
if b.Clicked() && !b.IsDisabled() {
*more = day
}
open := *more == day
ui.PopoverBase(c, b, &open, func(panel *ui.Element) {
overlayStyle(c, panel, overlayEnter(c, panel))
_, wh := c.Size()
panel.Padding(d.Unit() * 2).Gap(d.Unit()).MinWidth(d.ControlHeight() * 6).MaxHeight(wh / 2)
ui.Text(c, datetimeDayLabel(c, day)).Font(theme.SerifFont).TextColor(k.Text)
ui.Scroll(c).Shrink(1).Children(func() {
ui.Column(c).Gap(d.Unit()).Children(func() {
for _, row := range lay.rows {
for _, h := range row {
monthChip(c, events[h], loc, opts, r, apply)
}
}
})
})
})
if !open && *more == day {
*more = Date{}
}
}
})
}
// monthChip is a draggable chip; Alt+arrows move it.
func monthChip(c *ui.Context, ev CalendarEvent, loc *time.Location, opts CalendarMonthViewOptions, r *CalendarViewResult, apply func(CalendarChange, string)) {
chip := EventChip(c, ev, EventChipOptions{Location: loc, Hour12: opts.Hour12, Inline: true}).Grow(1).Basis(0).MinWidth(theme.MinHitSize)
if chip.IsDisabled() {
return
}
chip.Drag(monthDrag(ev.ID))
if chip.Clicked() {
r.opened = ev.ID
}
for key, days := range map[ui.Key]int{ui.KeyLeft: -1, ui.KeyRight: 1, ui.KeyUp: -7, ui.KeyDown: 7} {
if chip.Shortcut(ui.Alt, key) {
apply(eventShift(c, ev, days, loc))
}
}
}
// monthDays counts calendar days from t's day to day.
func monthDays(t time.Time, day Date) int {
return int(day.civil().Sub(DateOf(t).civil()).Hours() / 24)
}