wip calendar
This commit is contained in:
3 files changed
+394
No files matched your search
@@ -0,0 +1,119 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AttendeeStatus is an attendee's reply.
|
||||
type AttendeeStatus int
|
||||
|
||||
const (
|
||||
AttendeePending AttendeeStatus = iota
|
||||
AttendeeAccepted
|
||||
AttendeeTentative
|
||||
AttendeeDeclined
|
||||
)
|
||||
|
||||
// Attendee is one person invited to an event.
|
||||
type Attendee struct {
|
||||
Name string
|
||||
Email string
|
||||
Status AttendeeStatus
|
||||
Organizer bool
|
||||
Optional bool
|
||||
}
|
||||
|
||||
// AttendeeListOptions configure an AttendeeList.
|
||||
type AttendeeListOptions struct {
|
||||
// Removable shows a remove button on everyone but the organizer.
|
||||
Removable bool
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// AttendeeListResult is the list and the attendee the user removed.
|
||||
type AttendeeListResult struct {
|
||||
Element *ui.Element
|
||||
removed int
|
||||
}
|
||||
|
||||
// Removed returns the index of the attendee the user removed.
|
||||
func (r AttendeeListResult) Removed() (int, bool) { return r.removed, r.removed >= 0 }
|
||||
|
||||
// AttendeeList shows invitees with their reply, in words and an icon, and a reply summary.
|
||||
func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) AttendeeListResult {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
var counts [4]int
|
||||
for _, a := range attendees {
|
||||
if a.Status < AttendeePending || a.Status > AttendeeDeclined {
|
||||
panic(fmt.Sprintf("mujica: AttendeeList status %d out of range", a.Status))
|
||||
}
|
||||
counts[a.Status]++
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "attendee_list.label")
|
||||
}
|
||||
r := AttendeeListResult{removed: -1}
|
||||
r.Element = ui.Column(c).Gap(d.Unit()).Role(ui.RoleList).Label(label).Disabled(opts.Disabled)
|
||||
r.Element.Children(func() {
|
||||
summary := fmt.Sprintf(msg(c, "attendee_list.summary"), counts[AttendeeAccepted], counts[AttendeeTentative],
|
||||
counts[AttendeeDeclined], counts[AttendeePending])
|
||||
ui.Text(c, summary).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
|
||||
if len(attendees) == 0 {
|
||||
ui.Text(c, msg(c, "attendee_list.empty")).TextColor(k.TextMuted)
|
||||
}
|
||||
for i, a := range attendees {
|
||||
ui.Row(c).Key(i).MinHeight(d.ControlHeight()).Gap(d.Unit()*2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
|
||||
Avatar(c, a.Name, AvatarOptions{Size: 28})
|
||||
ui.Column(c).Grow(1).Shrink(1).Children(func() {
|
||||
name := a.Name
|
||||
if a.Organizer {
|
||||
name += " · " + msg(c, "attendee_list.organizer")
|
||||
}
|
||||
if a.Optional {
|
||||
name += " · " + msg(c, "attendee_list.optional")
|
||||
}
|
||||
ui.Text(c, name).TextColor(k.Text).SingleLine().Ellipsis("…")
|
||||
if a.Email != "" {
|
||||
ui.Text(c, a.Email).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Ellipsis("…")
|
||||
}
|
||||
})
|
||||
icon, fg := attendeeStatusLook(k, a.Status)
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Shrink(0).TextColor(fg).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(16, 16)
|
||||
ui.Text(c, msg(c, attendeeStatusKeys[a.Status])).FontSize(FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
if opts.Removable && !a.Organizer {
|
||||
b := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "attendee_list.remove"), a.Name))
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
slog.Debug("mujica: attendee_list remove", "index", i)
|
||||
r.removed = i
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
var attendeeStatusKeys = [...]string{"attendee_list.pending", "attendee_list.accepted", "attendee_list.tentative", "attendee_list.declined"}
|
||||
|
||||
func attendeeStatusLook(k theme.Tokens, s AttendeeStatus) (icon string, fg ui.Color) {
|
||||
switch s {
|
||||
case AttendeeAccepted:
|
||||
return "circle-check", k.Success
|
||||
case AttendeeTentative:
|
||||
return "circle-alert", k.Warning
|
||||
case AttendeeDeclined:
|
||||
return "circle-x", k.Danger
|
||||
}
|
||||
return "clock", k.TextMuted
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// EventColor picks an event's color from the theme's event palette.
|
||||
type EventColor int
|
||||
|
||||
const (
|
||||
EventAccent EventColor = iota
|
||||
EventInfo
|
||||
EventSuccess
|
||||
EventWarning
|
||||
EventDanger
|
||||
EventNeutral
|
||||
)
|
||||
|
||||
// CalendarEvent is one entry of the calendar views. End is exclusive.
|
||||
type CalendarEvent struct {
|
||||
ID string
|
||||
Title string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
AllDay bool
|
||||
Color EventColor
|
||||
Location string
|
||||
// Attendees, Reminders and Repeat are edited by EventEditor.
|
||||
Attendees []Attendee
|
||||
Reminders []time.Duration
|
||||
Repeat Recurrence
|
||||
}
|
||||
|
||||
// CalendarChangeKind says what a view gesture did.
|
||||
type CalendarChangeKind int
|
||||
|
||||
const (
|
||||
CalendarMove CalendarChangeKind = iota
|
||||
CalendarResize
|
||||
CalendarCreate
|
||||
)
|
||||
|
||||
// CalendarChange is a move, resize or new event a view asks the caller to apply.
|
||||
type CalendarChange struct {
|
||||
Kind CalendarChangeKind
|
||||
ID string
|
||||
Start, End time.Time
|
||||
AllDay bool
|
||||
}
|
||||
|
||||
// CalendarViewResult is a calendar view and what the user asked of it.
|
||||
type CalendarViewResult struct {
|
||||
Element *ui.Element
|
||||
opened string
|
||||
change *CalendarChange
|
||||
day Date
|
||||
}
|
||||
|
||||
// Opened returns the event the user activated.
|
||||
func (r CalendarViewResult) Opened() (string, bool) { return r.opened, r.opened != "" }
|
||||
|
||||
// Changed returns the move, resize or creation the user made.
|
||||
func (r CalendarViewResult) Changed() (CalendarChange, bool) {
|
||||
if r.change == nil {
|
||||
return CalendarChange{}, false
|
||||
}
|
||||
return *r.change, true
|
||||
}
|
||||
|
||||
// DayChosen returns the day the user activated.
|
||||
func (r CalendarViewResult) DayChosen() (Date, bool) { return r.day, !r.day.IsZero() }
|
||||
|
||||
// eventPalette maps EventColor to tokens that keep 3:1 on Surface in both themes.
|
||||
func eventPalette(k theme.Tokens) [6]ui.Color {
|
||||
return [6]ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Danger, k.TextMuted}
|
||||
}
|
||||
|
||||
func eventColor(k theme.Tokens, c EventColor) ui.Color {
|
||||
if c < EventAccent || c > EventNeutral {
|
||||
panic(fmt.Sprintf("mujica: EventColor %d out of range", c))
|
||||
}
|
||||
return eventPalette(k)[c]
|
||||
}
|
||||
|
||||
// eventsCheck panics on events no view can place.
|
||||
func eventsCheck(name string, events []CalendarEvent) {
|
||||
for _, e := range events {
|
||||
if e.ID == "" {
|
||||
panic("mujica: " + name + " event without ID")
|
||||
}
|
||||
if !e.End.After(e.Start) {
|
||||
panic(fmt.Sprintf("mujica: %s event %q ends before it starts", name, e.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eventsIndex caches the events overlapping a span, rebuilt only when data or span change.
|
||||
type eventsIndex struct {
|
||||
first *CalendarEvent
|
||||
n int
|
||||
version int
|
||||
from, to time.Time
|
||||
hits []int
|
||||
}
|
||||
|
||||
// eventsIn returns the indexes of events overlapping [from, to), by start time.
|
||||
func eventsIn(e *ui.Element, events []CalendarEvent, version int, from, to time.Time) []int {
|
||||
ix := ui.Local(e, "mujica.events.index", func() *eventsIndex { return &eventsIndex{} })
|
||||
var first *CalendarEvent
|
||||
if len(events) > 0 {
|
||||
first = &events[0]
|
||||
}
|
||||
if c := *ix; c.first == first && c.n == len(events) && c.version == version && c.from.Equal(from) && c.to.Equal(to) && c.hits != nil {
|
||||
return c.hits
|
||||
}
|
||||
hits := []int{}
|
||||
for i, ev := range events {
|
||||
if ev.Start.Before(to) && ev.End.After(from) {
|
||||
hits = append(hits, i)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(hits, func(a, b int) bool {
|
||||
x, y := events[hits[a]], events[hits[b]]
|
||||
if !x.Start.Equal(y.Start) {
|
||||
return x.Start.Before(y.Start)
|
||||
}
|
||||
return x.End.After(y.End)
|
||||
})
|
||||
**ix = eventsIndex{first, len(events), version, from, to, hits}
|
||||
return hits
|
||||
}
|
||||
|
||||
// eventSpan is a placed interval, in any unit.
|
||||
type eventSpan struct{ from, to float64 }
|
||||
|
||||
// eventColumns lays out overlapping spans side by side: each gets a column and its cluster's width.
|
||||
func eventColumns(spans []eventSpan) (col, cols []int) {
|
||||
col, cols = make([]int, len(spans)), make([]int, len(spans))
|
||||
order := make([]int, len(spans))
|
||||
for i := range order {
|
||||
order[i] = i
|
||||
}
|
||||
sort.SliceStable(order, func(a, b int) bool { return spans[order[a]].from < spans[order[b]].from })
|
||||
var ends []float64
|
||||
var cluster []int
|
||||
clusterEnd := -1.0
|
||||
flush := func() {
|
||||
for _, i := range cluster {
|
||||
cols[i] = len(ends)
|
||||
}
|
||||
cluster, ends = cluster[:0], ends[:0]
|
||||
}
|
||||
for _, i := range order {
|
||||
s := spans[i]
|
||||
if len(cluster) > 0 && s.from >= clusterEnd {
|
||||
flush()
|
||||
}
|
||||
placed := false
|
||||
for c, end := range ends {
|
||||
if end <= s.from {
|
||||
ends[c], col[i], placed = s.to, c, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !placed {
|
||||
col[i] = len(ends)
|
||||
ends = append(ends, s.to)
|
||||
}
|
||||
cluster = append(cluster, i)
|
||||
clusterEnd = max(clusterEnd, s.to)
|
||||
}
|
||||
flush()
|
||||
return col, cols
|
||||
}
|
||||
|
||||
// eventDayStart is midnight of d in loc; a missing midnight resolves to the day's first instant.
|
||||
func eventDayStart(d Date, loc *time.Location) time.Time {
|
||||
return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
// eventTimeText formats an event's wall-clock span in loc.
|
||||
func eventTimeText(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) string {
|
||||
if ev.AllDay {
|
||||
return msg(c, "event_chip.all_day")
|
||||
}
|
||||
cfg := timeConfig{hour12: hour12}
|
||||
s, e := ev.Start.In(loc), ev.End.In(loc)
|
||||
text := timeText(c, TimeOfDay{s.Hour(), s.Minute(), 0}, cfg) + " – " + timeText(c, TimeOfDay{e.Hour(), e.Minute(), 0}, cfg)
|
||||
return text
|
||||
}
|
||||
|
||||
// eventLocation panics without a caller zone; views never assume UTC.
|
||||
func eventLocation(name string, loc *time.Location) *time.Location {
|
||||
if loc == nil {
|
||||
panic("mujica: " + name + " needs a Location")
|
||||
}
|
||||
return loc
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// EventChipOptions configure an EventChip.
|
||||
type EventChipOptions struct {
|
||||
// Location is the zone the time shows in; required.
|
||||
Location *time.Location
|
||||
Hour12 bool
|
||||
// Inline puts time and title on one line.
|
||||
Inline bool
|
||||
Selected bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// EventChip is a clickable event block: color stripe, time and title, truncated to fit.
|
||||
func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element {
|
||||
loc := eventLocation("EventChip", opts.Location)
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
col := eventColor(k, ev.Color)
|
||||
when := eventTimeText(c, ev, loc, opts.Hour12)
|
||||
title := ev.Title
|
||||
if title == "" {
|
||||
title = msg(c, "event_chip.untitled")
|
||||
}
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Justify(ui.Start).AlignItems(ui.Start).
|
||||
Radius(theme.ControlRadius).Padding(d.Unit(), d.Unit()*2).PaddingX(d.Unit()*2).Gap(d.Unit()).
|
||||
Label(title + ", " + when).Disabled(opts.Disabled).Clip()
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
bg := col.Alpha(0.14).Over(k.Surface)
|
||||
switch {
|
||||
case opts.Selected:
|
||||
bg = k.Selection
|
||||
case b.Pressed():
|
||||
bg = col.Alpha(0.24).Over(k.Surface)
|
||||
case b.Hovered():
|
||||
bg = col.Alpha(0.2).Over(k.Surface)
|
||||
}
|
||||
b.Background(bg).Transition(colorFade)
|
||||
if !opts.Inline {
|
||||
b.Column()
|
||||
}
|
||||
b.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, col, 0)
|
||||
})
|
||||
selected := opts.Selected
|
||||
b.DrawOver(func(p *ui.Painter, r ui.Rect) {
|
||||
if selected {
|
||||
datetimeSelectedMark(p, r, col)
|
||||
}
|
||||
})
|
||||
b.Children(func() {
|
||||
small := FontSize(c, theme.CaptionSize)
|
||||
if opts.Inline {
|
||||
ui.Text(c, when).FontSize(small).TextColor(k.TextMuted).SingleLine().Shrink(0).FontFeatures("tnum")
|
||||
}
|
||||
ui.Text(c, title).FontSize(small).Bold().TextColor(k.Text).SingleLine().Ellipsis("…").Shrink(1)
|
||||
if !opts.Inline {
|
||||
ui.Text(c, when).FontSize(small).TextColor(k.TextMuted).SingleLine().Ellipsis("…").FontFeatures("tnum")
|
||||
}
|
||||
})
|
||||
return b
|
||||
}
|
||||
Reference in new issue
Block a user