diff --git a/attendee_list.go b/attendee_list.go new file mode 100644 index 0000000..32b3173 --- /dev/null +++ b/attendee_list.go @@ -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 +} diff --git a/calendar_events.go b/calendar_events.go new file mode 100644 index 0000000..6599680 --- /dev/null +++ b/calendar_events.go @@ -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 +} diff --git a/event_chip.go b/event_chip.go new file mode 100644 index 0000000..51acf51 --- /dev/null +++ b/event_chip.go @@ -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 +}