Files
MujicaUI/calendar_time_grid.go
T

370 lines
12 KiB
Go

package mujica
import (
"fmt"
"log/slog"
"math"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// timeGridConfig is what the week and day views share.
type timeGridConfig struct {
name string
loc *time.Location
version int
slot int
today Date
now time.Time
hour12 bool
}
// timeGesture is a drag in progress on the time grid.
type timeGesture struct {
active bool
kind CalendarChangeKind
id string
day int
anchor int
dx, dy float32
cur int
}
// timeGridDay is one column's timed and all-day events.
type timeGridDay struct {
timed []timePlaced
allDay []int
}
type timePlaced struct {
ev int
from, to int
col, cols int
}
// timeGridSlot checks and defaults the slot length.
func timeGridSlot(name string, slot int) int {
if slot == 0 {
return 30
}
if slot < 0 || 60%slot != 0 && slot%60 != 0 {
panic(fmt.Sprintf("mujica: %s SlotMinutes %d must divide an hour", name, slot))
}
return slot
}
// timeGrid draws day columns and turns gestures into changes.
func timeGrid(c *ui.Context, root *ui.Element, days []Date, events []CalendarEvent, cfg timeGridConfig, r *CalendarViewResult) {
k := Tokens(c)
d := Density(c)
hourH := d.ControlHeight() * 2
slotH := hourH * float32(cfg.slot) / 60
g := *ui.Local(root, "time-grid-gesture", func() *timeGesture { return &timeGesture{} })
morning := func() []int {
v := make([]int, len(days))
for i := range v {
v[i] = 8 * 60 / cfg.slot
}
return v
}
cursor := ui.Local(root, "time-grid-cursor", morning)
if len(*cursor) != len(days) {
*cursor = morning()
}
problem := ui.Local(root, "time-grid-problem", func() string { return "" })
scroll := ui.Local(root, "time-grid-scroll", func() *ui.ScrollState { return &ui.ScrollState{Y: hourH * 8} })
lay := eventsDerived(root, "time-grid-layout", events, cfg.version, eventDayStart(days[0], cfg.loc),
eventDayStart(days[len(days)-1].AddDays(1), cfg.loc), func(hits []int) []timeGridDay { return timeGridLayout(events, hits, days, cfg.loc) })
apply := func(ch CalendarChange, p string) {
*problem = p
if p == "" && !root.IsDisabled() {
slog.Debug("mujica: "+cfg.name+" change", "kind", ch.Kind, "id", ch.ID, "start", ch.Start, "end", ch.End)
r.change = &ch
}
}
gutter := d.ControlHeight() * 1.6
ui.Row(c).Children(func() {
ui.Box(c).Width(gutter).Shrink(0)
for _, day := range days {
wd := msg(c, "calendar.weekday_"+strconv.Itoa(int(day.Weekday())))
b := ui.ButtonBase(c).Grow(1).Basis(0).MinHeight(theme.MinHitSize).Gap(d.Unit()).Radius(theme.ControlRadius).Label(datetimeDayLabel(c, day))
internal.Ring(b, k.Focus, theme.ControlRadius)
if day == cfg.today {
b.Background(k.Selection)
} else if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
ui.Text(c, wd).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
t := ui.Text(c, strconv.Itoa(day.Day)).TextColor(k.Text).FontFeatures("tnum")
if day == cfg.today {
t.Bold().TextColor(k.AccentText)
}
})
if b.Clicked() && !b.IsDisabled() {
r.day = day
}
}
})
ui.Row(c).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
ui.Text(c, msg(c, "calendar_week_view.all_day")).Width(gutter).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).
Padding(d.Unit())
for i, day := range days {
cell := ui.Column(c).Grow(1).Basis(0).MinWidth(0).MinHeight(theme.MinHitSize).Padding(d.Unit() / 2).Gap(d.Unit() / 2)
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(cfg.loc), day), cfg.loc))
}
}
}
cell.Children(func() {
for _, h := range lay[i].allDay {
monthChip(c, events[h], cfg.loc, CalendarMonthViewOptions{Hour12: cfg.hour12}, r, apply)
}
})
}
})
ui.Scroll(c).TrackScroll(*scroll).Height(hourH * 9).Children(func() {
ui.Row(c).Children(func() {
ui.Column(c).Width(gutter).Shrink(0).Children(func() {
for h := range 24 {
ui.Text(c, timeText(c, TimeOfDay{h, 0, 0}, timeConfig{hour12: cfg.hour12})).Height(hourH).
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum").PaddingX(d.Unit())
}
})
for i, day := range days {
timeColumn(c, i, day, lay[i], events, cfg, hourH, slotH, g, &(*cursor)[i], r, apply)
}
})
})
datetimeError(c, *problem)
}
// timeGridLayout places each day's events in minutes with side-by-side columns.
func timeGridLayout(events []CalendarEvent, hits []int, days []Date, loc *time.Location) []timeGridDay {
out := make([]timeGridDay, len(days))
for i, day := range days {
from, to := eventDayStart(day, loc), eventDayStart(day.AddDays(1), loc)
var spans []eventSpan
for _, h := range hits {
ev := events[h]
if !ev.Start.Before(to) || !ev.End.After(from) {
continue
}
if ev.AllDay || ev.End.Sub(ev.Start) >= 24*time.Hour {
out[i].allDay = append(out[i].allDay, h)
continue
}
a, b := 0, 24*60
if !ev.Start.Before(from) {
a = timeWallMinutes(ev.Start.In(loc))
}
if ev.End.Before(to) {
b = timeWallMinutes(ev.End.In(loc))
}
b = max(b, a+1)
out[i].timed = append(out[i].timed, timePlaced{ev: h, from: a, to: b})
spans = append(spans, eventSpan{float64(a), float64(b)})
}
col, cols := eventColumns(spans)
for j := range out[i].timed {
out[i].timed[j].col, out[i].timed[j].cols = col[j], cols[j]
}
}
return out
}
func timeWallMinutes(t time.Time) int { return t.Hour()*60 + t.Minute() }
// timeColumn draws one day: lines, events, preview and cursor.
func timeColumn(c *ui.Context, i int, day Date, lay timeGridDay, events []CalendarEvent, cfg timeGridConfig, hourH, slotH float32,
g *timeGesture, cursor *int, r *CalendarViewResult, apply func(CalendarChange, string)) {
k := Tokens(c)
slots := 24 * 60 / cfg.slot
col := ui.Box(c).Grow(1).Basis(0).MinWidth(0).Height(hourH*24).Focusable().
Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "calendar_week_view.column"), datetimeDayLabel(c, day))).BorderWidth(0, 0, 0, theme.BorderWidth).BorderColor(k.Border)
disabled := col.IsDisabled()
slotOf := func(y float32) int { return max(0, min(slots-1, int(y/slotH))) }
if !disabled {
switch {
case col.Shortcut(0, ui.KeyUp):
*cursor = max(0, *cursor-1)
case col.Shortcut(0, ui.KeyDown):
*cursor = min(slots-1, *cursor+1)
case col.Shortcut(0, ui.KeyEnter):
apply(timeCreate(c, day, *cursor, *cursor+1, cfg))
}
_, py, _ := col.PointerPosition()
dx, dy, pressing := col.Dragged()
if pressing && !g.active {
*g = timeGesture{active: true, kind: CalendarCreate, day: i, anchor: slotOf(py - dy), cur: slotOf(py)}
}
if g.active && g.kind == CalendarCreate && g.day == i {
if pressing {
g.dx, g.dy = g.dx+dx, g.dy+dy
g.cur = slotOf(py)
} else {
a, b := min(g.anchor, g.cur), max(g.anchor, g.cur)+1
if math.Abs(float64(g.dy)) >= 4 {
apply(timeCreate(c, day, a, b, cfg))
}
*cursor = g.anchor
*g = timeGesture{}
}
}
}
focused := col.Focused()
cur := *cursor
creating := g.active && g.kind == CalendarCreate && g.day == i
ga, gb := min(g.anchor, g.cur), max(g.anchor, g.cur)+1
col.Draw(func(p *ui.Painter, rr ui.Rect) {
for h := 1; h < 24; h++ {
internal.Rule(p, rr.X, rr.X+rr.W, rr.Y+float32(h)*hourH, k.Border)
}
if focused {
box := ui.Rect{X: rr.X + 2, Y: rr.Y + float32(cur)*slotH + 1, W: rr.W - 4, H: slotH - 2}
p.Fill(box, k.Selection, theme.ControlRadius)
datetimeSelectedMark(p, box, k.Focus)
}
if creating {
box := ui.Rect{X: rr.X + 2, Y: rr.Y + float32(ga)*slotH, W: rr.W - 4, H: float32(gb-ga) * slotH}
p.Fill(box, k.Selection, theme.ControlRadius)
p.StrokeDashed(box, k.AccentText, theme.ControlRadius, theme.BorderWidth)
}
})
col.Children(func() {
now := cfg.now
if now.IsZero() {
now = c.Now()
}
for _, pl := range lay.timed {
timeChip(c, i, pl, events[pl.ev], cfg, hourH, slotH, g, r, apply)
}
if day == DateOf(now.In(cfg.loc)) {
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: cfg.loc, Day: day, Now: cfg.now, Hour12: cfg.hour12})
}
})
}
// timeChip places an event; drag moves, bottom edge resizes.
func timeChip(c *ui.Context, i int, pl timePlaced, ev CalendarEvent, cfg timeGridConfig, hourH, slotH float32,
g *timeGesture, r *CalendarViewResult, apply func(CalendarChange, string)) {
from, to := float32(pl.from)*hourH/60, float32(pl.to)*hourH/60
mine := g.active && g.id == ev.ID && g.day == i
snap := func(v float32) int { return int(math.Round(float64(v/slotH))) * cfg.slot }
var shiftY, growY float32
if mine && g.kind == CalendarMove {
shiftY = float32(snap(g.dy)) * hourH / 60
}
if mine && g.kind == CalendarResize {
growY = float32(snap(g.dy)) * hourH / 60
}
box := ui.Box(c).Key(ev.ID).Absolute().Top(from + shiftY).Height(max(to-from+growY, theme.MinHitSize)).
LeftPercent(100 * float32(pl.col) / float32(pl.cols)).WidthPercent(100 / float32(pl.cols)).Padding(1)
box.Children(func() {
chip := EventChip(c, ev, EventChipOptions{Location: cfg.loc, Hour12: cfg.hour12, Selected: mine, Inline: to-from < hourH*0.75}).Fill()
handle := ui.Box(c).Absolute().Left(0).Right(0).Bottom(0).Height(6).Cursor(ui.CursorResizeNS).Label(fmt.Sprintf(msg(c, "calendar_week_view.resize"), ev.Title))
if chip.IsDisabled() {
return
}
if chip.Clicked() && !mine {
r.opened = ev.ID
}
for key, dm := range map[ui.Key]int{ui.KeyUp: -cfg.slot, ui.KeyDown: cfg.slot} {
if chip.Shortcut(ui.Alt, key) {
apply(timeMove(c, ev, 0, dm, cfg))
}
if chip.Shortcut(ui.Alt|ui.Shift, key) {
apply(timeResize(c, ev, dm, cfg))
}
}
for key, dd := range map[ui.Key]int{ui.KeyLeft: -1, ui.KeyRight: 1} {
if chip.Shortcut(ui.Alt, key) {
apply(timeMove(c, ev, dd, 0, cfg))
}
}
for _, part := range []struct {
e *ui.Element
kind CalendarChangeKind
}{{chip, CalendarMove}, {handle, CalendarResize}} {
dx, dy, ok := part.e.Dragged()
switch {
case ok && (!g.active && (dx != 0 || dy != 0) || mine && g.kind == part.kind):
if !g.active {
*g = timeGesture{active: true, kind: part.kind, id: ev.ID, day: i}
}
g.dx, g.dy = g.dx+dx, g.dy+dy
case !ok && mine && g.kind == part.kind:
dm := snap(g.dy)
if part.kind == CalendarResize {
if dm != 0 {
apply(timeResize(c, ev, dm, cfg))
}
} else if dd := int(math.Round(float64(g.dx / max(chip.Bounds().W*float32(pl.cols), 1)))); dm != 0 || dd != 0 {
apply(timeMove(c, ev, dd, dm, cfg))
}
*g = timeGesture{}
}
}
})
}
// timeCreate makes an event over slots [a, b).
func timeCreate(c *ui.Context, day Date, a, b int, cfg timeGridConfig) (CalendarChange, string) {
s, sn := timeAt(day, a*cfg.slot, cfg.loc)
e, en := timeAt(day, b*cfg.slot, cfg.loc)
if p := eventWallProblem(c, timeDay(day, a*cfg.slot), timeWall(a*cfg.slot), cfg.loc, sn); p != "" {
return CalendarChange{}, p
}
if p := eventWallProblem(c, timeDay(day, b*cfg.slot), timeWall(b*cfg.slot), cfg.loc, en); p != "" {
return CalendarChange{}, p
}
return CalendarChange{Kind: CalendarCreate, Start: s, End: e}, ""
}
// timeMove shifts ev by days and minutes of wall time.
func timeMove(c *ui.Context, ev CalendarEvent, days, minutes int, cfg timeGridConfig) (CalendarChange, string) {
s := ev.Start.In(cfg.loc)
day := DateOf(s).AddDays(days)
m := timeWallMinutes(s) + minutes
at, n := timeAt(day, m, cfg.loc)
if p := eventWallProblem(c, timeDay(day, m), timeWall(m), cfg.loc, n); p != "" {
return CalendarChange{}, p
}
return CalendarChange{Kind: CalendarMove, ID: ev.ID, Start: at, End: at.Add(ev.End.Sub(ev.Start))}, ""
}
// timeResize moves ev's end by minutes of wall time.
func timeResize(c *ui.Context, ev CalendarEvent, minutes int, cfg timeGridConfig) (CalendarChange, string) {
e := ev.End.In(cfg.loc)
day, m := DateOf(e), timeWallMinutes(e)+minutes
at, n := timeAt(day, m, cfg.loc)
if p := eventWallProblem(c, timeDay(day, m), timeWall(m), cfg.loc, n); p != "" {
return CalendarChange{}, p
}
if !at.After(ev.Start) {
return CalendarChange{}, msg(c, "calendar_week_view.too_short")
}
return CalendarChange{Kind: CalendarResize, ID: ev.ID, Start: ev.Start, End: at}, ""
}
// timeAt resolves minute m of day as wall time.
func timeAt(day Date, m int, loc *time.Location) (time.Time, int) {
return dateTimeResolve(timeDay(day, m), timeWall(m), loc)
}
func timeDay(day Date, m int) Date {
return day.AddDays(int(math.Floor(float64(m) / (24 * 60))))
}
func timeWall(m int) TimeOfDay {
m = ((m % (24 * 60)) + 24*60) % (24 * 60)
return TimeOfDay{m / 60, m % 60, 0}
}