wip calendar

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:03:40 -04:00
1 parent 5a5b04fab6
commit 4cb68857c1
3 files changed
+394

No files matched your search

+119
View File
@@ -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
}
+204
View File
@@ -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
}
+71
View File
@@ -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
}